跳转到主要内容

CSR 项目改造为请求期 SSR(React Router 7 data-mode)

你是代码迁移 Agent。任务:把当前 CSR 项目项目级 flip 到一个标准、自洽、能独立跑的 React Router 7 data-mode Vite SSR 项目。

能力与边界(先读这一节)

这个 skill 让你做到:把 CSR 项目整体翻成标准 Vite SSR——单一 routes.tsx + entry-server/entry-client 双入口 + IIFE server bundle + 项目自带的 server(部署成本站 Supabase 的 edge function,请求期直接产出完整 HTML)。产物是标准 Vite SSR 项目,不引 Next/Remix/vite-react-ssg、不自造数据层。方案依据:React Router 官方 ssr-data-router 示例 + Vite 官方 SSR(server.js 注模板)。 何时用:某页「公开 + 要 SEO/被索引」→ SSR(无论纯静态还是带真数据:静态页就是没有 loader 的 SSR,首屏照样预渲染)。私有/登录态页保持 CSR。SSG(vite-react-ssg)已退役(钉死 react-router v6、不再投入):禁止新用;已做过 SSG 的项目一律升级到 SSR(含纯静态页,别以”静态就留 SSG”为由保留),见 SSG→SSR 迁移 心智模型:整体 flip → 局部深化
  • 默认 = CSR切 SSR = 项目级一次 flip。flip 后每条路由都被服务端渲染——但多数页此刻仍是壳(渲染外壳 + 客户端水合后取数),标准 React SSR 行为,无需特殊处理。
  • 局部深化(按需逐页):要 SEO/首屏数据的页,给那条 route 加 loader(服务端取数),组件 useLoaderData() 读。
你写什么 vs 平台做什么(三层架构)
  • 你的项目自带一个 server——标准 Vite SSR 里的 server.js。它 load server bundle + index.html 模板,render(url) 出 app HTML、注进模板 → 吐完整 HTML 文档。这个 server 以 edge function 部署到本站自己的 Supabase(Deno):每站独立 = 强隔离、能用 service_role 直连本站库取数。它是你写的项目代码(见「模板·supabase fn」)。
  • 平台的 container 只是多租户门控:转发请求给你的 server、拿回整页、注平台脚本(monitor/付费墙)、在真站点 URL 吐给访客。它不碰你的渲染逻辑。
  • 核心原则:「模板」节的文件全由你写进项目源码,不依赖平台注入。平台只负责 CDN 上传、edge 部署、请求期门控(见 「平台负责的」一节)。

你要做的决策

flip 是机械的(照「模板」节写文件)。真正要你判断的就这几条:
决策怎么判断落地
这页要不要 SSR公开 + 要 SEO/被索引 → SSR;私有/登录态 → 留 CSRflip 后所有路由自动 SSR;私有页无需特殊处理
这页要不要 loader要 SEO/首屏就带真实数据 → 加 loader(服务端取数);否则是壳(客户端取数)「局部深化」
哪些库要从 server bundle external 剔除import 期跑浏览器初始化的客户端重库(图表/可视化/canvas/WebGL/数值精度:recharts/echarts/three/konva/decimal.js…)见下「关于 external」+ 「模板·vite.config.ssr」
要不要资源路由(按需)要请求期动态产出带扩展名的文件(sitemap.xml / robots.txt / feed.json)「局部深化·资源路由」

关于「external 剔除重库」(最常踩的 P0,必读)

纯客户端重库(图表/可视化/canvas/WebGL/数值精度,如 recharts、echarts、decimal.js、three 等)绝不能 import 进 SSR 渲染树routes.tsx → 组件模块顶层)。它们常在 import 期就跑浏览器初始化、或用 typeof self/window 探测环境;而 edge 是 Deno(有 self)会被误判成浏览器分支直接崩(线上实测:decimal.js 抛 [DecimalError] Invalid argument: LN10 → edge 500 → 容器静默降级 CSR)。这类只做客户端展示、无 SEO 价值的能力,必须 React.lazy/动态 import() 或挂载后渲染,仅客户端加载 ⚠️ 但仅靠 React.lazy/动态 import() 在本指南的 IIFE server bundle 下不充分。 IIFE 无法代码分割,Vite lib 模式会强制 inlineDynamicImports,把 lazy 的目标模块连同它静态依赖的重库一起内联进唯一 bundle,并在 edge 加载(new Function eval)那一刻执行该重库的模块顶层副作用 → 仍然崩,且连不含该库的页(如首页 /)都会一起 500——这是 bundle 级 eval 崩,不是路由级渲染崩。所以这里有两层、缺一不可
  1. 客户端懒加载 / 挂载守卫 —— 挡的是渲染期崩(组件别在服务端被渲染)。
  2. vite.config.ssr.tsbuild.rollupOptions.external 把重库从 server bundle 物理剔除 —— 挡的是 eval 期崩。
