Astro SSR 示例实战:用 @astrojs/node 适配器构建服务端渲染电商 Demo
本文以 Astro 官方仓库中的 examples/ssr 示例为主体,完整讲解如何用 output: 'server' + @astrojs/node 适配器搭建一个服务端渲染(SSR)站点:包括 API 路由、动态路由、Cookie 会话、表单登录(同步/渐进式两种写法)以及 Svelte 客户端岛之间的组件通信。读完本文,你能独立复制该示例、理解每个页面与服务端 API 的数据流,并掌握从本地开发到 dist/server/ 生产部署的完整命令链。
示例定位与快速开始
该示例是一个名为 "Online Store" 的小型在线商店,官方 README(examples/ssr/README.md)说明其核心目标:用 @astrojs/node 适配器配合 @astrojs/svelte 集成来展示服务端渲染能力。你可以直接从 Astro 仓库创建同模板的新项目:
npm create astro@latest -- --template ssr
也可以使用 StackBlitz、CodeSandbox 或 GitHub Codespaces 在线打开该示例(README 中提供了对应的入口徽章,无需本地克隆即可运行)。
该示例 examples/ssr/package.json 声明了运行前提与依赖版本,适用条件需要注意:
{
"engines": { "node": ">=22.12.0" },
"dependencies": {
"@astrojs/node": "^11.1.5",
"@astrojs/svelte": "^9.0.1",
"astro": "^7.2.10",
"svelte": "^5.53.5"
}
}
即要求 Node.js 不低于 22.12.0,Astro 7.x 搭配 @astrojs/node 11.x 与 Svelte 5。
项目结构
README 给出的目录树如下,这也是官方推荐的目录组织方式:
/
├── public/
│ ├── favicon.ico
│ ├── favicon.svg
│ └── images/
├── src/
│ ├── components/
│ ├── models/
│ ├── pages/
│ │ ├── api/
│ │ └── products/
│ ├── styles/
│ └── api.ts
├── astro.config.mjs
├── package.json
└── tsconfig.json
三个关键目录的职责(README 原意 + 实际文件对应):
src/pages/:Astro 在此目录寻找.astro或.md文件,每个文件按文件名暴露为一个路由;动态路由如products/[id].astro用于渲染单个商品页。由于本项目是 SSR 模式,src/pages/下的.ts文件同样会成为服务端 API 路由(下一节详述)。src/components/:放 Astro 组件或框架组件(如 Svelte 组件),没有任何特殊约定,只是团队惯例。public/:存放图片等静态资源,例如商品图 public/images/products/ 下的cereal.jpg、yogurt.jpg等。
另有 src/models/ 与 src/api.ts 承载数据层与请求封装,属于本示例特有的业务组织,README 未展开,下面结合源码说明。
核心配置:output: 'server' + Node 适配器
examples/ssr/astro.config.mjs 全文只有十几行,却是整个示例的开关:
import node from '@astrojs/node';
import svelte from '@astrojs/svelte';
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone',
}),
integrations: [svelte()],
});
output: 'server':让 Astro 以服务端渲染为主模式,页面按需(on demand)渲染,而不是构建期生成全部静态 HTML。adapter: node({ mode: 'standalone' }):将应用打包为独立的 Node 服务端。standalone模式会把依赖打包进产物,便于在最小化 Node 环境中直接启动。integrations: [svelte()]:注册 Svelte 集成,使.svelte文件可作为客户端组件(island)被client:*指令激活。
README 中的原话是:“本项目使用 @astrojs/node 适配器配合 output: 'server' 按需渲染页面,并从 src/pages/api/ 暴露 API 路由。”
TypeScript 方面,examples/ssr/tsconfig.json 继承 astro/tsconfigs/strict 并包含 .astro/types.d.ts,保证页面 props、Astro.params、APIContext 等有类型提示。
命令速查
README 给出的全部命令(在项目根目录的终端执行):
| Command | Action |
|---|---|
npm install |
Installs dependencies |
npm run dev |
Starts local dev server at localhost:4321 |
npm run build |
Build your production site to ./dist/ |
npm run preview |
Preview your build locally, before deploying |
npm run server |
Run the built Node server from ./dist/server/ |
npm run astro ... |
Run CLI commands like astro add, astro check |
npm run astro -- --help |
Get help using the Astro CLI |
注意 server 脚本的实际内容(examples/ssr/package.json 第 14 行)是 node dist/server/entry.mjs——这正是 @astrojs/node 在 standalone 模式下产物的入口约定:构建后直接用一个 Node 进程启动,不需要额外框架进程。
服务端 API 路由:src/pages/api/
SSR 模式下 src/pages/ 中任何 .ts 文件都可以通过导出 HTTP 方法函数(GET/POST…)成为服务端 API 端点。本示例有三个:
GET /api/products
examples/ssr/src/pages/api/products.ts:
import { products } from '../../models/db';
export function GET() {
return new Response(JSON.stringify(products));
}
直接从内存数据模型返回全部商品 JSON。
GET /api/products/[id](动态路由 + 错误处理)
examples/ssr/src/pages/api/products/[id].ts:
import type { APIContext } from 'astro';
import { productMap } from '../../../models/db';
export function GET({ params }: APIContext) {
const id = Number(params.id);
if (productMap.has(id)) {
const product = productMap.get(id);
return new Response(JSON.stringify(product));
} else {
return new Response(null, {
status: 400,
statusText: 'Not found',
});
}
}
要点:通过 APIContext 的 params 拿到 URL 中的 [id] 动态段;未命中时用 Web Response 构造 400 响应。
GET/POST /api/cart(Cookie 会话 + 内存存储)
examples/ssr/src/pages/api/cart.ts:
import { userCartItems } from '../../models/session';
export function GET({ cookies }: APIContext) {
let userId = cookies.get('user-id')?.value;
if (!userId || !userCartItems.has(userId)) {
return Response.json({ items: [] });
}
let items = userCartItems.get(userId);
return Response.json({ items: Array.from(items.values()) });
}
export async function POST({ cookies, request }: APIContext) {
const item = await request.json();
let userId = cookies.get('user-id')?.value;
if (!userCartItems.has(userId)) {
userCartItems.set(userId, new Map());
}
let cart = userCartItems.get(userId);
if (cart.has(item.id)) {
cart.get(item.id).count++;
} else {
cart.set(item.id, { id: item.id, name: item.name, count: 1 });
}
return Response.json({ ok: true });
}
会话来源是 user-id Cookie,存储是 examples/ssr/src/models/session.ts 里的一行 Map——源码注释明确写着 // Normally this would be in a database.,即这是演示用途的进程内会话,生产环境应替换为真实数据库/缓存。
数据模型侧,examples/ssr/src/models/db.ts 把 examples/ssr/src/models/db.json(4 个商品:Cereal、Yogurt、Rolled Oats、Muffins,含 id、name、price、image 字段)转为数组 + Map 双索引,分别供列表与按 ID 查询使用。
SSR 页面:在服务端发起请求
SSR 页面的核心能力是在服务端(前端拿到 HTML 之前)发起请求。本示例把这类请求统一封装在 examples/ssr/src/api.ts。其中 getJson 封装(第 20–37 行)值得逐行看:
async function getJson<T>(incomingReq: Request, endpoint: string): Promise<T> {
const origin = new URL(incomingReq.url).origin;
const response = await fetch(`${origin}${endpoint}`, {
credentials: 'same-origin',
headers: incomingReq.headers,
});
// ... 错误处理略
return response.json() as Promise<T>;
}
三个关键设计:
- 基于
Astro.request的 origin 拼接:服务端fetch没有浏览器默认 origin,因此从入站请求 URL 提取 origin,再拼出/api/...的绝对地址; - 透传入站请求头(含 Cookie):
headers: incomingReq.headers让服务端子请求能带上浏览器 Cookie,从而读到user-id会话——这是“页面 SSR 时即可拿到用户购物车”的关键; - 对 Network 错误的归一化:
DOMException/TypeError(典型如 fetch 连接失败)被转成带端点名与 statusText 的业务错误。
在此基础上导出 getProducts、getProduct、getUser、getCart 四个服务端取数函数。
首页:列表 SSR
examples/ssr/src/pages/index.astro 的 frontmatter 只有两行业务代码:
const products = await getProducts(Astro.request);
页面在服务端即完成取数,再把 products 传给 ProductListing 组件渲染商品卡片(图片、名称、价格),标题通过命名 slot(slot="title")注入。浏览器拿到的就是完整 HTML,不依赖 JS 即可看到商品列表。
动态路由商品页:products/[id].astro
examples/ssr/src/pages/products/[id].astro:
const id = Number(Astro.params.id);
const product = await getProduct(Astro.request, id);
Astro.params.id 提供 URL 中的动态段,服务端取数后渲染 <title>{product.name} | Online Store</title> 与商品图。页面上的“加入购物车”按钮则是唯一的客户端岛:
<AddToCart client:idle id={id} name={product.name} />
client:idle 表示浏览器空闲时再水合该 Svelte 组件,SSR 首屏保持纯 HTML。
购物车页:Cookie 守卫 + 服务端重定向
examples/ssr/src/pages/cart.astro 展示了 SSR 独有的服务端访问控制写法:
if (!Astro.cookies.get('user-id')) {
return Astro.redirect('/');
}
const cart = await getCart(Astro.request);
未登录(无 user-id Cookie)的请求在服务端就被 Astro.redirect('/') 挡回首页,用户永远不会看到购物车页的未授权 HTML;已登录则用 getCart 服务端取数并渲染成表格。
登录:同一流程的两种实现
本示例最有教学价值的部分是 login 功能提供了经典表单 POST 与渐进式(AJAX)登录两套端点,登录成功后都写入同一个 user-id Cookie(maxAge: 2592000,即 30 天)。
1) 经典表单 POST:login.form.ts
examples/ssr/src/pages/login.form.ts 导出 POST 处理器,读取表单提交后:
cookies.set('user-id', '1', { path: '/', maxAge: 2592000 });
return new Response(null, { status: 301, headers: { Location: '/' } });
设置 Cookie 后以 301 重定向回首页。页面侧 login.astro 中对应的表单是 <form action="/login.form" method="POST">——完全无 JS 也能走通登录。
2) 渐进式登录:login.form.async.ts
examples/ssr/src/pages/login.async.ts 则是 JSON 接口:
export const POST: APIRoute = ({ cookies }: APIContext) => {
cookies.set('user-id', '1', { path: '/', maxAge: 2592000 });
return Response.json({ ok: true, user: 1 });
};
login.astro 内联的 is:inline 脚本监听表单 submit,e.preventDefault() 后 fetch('/login.form.async', { method: 'POST', body: JSON.stringify(data) }),成功后提示 3 秒跳转。两种写法对照,正好覆盖了“表单端点(form endpoint)”与“REST 风格端点”两种服务端交互范式。
客户端岛与跨组件通信
除了 AddToCart,导航栏 Header.astro 也演示了“SSR 数据 + 客户端岛”的组合:
const cart = await getCart(Astro.request);
const cartCount = cart.items.reduce((sum, item) => sum + item.count, 0);
<!-- ... -->
<Cart client:idle count={cartCount} />
首屏的购物车数量在服务端算好并写入 <Cart> 岛的初始 prop,避免客户端二次请求。两个 Svelte 岛之间的联动则通过浏览器自定义事件解耦:
- AddToCart.svelte:点击按钮后调用 api.ts 第 55–69 行的
addToUserCart(id, name)(客户端fetchPOST 到/api/cart,带credentials: 'same-origin'与 JSON body),随后window.dispatchEvent(new CustomEvent('add-to-cart', { detail: id })); - Cart.svelte:用
<svelte:window onadd-to-cart={onAddToCart}/>监听该事件,本地count++更新徽标,并记录已加入的 id 集合。
也就是说:加购请求由客户端岛直接打到服务端 API,UI 更新走 window 自定义事件广播,两岛之间没有任何直接依赖——这是 island 架构下典型的“事件总线”式解耦写法。
其余通用组件 Container.astro(可替换标签名的布局容器)与 TextDecorationSkip.astro(逐词下划线的装饰标题)属于演示样式能力的配角组件,与 SSR 机制无关,可按需忽略。
适用前提与限制
结合源码可以归纳使用该示例时的注意项:
- Node 版本:
engines要求>=22.12.0; - 会话是进程内存:
userCartItems是Map,重启进程即清空、多实例部署不共享,源码注释已声明这是示例做法; - 登录是假认证:两个登录端点都不校验密码,直接写死
user-id: '1',只用于演示 Cookie 写入与重定向,不可照搬到生产; - standalone 产物:
npm run build后用npm run server(即node dist/server/entry.mjs)启动,是@astrojs/nodestandalone 模式的标准部署形态。
延伸学习
示例 README 建议读者带着本示例去阅读 Astro 官方文档中 output: 'server'、@astrojs/node 适配器与 Svelte 集成的章节,并可以按 examples/ssr/package.json 中的 npm run astro -- --help 探索 CLI 能力。若想对照静态站点写法,可参考同仓库的 examples/minimal 与 examples/blog 示例;若关心测试,仓库根级维护了 Vitest 与 Playwright 两套体系(如 packages/astro/e2e/server-islands.test.ts 覆盖了服务端岛屿的水合行为),可作为 SSR 行为的验证参考。
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