在 Next.js 中集成 Electric 同步引擎:路由代理、认证与写入模式的实战指南
【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric
Electric 是一个基于 Postgres 逻辑复制的同步引擎,而 Next.js 是一个全栈 React 框架。本文以仓库文档 website/docs/sync/integrations/next.md 为核心骨架,讲解如何将两者组合:在客户端组件中使用 Electric 的 React Hooks 消费 Shape 数据,通过 Next.js Route Handler 代理 Shape 请求以保证密钥不暴露,并将写入路径交给自己的 API。读完本文,你将掌握在 Next.js 应用里落地实时数据同步的完整方案:从useShape绑定数据、到服务端代理与授权、再到四种写入模式的选择。
Electric 与 Next.js 的集成定位
Next.js 基于 React 构建。Electric 对 React 有一等公民的支持,维护了 @electric-sql/react 包,提供useShape、preloadShape、getShapeStream、getShape等 Hooks 与辅助函数,用于把 Shape 数据绑定到组件上(见 React 集成文档)。因此,在 Next.js 中集成 Electric 与集成任何其他 npm / React 库没有本质区别——你既可以在纯 React 前端使用它,也可以充分利用 Next.js 的服务端能力(Route Handler、中间件、SSR)来加固数据访问。
仓库中的示例现状
需要注意的是,本仓库当前没有维护examples/nextjs示例应用。根据 next.md 的说明,之前的示例于 2026 年 4 月 23 日从main分支移除,原因是其落后于受支持的 Next.js 与 React 技术栈。这一点在 Next.js 示例页 中也有同步说明。
不过,集成 Next.js 所需的关键环节并不依赖那个被移除的示例,而是由当前仓库中仍在维护的组件构成:
- 客户端:React Hooks 包 与 TypeScript 客户端,用于在客户端组件中订阅 Shape。
- 服务端:认证指南 提供的 Route Handler / 代理实现,保证密钥留在服务端。
- 写入路径:写入模式指南 提供的四种写入模式。
- 数据模型:Shape 指南 提供的 Shape 定义(表、where、columns、queryable columns)。
历史参考:仓库中保留的 Next.js demo 页面 可作为历史部署参考,但当前实现应以本文引用的各指南为准。
第一步:在客户端组件中使用 React Hooks 订阅 Shape
安装依赖
在 Next.js 项目中安装 React Hooks 包(它依赖 TypeScript 客户端@electric-sql/client,后者会作为依赖被引入):
npm i @electric-sql/react若需要直接使用ShapeStream/Shape底层原语,也可单独安装:
npm i @electric-sql/client用useShape绑定数据
在客户端组件("use client")中,useShape会把一个物化后的 Shape 绑定到状态变量上,并在数据更新时自动触发重渲染。推荐的生产模式是把请求指向你自己的 API 端点,由服务端代理到 Electric:
"use client" import { useShape } from '@electric-sql/react' const MyComponent = () => { const { isLoading, data } = useShape<{ title: string }>({ url: `/api/items`, // 你的 Next.js Route Handler,服务端再代理到 Electric }) if (isLoading) { return <div>Loading ...</div> } return ( <div> {data.map((item) => ( <div key={item.title}>{item.title}</div> ))} </div> ) }在开发环境或可信环境,也可以直接连接 Electric 服务,并通过params传递 Shape 定义(表名、where、columns 等 Postgres 特定参数):
"use client" import { useShape } from '@electric-sql/react' const MyFilteredComponent = () => { const { isLoading, data } = useShape<{ id: number; title: string }>({ url: `http://localhost:3000/v1/shape`, params: { table: 'items', where: "status = 'active'", columns: ['id', 'title'], }, }) if (isLoading) { return <div>Loading ...</div> } return ( <div> {data.map((item) => ( <div key={item.id}>{item.title}</div> ))} </div> ) }⚠️ 直接连接会暴露数据库结构,仅限开发环境。生产应用必须走服务端代理。
useShape接受与ShapeStream相同的 options,返回UseShapeResult:
| 字段 | 类型 | 说明 |
|---|---|---|
data | T[] | 组成物化 Shape 的行数组 |
shape | Shape<T> | 该useShape使用的 Shape 实例 |
isLoading | boolean | 初始拉取期间为true,之后为false |
lastSyncedAt | number? | 最近一次同步的 Unix 时间,isLoading时为undefined |
isError | boolean | 是否出错 |
error | Shape<T>['error'] | 错误对象 |
其他 Hooks 与辅助函数
除了useShape,react-hooks.tsx 还导出了几个适合在路由加载阶段或模块级使用的函数(其内部实现基于全局缓存,可避免同一 Shape 重复建流、重复物化):
preloadShape:在路由加载函数中提前确保 Shape 数据就绪后再渲染。从源码看(react-hooks.tsx#L17-L24),它内部先getShapeStream再getShape,并await shape.rows直到数据完全加载。非常适合 Next.js 的generateStaticParams、Server Component 预取或客户端路由加载阶段:
// 生产模式:走 API 代理 export const clientLoader = async () => { return await preloadShape({ url: `/api/items`, }) } // ⚠️ 开发模式:直接连接 export const devLoader = async () => { return await preloadShape({ url: `http://localhost:3000/v1/shape`, params: { table: 'items', }, }) }getShapeStream<T>(react-hooks.tsx#L47):基于全局缓存 get-or-create 一个ShapeStream,避免对同一 Shape 日志建立多个流。缓存键是sortedOptionsHash(options)——对所有 options 按键排序后序列化,因此不同组件只要传入相同配置就会复用同一流。getShape<T>(react-hooks.tsx#L71):基于全局缓存 get-or-create 一个物化Shape,避免对同一流多次物化。
// ✅ 生产模式 const itemsStream = getShapeStream<Item>({ url: `/api/items`, }) const itemsShape = getShape<Item>(itemsStream)组件卸载时中止订阅
如果希望在组件卸载或路由离开时中止 Shape 对实时更新的订阅,可以使用AbortController。注意:如果有多个组件共享同一个流(getShapeStream的缓存会让这种情况很容易发生),中止会停止所有订阅者,因此请谨慎使用:
function MyComponent() { const [controller, _] = useState(new AbortController()) const { data } = useShape({ url: `/api/items`, signal: controller.signal, }) useEffect(() => { return () => { // 实时更新现在被禁用 controller.abort() } }, []) // ... }第二步:通过 Route Handler 代理 Shape 请求
为什么要代理
Electric 的同步 API 是纯 HTTP:GET /v1/shape携带 Shape 定义(?table=items等)即可订阅数据。这意味着你可以像保护任何 Web 资源一样保护 Shape——在请求到达 Electric 之前,先经过你的 Next.js Route Handler(或中间件、边缘函数)完成鉴权与授权。
代理模式的核心原则是服务端控制 Shape 定义:table、queryable_columns、主where以及 API 密钥secret都必须在服务端设置,绝不能交给客户端;客户端只能传递offset、handle、live、live_sse、replica、log等协议参数,以及 POST body 中的子集查询参数。这样即使客户端恶意传参,也无法扩大数据访问范围(详见 认证指南 的“Parameters your proxy must control”一节)。
完整的 Route Handler 实现
下面是一个可直接放入app/api/items/route.ts的完整实现,它综合了 认证指南 的代理示例:
// app/api/items/route.ts import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client' export async function GET(request: Request) { const url = new URL(request.url) // 构造上游 Electric 地址 const originUrl = new URL(`http://localhost:3000/v1/shape`) // 只透传 Electric 协议参数(offset、handle、live 等) url.searchParams.forEach((value, key) => { if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { originUrl.searchParams.set(key, value) } }) // 表名由服务端设置,不接受客户端参数 originUrl.searchParams.set(`table`, `items`) // // 认证与授权 // const user = await loadUser(request.headers.get(`authorization`)) // 用户不存在则返回 401 if (!user) { return new Response(`user not found`, { status: 401 }) } // 非管理员只能访问自己组织的数据。 // 使用参数化查询防止 SQL 注入 if (!user.roles.includes(`admin`)) { originUrl.searchParams.set(`where`, `org_id = $1`) originUrl.searchParams.set(`params[1]`, user.org_id) } const response = await fetch(originUrl) // fetch 会解压 body,但不会移除 content-encoding 与 content-length // 头,这会导致浏览器解码失败。 // 参见 https://github.com/whatwg/fetch/issues/1729 const headers = new Headers(response.headers) headers.delete(`content-encoding`) headers.delete(`content-length`) return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }) }关键点:
ELECTRIC_PROTOCOL_QUERY_PARAMS来自@electric-sql/client,是客户端需要透传的协议参数白名单(offset、handle、live等),代理只放行这些参数。- 表名与授权
where全部服务端设定;非管理员用org_id = $1+params[1]做参数化过滤,防止 SQL 注入。 - 返回前必须删除
content-encoding与content-length头(fetch 已解压 body,保留这两个头会破坏浏览器端解码)。
类型安全的 where 子句生成
当授权规则复杂时,手拼 SQL 字符串容易出错。可以用 Drizzle 或 Kysely 在编译期生成类型安全的 where 子句。由于 Electric 的 HTTP API 通过独立查询参数(params[1]=value、params[2]=value)传递参数值来安全替换$1、$2占位符,代理只需提取 where 片段并把参数逐个写入即可。
Drizzle(用column.name+sql.identifier()生成不带表前缀的列名,因为 Electric 期望不带表前缀的列名):
import { QueryBuilder } from 'drizzle-orm/pg-core' import { sql } from 'drizzle-orm' import { users } from './schema' // 你的 Drizzle schema 定义 export async function GET(request: Request) { // ... 前置代码 ... const user = await loadUser(request.headers.get('authorization')) if (!user || user.roles.includes('admin')) { // 管理员看到全部 } else { // 类型安全的 where 表达式;引用不存在的列会在编译期报错 const whereExpr = sql`${sql.identifier(users.org_id.name)} = ${user.org_id}` // 无需数据库连接即可编译为 SQL 片段 const qb = new QueryBuilder() const { sql: query, params } = qb .select() .from(users) .where(whereExpr) .toSQL() // 提取 WHERE 子句片段 const fragment = query.replace(/^SELECT .* FROM .* WHERE\s+/i, '') originUrl.searchParams.set('where', fragment) // 参数逐个写入:params[1]=value, params[2]=value ... params.forEach((value, index) => { originUrl.searchParams.set(`params[${index + 1}]`, String(value)) }) } // ... fetch 并返回响应 ... }Kysely(编译后需要去掉表前缀):
import { db } from './db' // 带生成类型的 Kysely 实例 export async function GET(request: Request) { // ... 前置代码 ... if (!user.roles.includes('admin')) { // 引用无效列会在编译期报错 const query = db .selectFrom('users') .selectAll() .where('org_id', '=', user.org_id) .where('status', '=', 'active') const { sql: query, parameters } = query.compile() let fragment = query.replace(/^SELECT .* FROM .* WHERE\s+/i, '') fragment = fragment.replace(/\b\w+\./g, '') // 去掉表前缀 originUrl.searchParams.set('where', fragment) parameters.forEach((value, index) => { originUrl.searchParams.set(`params[${index + 1}]`, String(value)) }) } }两种方式都提供编译期校验(引用无效列、错误类型、不兼容操作符都会报错)、SQL 注入防护、重命名安全与 IDE 自动补全。
支持 POST 子集查询
当 where 子句变得很大(复杂的 ACL 子查询、大量参数,或WHERE id = ANY($1)包含数百个 ID)时,GET 请求会因 URL 过长而返回HTTP 414 Request-URI Too Long。Electric 支持用 POST 把子集参数放在 JSON body 中。
POST body 支持的参数:
| 参数 | 类型 | 说明 |
|---|---|---|
where | string | 过滤子集的 WHERE 子句 |
params | object | 参数,形如{"1": "value1", "2": "value2"},对应$1、$2占位符 |
limit | integer | 最大返回行数(需要order_by) |
offset | integer | 分页跳过的行数(需要order_by) |
order_by | string | ORDER BY 子句(使用 limit/offset 时必填) |
{ "where": "\"organization_id\" = $1 AND (\"owner_user_id\" = $2 OR ...)", "params": {"1": "org_123", "2": "user_456"}, "order_by": "created_at DESC", "limit": 100 }安全模型:Electric 始终用AND组合主 Shape where(URL 中)与子集 where(POST body 中),即WHERE {main_shape_where} AND ({subset_where})。因此子集查询只能收窄结果、永远不能扩大访问范围;即使客户端发送where: "1=1",主 where 依然生效。子集 where 还会校验语法、禁止子查询,并在设置queryable_columns时限制只能引用这些列。
代理需要同时支持 GET 与 POST,并始终在服务端设置table、queryable_columns、主where与secret:
import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client' export async function handler(request: Request) { const url = new URL(request.url) const method = request.method const originUrl = new URL(`http://localhost:3000/v1/shape`) // 透传协议参数(offset、handle、live 等) url.searchParams.forEach((value, key) => { if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { originUrl.searchParams.set(key, value) } }) // 认证 const user = await loadUser(request.headers.get(`authorization`)) if (!user) { return new Response(`unauthorized`, { status: 401 }) } // 服务端设置 Shape 定义(这是你的授权层) originUrl.searchParams.set(`table`, `items`) originUrl.searchParams.set(`queryable_columns`, `id,title,created_at`) originUrl.searchParams.set(`columns`, `id,title`) originUrl.searchParams.set(`where`, `"organization_id" = '${user.org_id}'`) originUrl.searchParams.set(`secret`, process.env.ELECTRIC_SECRET) // 转发请求到 Electric let response: Response if (method === 'POST') { // POST:透传客户端 body(子集参数只能收窄结果) const clientBody = await request.text() response = await fetch(originUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: clientBody, }) } else { // GET:简单代理 response = await fetch(originUrl) } const headers = new Headers(response.headers) headers.delete(`content-encoding`) headers.delete(`content-length`) return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }) }客户端侧只需配置subsetMethod: 'POST':
import { ShapeStream } from '@electric-sql/client' const stream = new ShapeStream({ url: '/api/shapes/items', // 你的代理端点(Next.js Route Handler) headers: { Authorization: `Bearer ${token}`, }, subsetMethod: 'POST', // 子集请求走 POST })⚠️前瞻性提醒:根据 认证指南 的说明,Electric 2.0 中 GET 子集快照请求将被弃用,仅支持 POST。建议现在就让代理同时支持 GET/POST,并让客户端切换到
subsetMethod: 'POST',为升级做好准备。
第三步:让密钥只存在于服务端——认证与安全
令牌怎么带到代理
客户端通过headers选项携带Authorization头,代理(Route Handler)据此解析用户并做授权。TypeScript 客户端还支持函数式选项,便于动态刷新令牌:
const stream = new ShapeStream({ url: 'http://localhost:3000/v1/shape', headers: { // 每次请求都会重新求值 Authorization: async () => `Bearer ${await getAccessToken()}`, }, })这个模式适用于需要周期性刷新令牌、基于会话的认证、从安全存储取令牌或需要自动处理令牌轮换的场景。
处理 401/403 与错误重试
代理返回 401(认证失败)或 403(无权限)时,客户端可用onError回调刷新凭证并重试。返回值的语义:返回{ headers }或{ params }用新值重试;返回{}用相同配置重试(适合瞬时错误);返回 void 则停止流。5xx 服务端错误会自动按指数退避重试。
const stream = new ShapeStream({ url: '/api/shapes/items', headers: { Authorization: `Bearer ${currentToken}`, }, onError: async (error) => { if (error instanceof FetchError && error.status === 401) { // 令牌过期——刷新并重试 const newToken = await refreshAuthToken() return { headers: { Authorization: `Bearer ${newToken}`, }, } } // 其他错误停止同步 }, })会话失效与缓存隔离(Vary 头)
如果 CDN 或浏览器缓存了 Shape 响应,用户登出后仍可能读到旧数据。解决方法是让响应携带Vary头,把认证上下文纳入缓存键:
Vary: Authorization或基于 Cookie 的认证:
Vary: Cookie若同时支持多种认证方式,可合并:Vary: Authorization, Cookie。这样不同用户的请求会被分别缓存,登出后失去凭证的用户无法命中已认证的缓存响应。同时,登出时应做整页刷新以清空内存中的已同步 Shape 数据:
async function handleLogout() { await clearAuthToken() // 整页刷新清空内存中的全部同步数据 window.location.reload() }Gatekeeper 模式(另一种选择)
除代理模式外,认证指南 还描述了 Gatekeeper 模式:客户端向你的 API 中的 gatekeeper 端点POST /gatekeeper/:table提交凭证与 Shape 定义,gatekeeper 授权后签发包含 Shape 声明的 shape-scoped JWT;此后客户端通过授权代理访问 Electric,代理验证 JWT 并核对令牌中的 Shape 声明与请求参数完全一致才放行。该模式把主鉴权逻辑收敛到 API 中只执行一次,适合在边缘函数中做轻量校验,也适用于 CDN 场景。仓库中的 gatekeeper-auth 示例 提供了 API(Elixir/Phoenix)、Caddy 反向代理、边缘函数三种代理实现。
第四步:写入路径——四种模式的选择
Electric 只做读路径同步:数据从 Postgres 同步进本地应用;它不提供也不规定写路径方案。在 Next.js 中,读路径交给 Electric(代理后的 Shape),写路径则由你自由选择。写入模式指南 给出了四种由简到繁的模式,均有 write-patterns 示例 对应的源码:
1. 在线写入(Online writes)
最直接:把同步(读)与常规 REST API 调用(写)组合。适合只读应用、偶尔写入或必须在线才能编辑的应用——例如实时仪表盘、数据分析可视化、在云端生成 embedding 的 AI 应用、支付类系统。
优点:实现简单,可复用现有 API。缺点:写路径上有网络延迟,UI 要等服务器响应,离线不可用。
2. 乐观状态(Optimistic state)
在模式 1 之上,用 React 内置的useOptimistic钩子(示例源码)在等待服务器响应时立即显示“乐观”状态;写入成功后会经 Electric 自动同步回应用,此时丢弃乐观状态即可。
优点:把网络移出写路径,读写都支持离线,实现简单。缺点:示例中的乐观状态是组件作用域的、不持久化——其他组件可能显示不一致信息,卸载组件或刷新页面会丢失乐观状态。
3. 共享持久化乐观状态(Shared persistent optimistic state)
在模式 2 基础上,把乐观状态放进共享、持久化的本地存储(示例用 valtio + localStorage,示例源码)。所有组件都能看到并响应乐观状态,matchWrite合并逻辑支持把本地乐观状态 rebase 到其他用户的并发更新上,回滚入口拥有写上下文、可回滚单条写入。
优点:写更抗中断、组件一致,不可变同步状态与可变本地状态分离,便于实现回滚。缺点:读路径上合并数据会让本地读取稍慢;写入仍走 API。
4. 通过数据库同步(Through the database sync)
把共享持久化乐观状态延伸到本地嵌入式数据库(示例用 PGlite,示例源码)。应用代码直接读写本地数据库,变更在后台自动同步。具体做法:数据同步进不可变的todos_synced表,乐观状态持久化在影子表todos_local,用todos视图在读取时合并;写路径用INSTEAD OF触发器把对视图的写入重定向到本地表并记录changes日志,再用NOTIFY驱动同步工具把变更发往服务器。
优点:纯本地优先体验,组件只与本地数据库交互,读写接口统一。缺点:嵌入式数据库是较重依赖,影子表与触发器让客户端 schema 复杂化,后台同步使回滚上下文难以重建。
选择建议
顺序即复杂度:在线写入最简单;乐观状态适合想要快速响应的管理类应用与移动应用;共享持久化状态在复杂度与体验间取得良好平衡;通过数据库同步则适合纯本地优先的协作/创作类软件。若需要处理并发合并与回滚,指南还给出了进阶讨论(合并逻辑、回滚策略),并指出实践中冲突非常罕见、可用简单的暴力策略应对大部分场景。
关于 SSR 的现状
Next.js 支持 SSR,而 Electric 团队目前正在探索用 SSR 使用 Electric 的模式(仓库中标注为实验性)。目标是在服务器渲染与客户端组件无缝进入实时同步之间取得平衡。因此当前文档给出的核心建议是:客户端组件内使用标准 Electric React 客户端模式;SSR 相关的框架集成仍在演进中,仓库以 HelpWanted issue #1596 公开征集改进。生产项目应优先采用本文第二步的路由代理方案,并保持客户端组件边界清晰。
参考资料
- 关联文档:Next.js 集成指南
- React 集成指南
- 认证指南
- 写入模式指南
- Shape 指南
- TypeScript 客户端文档
- React Hooks 源码
- Next.js 示例页(历史部署参考)
- write-patterns 示例
- gatekeeper-auth 示例
【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考