客户端构建(vite.config.ts不要 external,图表照常在浏览器加载。这份重库清单同时就是「执行步骤」的预扫描要标出、「模板·vite.config.ssr」 external 要剔除的库。

硬性规则(护栏,P0 真实踩过)

MUST
  1. 只用 Vite + React Router 7 data-mode,不换栈。路由收敛成单一 src/routes.tsx 导出 RouteObject[](server/client 共用),用 createStaticHandler/createStaticRouter/StaticRouterProvider + loader + useLoaderData;删 <ScrollRestoration/>
  2. CSR 项目零影响:没 flip 的项目构建/行为跟今天完全一致(仍用 main.tsx,没有 entry-client/vite.config.ssr.ts/edge function)。
  3. entry-serverrender(path,ctx) 只返回 app HTML(不拼完整文档);由你写的 server(edge function)注进 index.html 模板的 <!--ssr-outlet--> 才成完整 HTML。
  4. index.html 改两处:入口脚本 main.tsxsrc/entry-client.tsx<div id="root"></div><div id="root"><!--ssr-outlet--></div>(server 据此注入)。
  5. App 的 providers 收进 routes.tsx 根 route 的 element(providers 包 <Outlet/>)。
  6. vite.config.ssr.tsreact-router-dom alias 到 react-router(保证 react-router 单份)。
  7. 仅客户端能力(Sonner/主题/window)做挂载后渲染/守卫(见「客户端能力约束」)。
DO NOT
  1. 不引入 Next/Remix/vite-react-ssg/框架式文件路由。
  2. 不在模块顶层执行依赖浏览器环境的逻辑:window/document/localStorage → 加 typeof window==='undefined' 守卫或惰性化(SSR bundle import 整棵树时会执行)。
  3. 不保留 manualChunksreact/react-dom 的拆分(破坏自包含 IIFE bundle)。
  4. 不把客户端重库 import 进 SSR 渲染树(见 「关于 external」一节)。
  5. 不做平台职责的事:不要往 index.html 注 <meta name="x-ssr">不要render-manifest.json不要做 CDN 上传——这些平台自动处理(见 「平台负责的」一节)。

执行步骤

1) SSR 安全全量预扫描(编码前必做)

改造前对 src/ 全目录预扫描,汇总”待修改文件清单”:模块顶层/Hook 初始值/渲染路径里的 window/document/localStorage;非确定性值(时间戳/随机);client-only 组件(Sonner/useTheme);framer-motioninitial 隐藏态。另外标出所有 client-only 重库(图表/可视化/canvas/WebGL/数值精度,如 recharts/echarts/decimal.js)的 import 点——这些禁止进 SSR 树、必须改成客户端懒加载,否则在 Deno edge 直接崩(见 「关于 external」一节)。这份重库清单同时就是 「模板·vite.config.ssr」 rollupOptions.external 要剔除的库。清单收敛后再编码。

2) 按「模板」节写这些文件

src/routes.tsx · src/entry-server.tsx · src/entry-client.tsx · vite.config.ssr.ts · supabase/functions/ssr/index.ts,逐字照抄、按项目实际填空。

3) 改 index.html 两处

见上「硬性规则 MUST 4」。

4) 局部深化

要 SEO/首屏数据的页加 loader;要动态文件加资源路由。见 「局部深化」。

模板(逐字照抄)

下面是 5 个要新建的文件 + index.html 的 2 处改动,全照抄、按项目实际填空。注释里的坑都是 P0 实战,别删。

1) src/routes.tsx —— 单一路由源

