在 Next.js 中集成 Electric 同步引擎:路由代理、认证与写入模式的实战指南
2026/9/16 17:17:51 网站建设 项目流程

在 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 包,提供useShapepreloadShapegetShapeStreamgetShape等 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

字段类型说明
dataT[]组成物化 Shape 的行数组
shapeShape<T>useShape使用的 Shape 实例
isLoadingboolean初始拉取期间为true,之后为false
lastSyncedAtnumber?最近一次同步的 Unix 时间,isLoading时为undefined
isErrorboolean是否出错
errorShape<T>['error']错误对象

其他 Hooks 与辅助函数

除了useShape,react-hooks.tsx 还导出了几个适合在路由加载阶段或模块级使用的函数(其内部实现基于全局缓存,可避免同一 Shape 重复建流、重复物化):

  • preloadShape:在路由加载函数中提前确保 Shape 数据就绪后再渲染。从源码看(react-hooks.tsx#L17-L24),它内部先getShapeStreamgetShape,并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 定义tablequeryable_columns、主where以及 API 密钥secret都必须在服务端设置,绝不能交给客户端;客户端只能传递offsethandlelivelive_ssereplicalog等协议参数,以及 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,是客户端需要透传的协议参数白名单(offsethandlelive等),代理只放行这些参数。
  • 表名与授权where全部服务端设定;非管理员用org_id = $1+params[1]做参数化过滤,防止 SQL 注入。
  • 返回前必须删除content-encodingcontent-length头(fetch 已解压 body,保留这两个头会破坏浏览器端解码)。

类型安全的 where 子句生成

当授权规则复杂时,手拼 SQL 字符串容易出错。可以用 Drizzle 或 Kysely 在编译期生成类型安全的 where 子句。由于 Electric 的 HTTP API 通过独立查询参数(params[1]=valueparams[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 支持的参数

参数类型说明
wherestring过滤子集的 WHERE 子句
paramsobject参数,形如{"1": "value1", "2": "value2"},对应$1$2占位符
limitinteger最大返回行数(需要order_by
offsetinteger分页跳过的行数(需要order_by
order_bystringORDER 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,并始终在服务端设置tablequeryable_columns、主wheresecret

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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询