基于 Next.js 的 Jamstack / SSR / SPA 混合实践指南
By Leeting Yan
1. 前言:为什么需要「混合架构」?
在现代 Web 应用里,你很难只用「一种渲染模式」解决所有问题:
- 首页 / 营销页 / 博客:需要 极致首屏 + SEO → 更像 Jamstack(SSG/ISR)
- 用户控制台 / 后台系统:强调 交互、状态管理、实时性 → 更像 SPA(CSR)
- 某些页面:既要 实时数据、又要 SEO(比如商品详情、个人主页) → 更适合 SSR / 动态渲染
Next.js 最大的价值之一,就是在 同一个工程 里,优雅地支持:
- 静态站点(SSG / ISR → Jamstack 思路)
- 服务端渲染(SSR)
- 单页应用体验(CSR → SPA)
而且通过 App Router + 新的数据获取 / 缓存机制,Next.js 已经把这三者的边界模糊掉了:你可以在每个路由、甚至每个组件级别选择合适的渲染策略。(Next.js)
这篇指南的目标是:
- 不只是讲「概念」,而是从 架构视角 出发,帮你在 Next.js 中真正落地一个混合项目
- 以一个典型业务为例,给出从目录结构 → 路由设计 → 数据获取 → 缓存策略 → 部署的完整思路
- 所有内容以 App Router(
app/目录)+ TypeScript 为主,少量提到 Pages Router 是为了兼容老项目
2. 目标场景:一个典型的「产品 + SaaS」站点
假设你要做这样一个站点(非常贴近日常实际):
-
首页 / 营销页:
/、/pricing、/features- 重点:SEO、首屏速度、全球访问体验
-
文档 / 博客:
/docs/*、/blog/*- 重点:内容导向 + 高并发 + SEO
-
应用控制台(SaaS):
/app/*- 重点:登录态、复杂交互、实时数据
- 对 SEO 无需求,但对响应速度和交互体验敏感
-
用户公开页:
/u/[slug]- 需要被搜索引擎收录、同时数据来自数据库(半动态)
我们希望在一个 Next.js 项目内实现:
- 营销 + 文档 + 博客 → 尽量用 Jamstack(SSG/ISR)
- 控制台
/app→ 更偏 SPA(CSR + API) - 用户公开页
/u/[slug]→ 用 SSR 或「部分静态 + 动态数据」混合
这就是本文要实现的「混合实践」目标。
3. Next.js 渲染能力总览(映射到 Jamstack / SSR / SPA)
先把 Next.js 的渲染能力映射到我们熟悉的词:
| Next.js 能力(App Router) | 对应概念 | 说明 |
|---|---|---|
| 静态渲染(Static Rendering) | Jamstack / SSG | 在构建时或首次请求时渲染,结果缓存并复用 (Next.js) |
| Revalidate(时间 / 标签) | ISR | 按时间或事件重新生成静态结果(增量静态再生)(Next.js) |
| 动态渲染(Dynamic Rendering) | SSR | 每次请求都在服务端渲染(读取 cookies、headers、no-store 等)(Next.js) |
| Client Components / CSR | SPA | 在浏览器中渲染 & 状态管理,配合 SWR / React Query 等 |
| Static Export | 纯 Jamstack / SPA | output: 'export' 生成纯静态资源,用任何静态主机部署 (Next.js) |
同时,Next.js App Router 通过:
fetch的缓存选项(cache、next.revalidate)(Next.js)generateStaticParams(替代旧的getStaticPaths)(Next.js)- 一系列 cache 相关 API(
revalidatePath、revalidateTag、cacheTag、use cache等)(Next.js)
来控制每个路由 / 组件的渲染与缓存行为。
心智模型:
Jamstack:充分利用「静态渲染 + revalidate」
SSR:让路由变成动态,或者fetch使用no-store
SPA:使用 Client Components + client 数据获取 + 路由缓存
4. 项目初始化与目录结构设计
4.1 初始化项目(App Router)
npx create-next-app@latest my-mixed-app \
--typescript \
--eslint \
--tailwind \
--src-dir \
--app
关键点:
- 勾选
--app:使用 App Router --src-dir:代码放在src/下,有利于更清晰的结构
初始化后,目录大致为:
src/
app/
layout.tsx
page.tsx
globals.css
...
4.2 为混合架构规划路由分组
我们按场景划分 Route Groups(只在 URL 中分组,不影响路径):
src/app/
(marketing)/
layout.tsx
page.tsx # 首页 /
pricing/
page.tsx # /pricing
features/
page.tsx # /features
(content)/
blog/
page.tsx # /blog
[slug]/
page.tsx # /blog/[slug]
docs/
layout.tsx
page.tsx # /docs
[slug]/
page.tsx # /docs/[slug]
(app)/
layout.tsx
dashboard/
page.tsx # /app/dashboard
settings/
page.tsx # /app/settings
(public)/
u/
[slug]/
page.tsx # /u/[slug]
api/ # Route Handlers
...
(marketing)、(content)、(app)、(public)只是分组名,不会出现在 URL 中。- 有利于为不同区域定义不同
layout和策略(导航栏、权限控制、样式等)。
5. Jamstack 区:静态渲染 + ISR(首页 / 博客 / 文档)
5.1 静态首页(SSG)
需求:
- 首页
/基本是内容 + 若干动态统计(比如用户数),但可以延后加载 - 强调:极致首屏 + SEO
Next.js App Router 中,只要:
- 不使用
cookies()/headers()这类动态 API - 不在
fetch中设置cache: 'no-store'
默认就会走静态渲染,结果可缓存并复用。(Next.js)
src/app/(marketing)/page.tsx:
// 默认静态渲染(Static Rendering)
export const metadata = {
title: 'My Product – Ship Faster with Next.js',
description: 'Jamstack + SSR + SPA hybrid SaaS starter built with Next.js.',
}
export default async function HomePage() {
// 静态获取一些「慢变」数据,例如来自 CMS 的文案
const cmsData = await fetch('https://cms.example.com/home', {
// 默认就是 cache:'force-cache',可以不写
cache: 'force-cache',
next: { revalidate: 60 * 60 }, // 每小时重新拉一次(ISR)
}).then((res) => res.json())
return (
<main>
<section>{cmsData.heroTitle}</section>
{/* 统计数据可以用 Client Component + client fetch 渐进增强 */}
{/* <StatsWidget /> */}
</main>
)
}
这里 next.revalidate 让这条 fetch 对应的数据每小时再生一次,路由整体也会随之更新,实际效果等价于 ISR。(Next.js)
5.2 博客列表 + 详情:generateStaticParams + 时间 Revalidate
列表页 /blog:
// src/app/(content)/blog/page.tsx
export const revalidate = 60 * 10 // 10 分钟更新一次列表
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 * 10 }, // 再保险
})
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json() as Promise<Array<{ slug: string; title: string }>>
}
export default async function BlogIndexPage() {
const posts = await getPosts()
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((p) => (
<li key={p.slug}>
<a href={`/blog/${p.slug}`}>{p.title}</a>
</li>
))}
</ul>
</main>
)
}
详情页 /blog/[slug]:使用 generateStaticParams 生成预渲染路径。(Next.js)
// src/app/(content)/blog/[slug]/page.tsx
export const revalidate = 60 * 60 // 每小时重新生成一次页面
type Post = {
slug: string
title: string
content: string
}
async function getAllSlugs(): Promise<string[]> {
const res = await fetch('https://api.example.com/posts/slugs', {
next: { revalidate: 60 * 60 },
})
return res.json()
}
export async function generateStaticParams() {
const slugs = await getAllSlugs()
return slugs.map((slug) => ({ slug }))
}
async function getPost(slug: string): Promise<Post | null> {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 60 * 60 },
})
if (res.status === 404) return null
if (!res.ok) throw new Error('Failed to fetch post')
return res.json()
}
export default async function BlogPostPage({
params,
}: {
params: { slug: string }
}) {
const post = await getPost(params.slug)
if (!post) {
// 也可以配合 notFound()
return <div>404: Post not found</div>
}
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
种子规则:
- 所有博客的 HTML 在首个访问或构建时渲染,之后 60 分钟内复用,相当于 ISR 行为
- 非常符合「内容导向 + 中等频率更新」的 Jamstack 使用场景
5.3 On-demand Revalidation:与 CMS / Admin 联动
如果博客使用 Headless CMS,发布新文章时可以:
- 调用你自己实现的 Route Handler
- Route Handler 内部使用
revalidatePath('/blog')或revalidateTag('posts')来精准清缓存(Next.js)
src/app/api/revalidate/route.ts(示例):
// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function POST(req: NextRequest) {
const body = await req.json()
const secret = req.headers.get('x-cms-signature')
if (secret !== process.env.CMS_WEBHOOK_SECRET) {
return new NextResponse('Invalid signature', { status: 401 })
}
// 更新博客列表和详情
revalidatePath('/blog')
revalidateTag('posts')
return NextResponse.json({ revalidated: true })
}
CMS 发布 → 调用
/api/revalidate→ Next.js 重新生成缓存 → 前台站点几乎实时更新,依然保持 Jamstack 的高性能。
6. SSR 区:实时 / 个性化页面(例如 /u/[slug]、部分营销页)
6.1 动态渲染的触发条件(App Router)
Next.js App Router 中,如果满足以下条件之一,就会触发动态渲染(SSR):(Next.js)
- 使用了
cookies()、headers()等依赖请求信息的 API - 某些
fetch使用了cache: 'no-store' - 显式配置
export const dynamic = 'force-dynamic'
这通常用在:
- 需要按用户定制内容(A/B、地区、语言、登录态)
- 实时性非常强(股票价格、订单、日志流)
6.2 用户公开页 /u/[slug]:带个性化统计的 SSR
需求:
/u/[slug]是用户的公开档案页- 需要被搜索引擎收录(SEO)
- 展示用户的最新统计数据(粉丝数、作品数等)
实现思路:
- 页面 HTML 由服务端实时渲染(SSR)
- 内部数据请求
cache: 'no-store',确保每次请求都拿到最新数据
// src/app/(public)/u/[slug]/page.tsx
import { cookies } from 'next/headers'
export const dynamic = 'force-dynamic' // 明确声明为动态渲染
type Profile = {
name: string
bio: string
avatarUrl: string
stats: {
followers: number
items: number
}
}
async function getProfile(slug: string): Promise<Profile> {
const res = await fetch(`https://api.example.com/users/${slug}`, {
cache: 'no-store', // 禁用缓存,每次都拉最新数据
})
if (!res.ok) throw new Error('Failed to fetch profile')
return res.json()
}
export default async function UserProfilePage({
params,
}: {
params: { slug: string }
}) {
const profile = await getProfile(params.slug)
const cookieStore = cookies()
const theme = cookieStore.get('theme')?.value ?? 'light'
return (
<main data-theme={theme}>
<header>
<img src={profile.avatarUrl} alt={profile.name} />
<h1>{profile.name}</h1>
<p>{profile.bio}</p>
</header>
<section>
<div>Followers: {profile.stats.followers}</div>
<div>Items: {profile.stats.items}</div>
</section>
</main>
)
}
这里通过:
dynamic = 'force-dynamic'+cache: 'no-store'cookies()读取主题偏好
确保 /u/[slug] 是实时 SSR 页面,既拥有 SEO,又可做个性化和实时统计。
7. SPA 区:控制台 /app 的 CSR 设计
7.1 思路:路由仍在 Next.js,逻辑更偏 SPA
目标:
-
/app/**视作一个 Web 应用(SaaS 控制台) -
路由仍然使用 App Router(
app/(app)/dashboard/page.tsx等) -
但绝大多数组件采用 Client Components:
use client- React Query / SWR 管理请求与缓存
- 利用 Next.js 的 Router Cache +
<Link>实现无刷新导航(Next.js)
7.2 /app layout:统一导航 + 仅对登录态做 SSR 判断
src/app/(app)/layout.tsx:
// src/app/(app)/layout.tsx
import { ReactNode } from 'react'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export const dynamic = 'force-dynamic' // 登录态敏感
export default async function AppLayout({ children }: { children: ReactNode }) {
const cookieStore = cookies()
const token = cookieStore.get('token')?.value
if (!token) {
redirect('/login')
}
// 如果要减少 SSR 压力,可以只校验 token 是否存在,而不在这里做大量数据查询
return (
<html>
<body>
<div className="app-shell">
<aside>/* 左侧菜单 */</aside>
<main>{children}</main>
</div>
</body>
</html>
)
}
这里 layout 只做最小的「守卫」作用:检查是否登录。
真正的数据请求都交给 Client Components + API。
7.3 Dashboard 页面:完全客户端渲染的数据与交互
src/app/(app)/dashboard/page.tsx:
// src/app/(app)/dashboard/page.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
// 或者使用 SWR:import useSWR from 'swr'
async function fetchDashboard() {
const res = await fetch('/api/app/dashboard') // Next Route Handler
if (!res.ok) throw new Error('Failed to load')
return res.json()
}
export default function DashboardPage() {
const { data, isLoading, error } = useQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboard,
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error...</div>
return (
<div>
<h1>Dashboard</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
)
}
配套的 Server Route Handler:
// src/app/api/app/dashboard/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
// 可从 cookies / headers 取 userId,然后查数据库
const data = {
revenue: 12345,
users: 321,
// ...
}
return NextResponse.json(data)
}
这个区域的特点:
- 页面组件本身在客户端执行;路由切换、状态更新等体验如 SPA
- 服务端仅提供 JSON API(可以看作 BFF)
- 对 SEO 完全不敏感,可以放心地做复杂交互、轮询、长连接等
小结:
/app/**通过「SSR 做轻量守卫 + CSR 做业务逻辑」,在一个 Next 工程里实现了「接近纯 SPA 的交互体验」。
8. 导航与布局:连接三个世界
在混合架构中,一个常见的问题:如何让用户在 Jamstack / SSR / SPA 区之间无缝切换?
Next.js 的优势:
- 所有路由仍然由 同一个 Router 管理
- 使用
<Link>进行 客户端导航 + 预取,用户感知为单一应用(Next.js)
src/app/layout.tsx(根布局):
import Link from 'next/link'
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<header className="top-nav">
<Link href="/">Logo</Link>
<nav>
<Link href="/features">Features</Link>
<Link href="/pricing">Pricing</Link>
<Link href="/blog">Blog</Link>
<Link href="/docs">Docs</Link>
<Link href="/app/dashboard">App</Link>
</nav>
</header>
{children}
</body>
</html>
)
}
-
跳转到
/app/dashboard时:- 从营销页(SSG/ISR)进入 SPA 控制台
- 体验仍然是单页面式的快速路由切换
9. 数据获取与缓存策略组合(实战级总结)
9.1 四类典型数据需求 → 对应写法
-
几乎不变的配置 / 文案
- 如:产品特性说明、首页文案
- 写法:静态渲染 + 超长
revalidate或完全默认缓存
const data = await fetch('https://cms/...', { next: { revalidate: 60 * 60 * 24 }, // 每天 }) -
中频更新的内容(博客、商品信息)
- 写法:静态渲染 + 中等
revalidate(几分钟或几小时) - 使用
generateStaticParams(或 tags)+revalidatePath/revalidateTag
- 写法:静态渲染 + 中等
-
近实时的数据(热门榜单、公开统计)
- 写法:可以用短
revalidate或部分组件动态渲染 - 例如:页面主体 SSG,榜单部分单独用
no-store+ Suspense
async function getHotData() { const res = await fetch('https://api/.../hot', { cache: 'no-store' }) return res.json() } - 写法:可以用短
-
严格实时 + 用户私有数据
- 写法:SSR +
cache: 'no-store'或完全交给客户端(CSR) - 推荐:在
/app区域尽量使用客户端拉取 + React Query / SWR
- 写法:SSR +
9.2 Cache Components 与 Partial Prerendering(进阶)
Next.js 新引入的 Cache Components / use cache,允许你:
- 把页面中的一部分数据做成「可缓存 + 可再生」的组件
- 其他部分保持动态,在请求时渲染(Next.js)
例如商品页:
- 商品信息(标题、描述、图片) → 缓存 + ISR
- 推荐商品 / 购物车 / 登录信息 → 动态
(这块比较进阶,可以作为第二篇文章深入展开,这里先打个「概念伏笔」)
10. 部署与运行时:Jamstack / SSR / SPA 一站式上线
10.1 平台选择与运行模式
你可以:
-
部署到 Vercel:天然支持 App Router、Edge Runtime、ISR、缓存等(Next 官方维护)(Next.js)
-
或者自托管 / Docker 部署:
- 为 SSR + ISR 提供 Node.js 运行时(ISR 不支持纯静态导出)(Next.js)
- 配合 CDN(Cloudflare、Fastly 等)缓存静态资源和 HTML
10.2 静态导出 vs 带服务器部署
-
如果你只使用 Jamstack/SPA 能力(无 SSR / ISR):
-
可以配置
next.config.js:/** @type {import('next').NextConfig} */ const nextConfig = { output: 'export', } module.exports = nextConfig -
构建后得到
out/,随便丢到任意静态主机(Next.js)
-
-
如果你要用 SSR / ISR / 动态 Route Handlers:
- 必须使用
next start或平台的 Serverless / Edge Functions 支持(Next.js)
- 必须使用
10.3 上线前 Checklist(混合项目特别关注)
参考 Next 官方 Production Checklist:(Next.js)
- ✅ 检查路由:哪些应是静态?哪些必须动态?
- ✅ 对静态路由使用合理的
revalidate - ✅ 确保敏感数据的
fetch使用no-store - ✅ 使用
<Link>组件获得预取和路由缓存 - ✅ 进行 Bundle Analyze,避免
/app与/共用过多重型依赖 - ✅ 配置好
.env,在 Vercel / 服务器上注入环境变量
11. 从 0 到 1:一步步搭出你的混合项目
如果你现在就想开始动手,可以按下面这个顺序:
-
初始化项目(App Router + TS)
create-next-app,开启--app
-
规划 Route Groups 和目录
(marketing)、(content)、(app)、(public)- 设置
layout.tsx/page.tsx结构
-
先完成 Jamstack 部分(/、/blog、/docs)
- 使用静态渲染 +
revalidate - 接入 Headless CMS 或自建 API
- 使用静态渲染 +
-
再实现 SSR 页(/u/[slug])
- 使用
dynamic = 'force-dynamic'+no-store - 如有必要加上缓存策略(例如短 revalidate)
- 使用
-
最后实现 SPA 控制台(/app/)**
- layout 中做简单登录态守卫
- 业务页使用 Client Components + React Query / SWR
- 后端逻辑通过 Route Handlers 暴露为 API
-
部署 & 监控
-
部署到 Vercel 或自托管环境
-
核心关注两个维度:
- 静态页面缓存命中率 / ISR 行为
- SSR 路由的响应时间与错误率
-
12. 结语:用一个工程,承载三种世界
利用 Next.js 的 App Router、数据获取与缓存机制,你完全可以在 一个工程、一个部署单元 里:
- 实现一个性能极佳的 Jamstack 内容站(首页、文档、博客)
- 同时提供高质量的 SSR 动态页面(公开档案页、SEO 关键页)
- 并在
/app区域中运行一个体验接近原生 SPA 的控制台 / SaaS 产品
最后可以记住这样一句话作为实践原则:
静态优先,动态按需,交互交给客户端。