import type { RouteObject } from 'react-router';
// import Layout from './Layout';   // 原 App 的 providers 收这里 + <Outlet/>
export const routes: RouteObject[] = [
  // {
  //   path: '/',
  //   element: <Layout />,
  //   children: [
  //     { index: true, element: <Home /> },                       // 壳(无 loader)
  //     {
  //       path: 'product/:id',
  //       element: <Product />,
  //       loader: async ({ params, context }) => {                // 局部深化(见下文「局部深化」节)
  //         const { data } = await context.supabase.from('products').select('*').eq('id', params.id).single();
  //         return { product: data };
  //       },
  //     },
  //     // 动态文件路由(资源路由):有 loader、【无 element/Component】→ loader 返回 Response,server 原样吐。见 局部深化·资源路由。
  //     {
  //       path: 'sitemap.xml',
  //       loader: async ({ context }) => {
  //         const { data } = await context.supabase.from('posts').select('slug,updated_at');
  //         const body = renderSitemapXml(data ?? []);             // 你的拼接函数:产出 <urlset>…</urlset>
  //         return new Response(body, { headers: { 'content-type': 'application/xml; charset=utf-8' } });
  //       },
  //     },
  //     { path: '*', element: <NotFound /> },
  //   ],
  // },
];

2) src/entry-server.tsx(render → app HTML · 固定模板)

// ⚠️ 必须用显式子路径 `react-dom/server.browser`,不要写 `react-dom/server`。
// `react-dom/server` 是条件解析(export map),会被下面 vite.config 的 resolve.conditions:['node',…] 命中 `node`
// 分支 → server.node.js(React 的 Node streams 渲染器,依赖 Node 的 stream.Readable)。我们的 IIFE bundle 跑在
// Supabase 边缘函数(Deno),没有 Node 的 Readable → 初始化/首次 render 抛 `Cannot read properties of undefined
// (reading 'prototype')` → edge 500 → 容器降级 CSR(SSR 看着没生效)。子路径绕开 export 条件,恒拿 Web Streams 版
// (server.browser.js,用 ReadableStream/TextEncoder,Deno 原生支持);renderToString 同步、对无 server-Suspense 的树行为一致,Node dev 下也照常工作。
import { renderToString } from 'react-dom/server.browser';
import { createStaticHandler, createStaticRouter, StaticRouterProvider, matchRoutes } from 'react-router';
import { routes } from './routes';

const { query, queryRoute, dataRoutes } = createStaticHandler(routes);

// 资源路由判定(如 /sitemap.xml、/robots.txt):匹配到的叶子 route 有 loader 但【不渲染组件】(无 element/Component)。
// 这类「文件路由」用 queryRoute 取 loader 的【原始返回】(Response 直接透传);page 路由才用 query 渲染成 HTML 文档。
// ⚠️ 必须分流:query() 会把 loader 返回的 Response 反序列化成渲染数据(拿不到原始 XML),只有 queryRoute() 返回原始 Response。
function isResourceRoute(path: string): boolean {
  const matches = matchRoutes(routes, new URL(`http://ssr.local${path}`).pathname);
  const leaf: any = matches?.[matches.length - 1]?.route;
  return !!leaf && !leaf.element && !leaf.Component && !!(leaf.loader || leaf.lazy);
}

// ctx 由 server(edge/dev)注入:{ supabase }(service_role)。经 requestContext 传给每个 loader 作为其 context。
// page 路由 → 返回 app HTML(含 RR 自动内联的 hydration script),由 server 注进 index.html 的 <!--ssr-outlet--> 成完整文档;
// 资源路由 → 返回 { body, contentType },由 server 原样吐(不套模板),实现 /sitemap.xml 等动态文件。
export async function render(
  path: string,
  ctx: { supabase: any }
): Promise<{ html?: string; redirect?: string; status?: number; body?: string; contentType?: string }> {
  const request = new Request(`http://ssr.local${path}`);

  // 资源路由:queryRoute 取 loader 原始返回(Response 透传 / 字符串 / 对象都当文件原样输出)
  if (isResourceRoute(path)) {
    const result = await queryRoute(request, { requestContext: ctx });
    if (result instanceof Response) {
      const location = result.headers.get('Location');
      if (location && result.status >= 300 && result.status < 400) {
        return { redirect: location, status: result.status };
      }
      return {
        body: await result.text(),
        contentType: result.headers.get('Content-Type') ?? 'text/plain; charset=utf-8',
        status: result.status,
      };
    }
    if (typeof result === 'string') return { body: result, contentType: 'text/plain; charset=utf-8', status: 200 };
    // 非 Response/字符串:序列化为 JSON(result 为空也吐空串,确保资源路由绝不回落到 HTML 壳)
    return { body: result == null ? '' : JSON.stringify(result), contentType: 'application/json; charset=utf-8', status: 200 };
  }

  // page 路由:渲染完整 app HTML
  const context = await query(request, { requestContext: ctx });
  if (context instanceof Response) {
    return { redirect: context.headers.get('Location') ?? '/', status: context.status };
  }
  const router = createStaticRouter(dataRoutes, context);
  const html = renderToString(<StaticRouterProvider router={router} context={context} />);
  return { html };
}

