Excalidraw 接入 Next.js:with-nextjs 示例中 App Router 与 Pages Router 的完整集成方案
Excalidraw(手绘风格虚拟白板)提供官方的 with-nextjs 集成示例,演示了如何将 @excalidraw/excalidraw 库嵌入一个由 create-next-app 引导的 Next.js 项目。本文以该示例为骨架,从开发服务启动、客户端组件渲染、字体资源自托管,到构建产物与 Vercel 部署配置逐层拆解,读完后你可以在自己的 Next.js 应用(App Router 或 Pages Router)中正确接入 Excalidraw 组件,并理解 SSR 场景下必须处理的几个关键细节。
示例项目定位与目录结构
with-nextjs 是 Excalidraw monorepo 下 examples/* workspace 的成员之一(见根目录 package.json 中的 workspaces 字段)。与同仓库的 with-script-in-browser 示例 不同,它演示的是"以 npm 库形式引入 Excalidraw"的集成路径,而不是通过 script 标签加载打包产物。
示例的核心文件分布如下:
- src/app/page.tsx:App Router 入口页,负责动态导入客户端组件;
- src/pages/excalidraw-in-pages.tsx:Pages Router 版本页面,与 App Router 页面互相跳转;
- src/excalidrawWrapper.tsx:客户端包装组件,桥接 Excalidraw 库与示例应用;
- src/common.scss:页面基础样式;
- next.config.js、vercel.json:构建与部署配置;
- public/images/:示例运行时加载的图片资源(如
rocket.jpeg会被fetch后转成 DataURL 注入画布)。
值得注意的一点:该示例的 React 侧代码复用了 with-script-in-browser 中的 ExampleApp 组件,它通过 cloneElement 向 <Excalidraw /> 注入 excalidrawAPI、initialData、UIOptions 等属性,并演示了导出(exportToSvg/exportToBlob/exportToCanvas)、更新场景、协作头像列表等 API 用法。因此阅读 Next.js 示例时,实际交互逻辑要追溯到 ExampleApp.tsx 去看。
Getting Started:启动开发服务
按 README 的说明,可以在 examples/with-nextjs 目录下运行以下任一命令启动开发服务器:
npm run dev
# 或
yarn dev
# 或
pnpm dev
# 或
bun dev
但结合 package.json 的 scripts 可以看到,实际的 dev 脚本比 README 描述的多了两步前置工作:
"dev": "yarn build:workspace && next dev -p 3005",
"build:workspace": "yarn build:packages && yarn copy:assets",
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public"
即每次启动开发服务前,会先执行:
build:packages:在仓库根目录按依赖顺序构建common、fractional-indexing、laser-pointer、math、element、excalidraw六个 ESM 包(对应根 package.json 中的build:packages脚本);copy:assets:将packages/excalidraw/dist/prod/fonts下的字体文件复制到示例的public/目录,为字体自托管做准备(下文详述)。
另外,README 正文写的是打开 http://localhost:3000,但 next dev -p 3005 实际把开发服务跑在 3005 端口(README 中的超链接指向的也正是 3005);生产模式则通过 yarn build 构建、yarn start 以 3006 端口 启动。以当前仓库脚本的实际配置为准即可。
核心集成点一:用 dynamic + ssr: false 隔离浏览器 API
Excalidraw 组件依赖 Canvas、window 等浏览器 API,在 Next.js 的服务端预渲染阶段直接导入会失败。示例的解法是把所有 Excalidraw 相关代码收敛到一个显式声明 "use client" 的模块 excalidrawWrapper.tsx:
"use client";
import * as excalidrawLib from "@excalidraw/excalidraw";
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
import App from "../../with-script-in-browser/components/ExampleApp";
const ExcalidrawWrapper: React.FC = () => {
return (
<>
<App
appTitle={"Excalidraw with Nextjs Example"}
useCustom={(api: any, args?: any[]) => {}}
excalidrawLib={excalidrawLib}
>
<Excalidraw />
</App>
</>
);
};
export default ExcalidrawWrapper;
然后在服务端页面里通过 next/dynamic 以 ssr: false 动态加载(见 page.tsx):
import dynamic from "next/dynamic";
import Script from "next/script";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
// with ssr false
const ExcalidrawWithClientOnly = dynamic(
async () => (await import("../excalidrawWrapper")).default,
{
ssr: false,
},
);
export default function Page() {
return (
<>
<a href="/excalidraw-in-pages">Switch to Pages router</a>
<h1 className="page-title">App Router</h1>
<Script id="load-env-variables" strategy="beforeInteractive">
{`window["EXCALIDRAW_ASSET_PATH"] = window.origin;`}
</Script>
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
<ExcalidrawWithClientOnly />
</>
);
}
这个分层是关键:服务端页面只持有 dynamic 的懒加载句柄,@excalidraw/excalidraw 的整包导入和 index.css 引入全部发生在 client 组件内部。如果直接在 app/page.tsx 里 import { Excalidraw },预渲染阶段就会触及浏览器环境。
核心集成点二:字体自托管与 EXCALIDRAW_ASSET_PATH
copy:assets 把构建产物中的字体复制进 public/,而 <Script strategy="beforeInteractive"> 则在客户端模块加载之前提前设置:
window["EXCALIDRAW_ASSET_PATH"] = window.origin;
这个全局变量决定了 Excalidraw 从哪个基地址加载字体等资源。从源码结构看,字体加载逻辑位于 ExcalidrawFontFace.ts:当 window.EXCALIDRAW_ASSET_PATH 是字符串时按其构造字体 URL,是数组时依次作为多级 fallback 基地址;未设置时则走默认的 CDN 地址。这与 packages/excalidraw/README.md 的说明一致:自托管场景下应把 dist/prod/fonts 的内容复制到应用静态资源目录(如 public/),并把 EXCALIDRAW_ASSET_PATH 指向同一目录;该 README 同时提醒"不要刻意设置 window.EXCALIDRAW_ASSET_PATH,除非你确实要自托管字体/资源"。
示例选择 window.origin 作为取值,意味着开发/生产环境下字体都从站点自身路径读取——这正是 copy:assets 把字体放进 public/ 的原因,两者配套才构成完整的自托管链路。
App Router 与 Pages Router 的双路由演示
该示例同时覆盖 Next.js 的两种路由体系,两个页面通过链接互相切换:
- App Router 页面 src/app/page.tsx 提供 "Switch to Pages router" 链接,并额外包含上述的
<Script>字体变量注入; - Pages Router 页面 src/pages/excalidraw-in-pages.tsx 结构几乎相同,同样使用
dynamic(..., { ssr: false })懒加载excalidrawWrapper,但没有注入EXCALIDRAW_ASSET_PATH。从源码结构看,这意味着 Pages Router 页面上字体将按默认 CDN 策略加载,而不是本地public/副本。
两种写法共享同一个客户端包装层,说明该集成方案对路由体系是正交的——差异只体现在页面层如何挂载动态组件。
构建与部署配置
next.config.js
next.config.js 做了三处针对性配置:
/** @type {import('next').NextConfig} */
const nextConfig = {
distDir: "build",
typescript: {
// The ts config doesn't work with `jsx: preserve" and if updated to `react-jsx` it gets ovewritten by next js throwing ts errors hence I am ignoring build errors until this is fixed.
ignoreBuildErrors: true,
},
// This is needed as in pages router the code for importing types throws error as its outside next js app
transpilePackages: ["../"],
};
module.exports = nextConfig;
distDir: "build":把构建输出目录从默认的.next改为build,与 Vercel 配置对接;typescript.ignoreBuildErrors: true:注释说明是因为jsx配置与 Next.js 的类型检查存在冲突,暂时忽略构建期类型报错;transpilePackages: ["../"]:Pages Router 下跨 workspace 导入with-script-in-browser的代码会因位于 Next.js 应用目录之外而报错,这里通过 transpilePackages 让 Next.js 一并转译该目录。
vercel.json
vercel.json 只有两个字段,但都与上面的 distDir 呼应:
{
"git": {
"deploymentEnabled": {
"master": true,
"**": false
}
},
"outputDirectory": "build"
}
outputDirectory 指向 build(即 Next.js 的实际产物目录),git.deploymentEnabled 限定仅 master 分支触发 Vercel 部署。README 的 "Deploy on Vercel" 一节描述的正是这一部署路径:构建命令即 package.json 中的 yarn build(同样会先执行 build:workspace 完成依赖包构建与字体复制),产物目录由 outputDirectory 指定。
实践要点小结
把 README 的 Getting Started 流程与示例源码对照起来,在 Next.js 中接入 Excalidraw 的完整要点是:
- 隔离服务端渲染:把
@excalidraw/excalidraw的导入与index.css放入"use client"模块,页面层用dynamic(..., { ssr: false })加载; - 字体自托管:构建前把
dist/prod/fonts复制到public/,并用beforeInteractive脚本设置window.EXCALIDRAW_ASSET_PATH,两者缺一不可; - 跨 workspace 引用:复用其他示例目录的代码时,用
transpilePackages处理 Pages Router 的转译问题; - 部署对齐:
distDir与 Vercel 的outputDirectory保持一致。
README 中的 "Learn More" 部分指向的是 Next.js 官方文档与教程(外部资源,此处不附链接);而在仓库内部,若想继续深入 Excalidraw 组件本身的 API 与集成规范,可从 packages/excalidraw/README.md 入手,它包含了 EXCALIDRAW_ASSET_PATH 的完整取值说明(字符串或多基地址数组)以及自托管的注意事项。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00