基于 Next.js 的 Jamstack / SSR / SPA 混合实践指南

1. 前言:为什么需要「混合架构」? 在现代 Web 应用里,你很难只用「一种渲染模式」解决所有问题: 首页 / 营销页 / 博客:需要 极致首屏 + SEO → 更像 Jamstack(SSG/ISR) 用户控制台 / 后台系统:强调 交互、状态管理、实时性 → 更像 SPA(CSR) 某些页面:既要 实时数据、又要 …

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」站点

假设你要做这样一个站点(非常贴近日常实际):

  1. 首页 / 营销页//pricing/features

    • 重点:SEO、首屏速度、全球访问体验
  2. 文档 / 博客/docs/*/blog/*

    • 重点:内容导向 + 高并发 + SEO
  3. 应用控制台(SaaS)/app/*

    • 重点:登录态、复杂交互、实时数据
    • 对 SEO 无需求,但对响应速度和交互体验敏感
  4. 用户公开页/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 / CSRSPA在浏览器中渲染 & 状态管理,配合 SWR / React Query 等
Static Export纯 Jamstack / SPAoutput: 'export' 生成纯静态资源,用任何静态主机部署 (Next.js)

同时,Next.js App Router 通过:

  • fetch 的缓存选项(cachenext.revalidate)(Next.js)
  • generateStaticParams(替代旧的 getStaticPaths)(Next.js)
  • 一系列 cache 相关 API(revalidatePathrevalidateTagcacheTaguse 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 四类典型数据需求 → 对应写法

  1. 几乎不变的配置 / 文案

    • 如:产品特性说明、首页文案
    • 写法:静态渲染 + 超长 revalidate 或完全默认缓存
    const data = await fetch('https://cms/...', {
      next: { revalidate: 60 * 60 * 24 }, // 每天
    })
    
  2. 中频更新的内容(博客、商品信息)

    • 写法:静态渲染 + 中等 revalidate(几分钟或几小时)
    • 使用 generateStaticParams(或 tags)+ revalidatePath/revalidateTag
  3. 近实时的数据(热门榜单、公开统计)

    • 写法:可以用短 revalidate 或部分组件动态渲染
    • 例如:页面主体 SSG,榜单部分单独用 no-store + Suspense
    async function getHotData() {
      const res = await fetch('https://api/.../hot', { cache: 'no-store' })
      return res.json()
    }
    
  4. 严格实时 + 用户私有数据

    • 写法:SSR + cache: 'no-store' 或完全交给客户端(CSR)
    • 推荐:在 /app 区域尽量使用客户端拉取 + React Query / SWR

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:一步步搭出你的混合项目

如果你现在就想开始动手,可以按下面这个顺序:

  1. 初始化项目(App Router + TS)

    • create-next-app,开启 --app
  2. 规划 Route Groups 和目录

    • (marketing)(content)(app)(public)
    • 设置 layout.tsx / page.tsx 结构
  3. 先完成 Jamstack 部分(/、/blog、/docs)

    • 使用静态渲染 + revalidate
    • 接入 Headless CMS 或自建 API
  4. 再实现 SSR 页(/u/[slug])

    • 使用 dynamic = 'force-dynamic' + no-store
    • 如有必要加上缓存策略(例如短 revalidate)
  5. 最后实现 SPA 控制台(/app/)**

    • layout 中做简单登录态守卫
    • 业务页使用 Client Components + React Query / SWR
    • 后端逻辑通过 Route Handlers 暴露为 API
  6. 部署 & 监控

    • 部署到 Vercel 或自托管环境

    • 核心关注两个维度:

      • 静态页面缓存命中率 / ISR 行为
      • SSR 路由的响应时间与错误率

12. 结语:用一个工程,承载三种世界

利用 Next.js 的 App Router、数据获取与缓存机制,你完全可以在 一个工程、一个部署单元 里:

  • 实现一个性能极佳的 Jamstack 内容站(首页、文档、博客)
  • 同时提供高质量的 SSR 动态页面(公开档案页、SEO 关键页)
  • 并在 /app 区域中运行一个体验接近原生 SPA 的控制台 / SaaS 产品

最后可以记住这样一句话作为实践原则:

静态优先,动态按需,交互交给客户端。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页