3) src/entry-client.tsx(水合 · 固定模板)

import { createBrowserRouter, matchRoutes, RouterProvider } from 'react-router';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { routes } from './routes';

async function hydrate() {
  const lazyMatches = matchRoutes(routes, window.location)?.filter((m) => m.route.lazy);
  if (lazyMatches && lazyMatches.length > 0) {
    await Promise.all(
      lazyMatches.map(async (m) => {
        const routeModule = await (m.route.lazy as () => Promise<Record<string, unknown>>)();
        Object.assign(m.route, { ...routeModule, lazy: undefined });
      })
    );
  }
  const router = createBrowserRouter(routes); // 自动读 server 注入的 window.__staticRouterHydrationData
  const el = document.getElementById('root')!;
  const tree = <RouterProvider router={router} />;
  if (el.hasChildNodes()) hydrateRoot(el, tree);
  else createRoot(el).render(tree);
}
hydrate();

4) vite.config.ssr.ts(自包含 IIFE + react-router 单份 · 固定模板)

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
  plugins: [react()],
  // edge 跑在 Deno,没有 process;React 等库初始化需要它
  define: { 'process.env.NODE_ENV': JSON.stringify('production') },
  resolve: {
    alias: { '@': path.resolve(__dirname, './src'), 'react-router-dom': 'react-router' },
    // 让依赖按 server 分支解析,避免库用 typeof self/window 走浏览器初始化(Deno 有 self 会误判→崩)。
    // ⚠️ 副作用:'node' 也会把 react-dom/server 解析成 server.node.js(Node stream 版)→ Deno edge 崩;故 entry-server
    //    对 react-dom 用显式子路径 server.browser 绕开(见模板·entry-server)。其它「带 node 分支、又依赖 Node 内建(stream/fs/crypto)」
    //    的库同理:命中后在 Deno 报 `xxx is undefined` / `reading 'prototype'`,改用其显式 web 子路径或给该包单独 alias。
    conditions: ['node', 'module', 'import', 'default'],
  },
  build: {
    outDir: 'dist/server', emptyOutDir: true,
    lib: { entry: 'src/entry-server.tsx', formats: ['iife'], name: '__SSR_BUNDLE__', fileName: () => 'entry-server.js' },
    rollupOptions: {
      // 纯客户端重库(图表/可视化/canvas/WebGL/数值精度)从 server bundle 物理剔除。
      // IIFE 无法代码分割 → Vite 强制 inlineDynamicImports → React.lazy 的目标连同其静态依赖的重库会被
      // 内联进唯一 bundle,并在 edge eval 时跑模块顶层副作用 → 崩(连不含该库的页也一起 500)。只有 external 能在打包期剔掉。
      // 清单 = 预扫描标出的「client-only 重库」,按项目实际用到的增减(未 import 的库列入也只是警告,无害)。
      // ⚠️ 客户端构建 vite.config.ts 千万不要加这些 external,否则浏览器里图表也没了。
      external: [
        'recharts', 'echarts', 'three', 'konva', 'react-konva',
        '@react-three/fiber', '@react-three/drei', '@react-three/cannon',
      ],
      output: {
        // server bundle 自挂全局做兜底;edge 主用「new Function 末尾 return 取回 __SSR_BUNDLE__」(见模板·supabase fn),不依赖这条
        footer: 'globalThis.__SSR_BUNDLE__=__SSR_BUNDLE__;',
        // ⚠️ IIFE 下 external 被当作全局注入:产物形如 `})({}, globalThis.__ext_recharts)`。
        //    每个 external 都要给 globals,且值必须是「undefined-safe 的 globalThis 取值表达式」,不能写裸标识符
        //    (如 recharts),否则 IIFE 调用时 ReferenceError: recharts is not defined。这些库只在客户端渲染时
        //    触达,服务端 eval 永不解引用 → 取到 undefined 无害(缺省时 Rollup 会 guess 成裸名 → 反而会崩)。
        globals: {
          recharts: 'globalThis.__ext_recharts', echarts: 'globalThis.__ext_echarts',
          three: 'globalThis.__ext_three', konva: 'globalThis.__ext_konva',
          'react-konva': 'globalThis.__ext_react_konva',
          '@react-three/fiber': 'globalThis.__ext_r3f',
          '@react-three/drei': 'globalThis.__ext_drei',
          '@react-three/cannon': 'globalThis.__ext_cannon',
        },
      },
    },
  },
});

5) index.html(改 2 处)

  • 入口脚本:<script type="module" src="/src/main.tsx">/src/entry-client.tsx
  • 注入点:<div id="root"></div><div id="root"><!--ssr-outlet--></div>(server 把 app HTML 注进这个占位)。

6) supabase/functions/ssr/index.ts(你项目的 SSR server · 固定模板,逐字照抄)

这是标准 Vite SSR 的 server.js,以 Deno edge function 形式部署到本站 Supabase。它 load 你的 server bundle + index.html 模板,render 出 app HTML、注进模板 → 返回完整 HTML。平台会用现成 deploy_edge_function 把它部署到本站 Supabase——你只写文件、不管部署(见 「平台负责的」一节)。
// SSR server(Deno edge function)——本项目自带的 server,部署到本站 Supabase。
// load server bundle + index.html 模板 → render(path) → 注进 <!--ssr-outlet--> → 返回完整 HTML。
// bundle 的 CDN URL 由平台 container 用 header x-ssr-bundle 传进来;index.html 同版本基址(同目录的 /index.html)。
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

type Render = (
  path: string,
  ctx: { supabase: unknown }
) => Promise<{ html?: string; redirect?: string; status?: number; body?: string; contentType?: string }>;

const renderCache = new Map<string, Render>();
const templateCache = new Map<string, string>();

async function loadRender(bundleUrl: string): Promise<Render> {
  const cached = renderCache.get(bundleUrl);
  if (cached) return cached;
  const res = await fetch(bundleUrl);
  if (!res.ok) throw new Error(`fetch bundle failed: ${res.status}`);
  // ⚠️ Rollup 的 IIFE 产物是 `var __SSR_BUNDLE__ = (...)()`——在 new Function 里这个 var 是函数局部变量、
  // 不会挂到 globalThis 上。所以**末尾 return 把它直接取回来**,别去读 globalThis.__SSR_BUNDLE__(对 IIFE 不可靠:
  // 只要 bundle 能正常 eval,读到的 render 永远是空 → 每条路由 500 → 全部降级成同一份模板)。globalThis 只作兜底。
  const code = await res.text();
  const mod = new Function(`${code}\n;return typeof __SSR_BUNDLE__!=="undefined"?__SSR_BUNDLE__:globalThis.__SSR_BUNDLE__;`)() as
    | { render?: Render }
    | undefined;
  const render = mod?.render;
  if (typeof render !== 'function') {
    console.error(`[ssr] bundle did not expose render. bundleUrl=${bundleUrl}`);
    throw new Error('bundle did not expose __SSR_BUNDLE__.render');
  }
  renderCache.set(bundleUrl, render);
  return render;
}

async function loadTemplate(templateUrl: string): Promise<string> {
  const cached = templateCache.get(templateUrl);
  if (cached) return cached;
  const res = await fetch(templateUrl);
  if (!res.ok) throw new Error(`fetch template failed: ${res.status}`);
  const html = await res.text();
  templateCache.set(templateUrl, html);
  return html;
}

// 把 app HTML 注进模板:优先用 <!--ssr-outlet--> 占位;占位缺失(没加 / 被 minify 去注释)则注进 #root 开标签之后;
// 两者都没有才 throw(→ 500 → container 降级 CSR,绝不静默白屏)。
function injectAppHtml(template: string, appHtml: string): string {
  if (template.includes('<!--ssr-outlet-->')) return template.replace('<!--ssr-outlet-->', appHtml);
  const m = template.match(/<div[^>]*\bid=["']?root["']?[^>]*>/i);
  if (!m) throw new Error('SSR template has no <!--ssr-outlet--> nor #root to inject into');
  return template.replace(m[0], m[0] + appHtml);
}

Deno.serve(async (req: Request): Promise<Response> => {
  try {
    const u = new URL(req.url);
    const stripped = u.pathname.replace(/^\/functions\/v1\/ssr/, '').replace(/^\/ssr/, '');
    const sitePath = (stripped === '' ? '/' : stripped) + u.search;
    const bundleUrl = req.headers.get('x-ssr-bundle');
    if (!bundleUrl) return new Response('missing required header: x-ssr-bundle', { status: 400 });
    const templateUrl = bundleUrl.replace('/server/entry-server.js', '/index.html');

    const supabase = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
      { auth: { persistSession: false } }
    );

    const [render, template] = await Promise.all([loadRender(bundleUrl), loadTemplate(templateUrl)]);
    const r = await render(sitePath, { supabase });
    if (r.redirect) {
      return new Response(null, { status: r.status ?? 302, headers: { location: r.redirect } });
    }
    // 资源路由(如 /sitemap.xml /robots.txt):render 返回原始 body+contentType → 原样吐,不套 index.html 模板。
    if (r.body != null) {
      return new Response(r.body, {
        status: r.status ?? 200,
        headers: { 'content-type': r.contentType ?? 'text/plain; charset=utf-8' },
      });
    }
    const html = injectAppHtml(template, r.html ?? '');
    return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } });
  } catch (e) {
    // 渲染/载入失败 → 非 2xx,让 container 降级回 CSR(永不白屏)。
    // 必须 console.error:否则错误只进响应体、不进 function_logs,线上日志一片空、真因看不见。
    console.error(`[ssr] render failed: ${req.url}`, (e as Error)?.stack ?? e);
    return new Response((e as Error)?.message ?? String(e), { status: 500 });
  }
});

局部深化:loader 与资源路由

route loader(服务端取数)

{
  path: 'product/:id',
  element: <Product />,
  loader: async ({ params, context }) => {
    // context = server 注入的 { supabase }(service_role,取浏览器 anon 拿不到的数据)
    const { data } = await context.supabase.from('products').select('*').eq('id', params.id).single();
    return { product: data };
  },
}
组件 const { product } = useLoaderData(); 读回。没 loader 的 route = 壳(客户端取数)。

动态文件路由(资源路由)

/sitemap.xml/robots.txt、动态 feed.json 等。当你要请求期动态产出一个文件(生成 sitemap、robots、RSS/Atom、动态 manifest 等),在 routes.tsx 里加一条资源路由
  • loader、但【不写 element/Component——entry-server 据此用 queryRoute 取 loader 的原始 Response(page 路由有 element、走 query 渲染 HTML)。
  • loader 直接 return new Response(body, { headers: { 'content-type': '…' } }),且 Content-Type 必须是非 HTMLapplication/xmltext/plainapplication/json…)——这是容器判「资源」(原样吐、不注平台脚本)还是「页面」的唯一依据;漏设 / 设成 text/html 会被当页面注脚本。也可只 return 字符串(按 text/plain 吐)。
  • path 带不带扩展名都行:容器靠 edge 返回的 Content-Type 判定、不再靠扩展名——/sitemap.xml、无后缀的 /sitemap/static 都可。分片/动态文件名用动态段:/sitemap/:page.xml/feeds/:slug.xml
// routes.tsx 里,与 page 路由并列:
{
  path: 'sitemap.xml',                              // 无 element + loader 回非 HTML Content-Type = 资源路由(带不带后缀都行)
  loader: async ({ context }) => {
    const { data } = await context.supabase.from('posts').select('slug,updated_at');
    const urls = (data ?? [])
      .map((p) => `<url><loc>https://你的站点域名/posts/${p.slug}</loc><lastmod>${p.updated_at}</lastmod></url>`)
      .join('');
    const xml = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`;
    return new Response(xml, { headers: { 'content-type': 'application/xml; charset=utf-8' } });
  },
}
机制(你只写上面这条 route,其余平台自动)
  • entry-server 对资源路由用 queryRoute 取 loader 的原始 Response(page 路由才用 query 渲染 HTML);你的 index.ts server 把它原样吐、不套 index.html 模板——见「模板」节。
  • 容器对 SSR 站不做路由白名单(已无 dynamicFileRoutes):带扩展名路径先精确查静态产物(命中且非空就走文件),不存在或为空则连同任意无扩展名路由一起转发给 edge。页面还是资源由 edge 返回的 Content-Type 决定——text/html→当页面(注平台脚本);其余→资源原样吐(不注入、用 edge 的 Content-Type)。代价:带后缀的缺失请求(bot 探 .php 等)也会打一次 edge、由项目 router 的 * 兜底;真实静态产物因「有内容」已在前一步命中走文件、不碰 edge。
  • 不要再去 public/ 放同名静态文件——静态文件优先级更高(精确命中即原样返回),会盖掉你的动态路由。

客户端能力约束

  1. Sonner/toast:保留在根布局,加 mounted 守卫;toast(...) 只在事件回调/useEffect
  2. 主题useTheme() 分支挂载后执行,未挂载用稳定 fallback。
  3. 浏览器 API:顶层/Hook 初始值/渲染路径里的 window/document/localStorageuseEffecttypeof window!=='undefined' 守卫。
  4. 非确定性值:时间戳/随机改稳定值或移进 useEffect
useMounted 参考:
function useMounted() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted;
}

平台负责的(你写)

flip 时只写「模板」节的项目源码 + loaders(见「局部深化」)。下面平台自动处理,不要手搓:
  • edge 部署:平台用 deploy_edge_function 把你写的 supabase/functions/ssr/index.ts 部署到本站 Supabase。代码你写、部署平台管。
  • serving 标记 + bundle 指针:构建自动往 index.html 注 <meta name="x-ssr">、写 render-manifest.json(存 server bundle 的 CDN URL),供 container 门控判 SSR + 把 bundle URL 用 header 传给你的 server。你不写这些。
  • CDN 上传 / 请求期门控:平台上传 server bundle + index.html 到 CDN;container 转发请求给你的 server、拿回完整 HTML、注平台脚本、在真站点 URL 吐给访客。

dev 预览(可选)

  • 文件进了源码后,项目 devvite)跑的就是真 SSR-shaped app(data router 客户端跑),结构与 prod 一致。
  • 想 dev 真·服务端渲染:加官方 server.js(Node,vite.ssrLoadModule('src/entry-server') + 同样注模板)——和「模板·supabase fn」的 edge 是同一套逻辑、不同 runtime。也是项目源码。 不加则 SSR 校验落在发布后 snapshot 预览(走平台 container→你的 edge = 真 SSR)。

交付验收(必须全部通过)

  1. pnpm build 成功 + 产出 dist/server/entry-server.js(IIFE,暴露 __SSR_BUNDLE__.render;edge 按模板「末尾 return 取回」加载,不依赖 globalThis)。
  2. 客户端构建产出正常(index.html 入口指向 entry-client,#root 含 <!--ssr-outlet-->)。
  3. supabase/functions/ssr/index.ts 已写(你的 SSR server)。
  4. 运行后无 window/document is not defined;首屏 hydration 无显著 mismatch。
  5. 路由是单一 src/routes.tsxRouteObject[]
  6. 深化过的页 SSR 首屏带 loader 数据;未深化的页正常出壳 + 客户端接管。
  7. (若加了资源路由)发布后访问 /sitemap.xml/sitemap/static 等:返回 loader 设的 Content-Type(如 application/xml)、内容是动态产出而非 HTML 壳,且响应里没被注入平台 <script>(被注入 = 容器把它当成了页面,去查 loader Response 的 Content-Type 是否非 HTML)。
  8. 对一个不含任何客户端重库的公开页(通常是首页 /)单独发一次 SSR 请求,确认不出现任何重库的初始化错(如 [DecimalError])。若这种页都报重库错,说明该库进了 eager bundle(IIFE 内联),需按 「关于 external」一节 用 external 物理剔除,而非仅 lazy。

失败排查(按顺序)

  1. edge 500 且报错栈来自 node_modules → 某个包被打进 SSR bundle、在 Deno 上跑了不兼容的分支。两种典型,修法不同
    • 栈是 Cannot read properties of undefined (reading 'prototype')(来自 react-dom)→ react-dom/serverresolve.conditions:['node',…] 解析成了 server.node.js(Node stream 版),Deno 没有 Node Readable改 entry-server 的 import 为显式子路径 react-dom/server.browser(见「模板·entry-server」;别动 conditions)。其它「带 node 分支又依赖 Node 内建」的库同理(显式 web 子路径 / 单独 alias)。
    • 栈是 [DecimalError]、某图表/数值库 → 该库 import 期跑了浏览器初始化分支(typeof self/window 把 Deno 误判成浏览器)。注意仅靠 React.lazy/动态 import 在 IIFE bundle 下排不掉(IIFE 强制内联,库的顶层副作用仍在 bundle eval 时执行)。正确修法是 external + output.globals 把它从 server bundle 物理剔除(见 「关于 external」一节),客户端继续用 lazy 加载。自检:若连一个不含该库的公开页(如首页 /)都报这个错,就是 bundle 级 eval 崩、必须 external,而不是某条路由的渲染问题。
  2. edge 500 报 bundle did not expose __SSR_BUNDLE__.render,或现象是「每条路由返回同一份模板、/ 和子路由内容一模一样」 → bundle 能正常 eval 但服务端取不到 render 函数。根因:Rollup 的 IIFE 产物 var __SSR_BUNDLE__=(...)()new Function 里是局部变量、不挂 globalThis;必须用「末尾 return __SSR_BUNDLE__ 取回」的载入方式(见「模板·supabase fn」),别去读 globalThis.__SSR_BUNDLE__注意第 1 条会遮住这条——bundle 一 eval 崩就走不到取 render,所以先解第 1 条、再回来看这条是否仍在。
  3. 先查 vite.config.ssr.ts 是否把 react-router-dom alias 到 react-router、是否按模板加了 resolve.conditions/footer/define
  4. 再查 dist/server/entry-server.js 是否产出。
  5. 再查 edge:render 返回的是 {html}、server 注进 <!--ssr-outlet-->(占位是否在 index.html 里)、x-ssr-bundle/模板 URL 是否能 fetch。
  6. 再查 SSR bundle import 期崩:定位模块顶层浏览器 API(回「执行步骤」的预扫描补齐)。
  7. 再查 hydration mismatch:主题/Sonner 挂载守卫、非确定性值。
  8. 资源路由(/sitemap.xml/sitemap/static 等)返回 HTML 壳 / 被注入脚本 / 404 而非动态内容:① 该 route 是否误带了 element/Component(带了 edge 就当 page 渲染、走不到 queryRoute);② loader 的 Response 是否设了非 HTML 的 Content-Type(漏设 / 设成 text/html → 容器当页面、会注平台脚本);③ public/ 下是否放了同名非空静态文件(带后缀路径精确命中优先、会盖掉动态路由,删掉它)。
看日志:edge 现在会 console.error 出失败路径 + 栈(见「模板·supabase fn」),到本站 Supabase 的 function_logs 直接看真因——不再是「只进响应体、日志一片空」。 已知可接受噪音function_logs 里的 Warning: useLayoutEffect does nothing on the server(Radix 等组件在服务端调 useLayoutEffect 触发)不影响 200 与内容产出,属预期,不必处理。 function_logs 为空 ≠ 没出错:edge 实例回收后日志有延迟、或查询时间窗没覆盖到失败请求时都会「空」。此时应 ① 主动发一次请求触发渲染再查;② 确认时间窗覆盖最近的 500;③ 务必拿到真实栈再下结论。切忌在拿不到栈时仅凭「先 200 后 500、未重部署」就推断成冷启动/远程依赖问题——确定性的 bundle eval 崩(同一路由稳定 500)和冷启动(时对时错)现象不同,靠状态码区分不了,必须看栈。