Next.js 16 App Router: Production Patterns and Pitfalls

Production-ready Next.js 16 App Router patterns: server components, caching strategies, metadata API, error boundaries, and performance pitfalls to avoid.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分钟阅读1.5k 字数|

Next.js 16 应用程序路由器:生产模式和陷阱

Next.js App Router 从根本上改变了我们构建 React 应用程序的方式,到 Next.js 16,这些模式已经成熟为真正的生产就绪型模式。但 Pages Router 的概念转变是巨大的——服务器组件、部分预渲染、嵌套布局和完全重新设计的数据获取模型都需要重新布线思维模型,而仅靠文档无法完全传达。

本指南涵盖了在生产中实际重要的模式:防止缓存中毒、消除不必要的客户端捆绑包以及保持 Core Web Vitals 大规模绿色的模式。我们将借鉴构建 249 页 Next.js 16 应用程序的经验,该应用程序具有 11 个语言环境 i18n、动态 OG 图像以及 5,577 个 MDX 内容文件的服务器端渲染。

要点

  • 切勿在需要区域设置感知 SEO 的页面上使用 export const metadata — 始终使用 generateMetadata()
  • 服务器组件是默认的;仅当您确实需要交互时才添加 'use client'
  • loading.tsxerror.tsx 与它们的路线段并置,而不是在根处
  • proxy.ts 文件是 Next.js 16 的中间件 — 不要同时创建 middleware.tsproxy.ts
  • 服务器组件中的 fetch() 自动删除同一渲染中相同请求的重复数据
  • 带有标签的 unstable_cache 可以使外科手术缓存失效,而无需重新验证所有内容
  • 对于要在构建时静态生成的动态路由,需要 generateStaticParams()
  • JSON-LD 结构化数据必须使用 unicode 编码清理脚本突破

服务器组件心理模型

开发人员在使用 App Router 时犯的最大错误是太早到达 'use client'。在 Next.js 16 中,app/ 目录中的每个组件默认都是服务器组件。这是正确的默认设置 — 服务器组件在服务器上呈现,对 JavaScript 捆绑影响为零,并且可以直接访问数据库和 API。

规则:将 'use client' 推入组件树的叶子。页面可以是获取数据并将其传递到处理交互的瘦客户端组件 shell 的服务器组件。

// app/[locale]/blog/[slug]/page.tsx — Server Component (no directive needed)
import { getBlogPost } from '@/lib/blog';
import { BlogContent } from '@/components/blog/blog-content'; // Client Component

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string; locale: string }>;
}) {
  const { slug, locale } = await params;
  const post = await getBlogPost(slug, locale);

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      {/* Server Component — no JS sent to browser */}
      <PostMeta author={post.author} date={post.date} />
      {/* Client Component — only this file sent as JS */}
      <BlogContent content={post.content} />
    </article>
  );
}
// components/blog/blog-content.tsx — Client Component
'use client';

import { useState } from 'react';

export function BlogContent({ content }: { content: string }) {
  const [expanded, setExpanded] = useState(false);

  return (
    <div>
      <div>{content}</div>
      {/* Client-side interactivity lives here */}
    </div>
  );
}

关键见解:BlogContent 将 JavaScript 发送到浏览器。 PostMeta 没有。每个不必要的 'use client' 指令都会使您的包膨胀。


在静态元数据上生成元数据

静态 export const metadata 因其简单性而很诱人,但它无法识别区域设置,无法访问路由参数,也无法获取数据。对于任何实际应用程序,generateMetadata() 是唯一正确的选择:

// app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string; locale: string }>;
}): Promise<Metadata> {
  const { slug, locale } = await params;
  const post = await getBlogPost(slug, locale);

  if (!post) return {};

  const locales = ['en', 'zh', 'es', 'ar', 'pt', 'fr', 'de', 'ja', 'tr', 'hi', 'ur'];

  return {
    title: post.title,
    description: post.description,
    alternates: {
      canonical: `https://ecosire.com/${locale === 'en' ? '' : locale + '/'}blog/${slug}`,
      languages: Object.fromEntries(
        locales.map((loc) => [
          loc,
          `https://ecosire.com/${loc === 'en' ? '' : loc + '/'}blog/${slug}`,
        ])
      ),
    },
    openGraph: {
      title: post.title,
      description: post.description,
      images: [`/api/og/blog/${slug}?locale=${locale}`],
      type: 'article',
      publishedTime: post.date,
    },
  };
}

alternates.languages 字段为每个语言环境生成 hreflang 标签 - 对于多语言 SEO 至关重要。搜索引擎使用这些为每个用户提供正确的语言版本。


嵌套布局和共享状态

App Router 的嵌套布局系统功能强大,但有一个不明显的限制:布局无法通过 props 将数据传递给其子级。解决方法是使用 React Context(仅限客户端组件)或在每个级别独立获取数据。

app/
  [locale]/
    layout.tsx          <- Locale layout: fonts, i18n provider, theme
    page.tsx            <- Homepage
    blog/
      layout.tsx        <- Blog layout: sidebar, breadcrumbs
      page.tsx          <- Blog list
      [slug]/
        layout.tsx      <- Post layout: reading progress, TOC
        page.tsx        <- Post content
        loading.tsx     <- Skeleton while post loads
        error.tsx       <- Error boundary for this segment

每个 layout.tsx 独立地包装其子级。在父布局中获取的数据不会自动提供给子级 - 每个需要数据的组件都会独立获取数据。 Next.js 16 在渲染周期内删除重复的相同 fetch() 调用,因此这并不像听起来那么昂贵。

// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const messages = await getMessages();

  return (
    <html lang={locale} dir={['ar', 'ur'].includes(locale) ? 'rtl' : 'ltr'}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

缓存策略:四层

Next.js 16 有四个不同的缓存层。需要了解所有四个因素才能推断页面更新或不更新的原因:

1.请求记忆 — 一个渲染周期内的相同 fetch() URL 被调用一次。自动,无需配置。

2.数据缓存 — 跨请求持续存在。默认情况下,fetch() 响应无限期缓存。

// Force revalidation every hour
const data = await fetch('/api/posts', { next: { revalidate: 3600 } });

// Never cache (equivalent to SSR)
const data = await fetch('/api/posts', { cache: 'no-store' });

3.完整路由缓存 — 在构建静态路由时缓存的静态 HTML 和 RSC 负载。

4.路由器缓存 — 已访问路由的客户端缓存。在浏览器会话期间持续存在。

对于数据库查询(非获取),请使用 unstable_cache

import { unstable_cache } from 'next/cache';

const getCachedBlogPosts = unstable_cache(
  async (locale: string) => {
    return db.select().from(posts).where(eq(posts.locale, locale));
  },
  ['blog-posts'], // Cache key
  {
    revalidate: 3600, // Revalidate hourly
    tags: ['blog'], // Tag for manual invalidation
  }
);

// Invalidate from a Server Action or Route Handler:
import { revalidateTag } from 'next/cache';
revalidateTag('blog'); // All blog-tagged caches cleared

误差边界:需要两个级别

生产应用程序需要两个错误边界:一个用于区域设置感知页面,一个用于根级错误的全局回退。

// app/[locale]/error.tsx — Locale-level error boundary
'use client';

import { useTranslations } from 'next-intl';
import { useEffect } from 'react';

export default function LocaleError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  const t = useTranslations('errors');

  useEffect(() => {
    // Log to error tracking service
    console.error(error);
  }, [error]);

  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h2>{t('title')}</h2>
      <p>{t('description')}</p>
      <button onClick={reset}>{t('retry')}</button>
    </div>
  );
}
// app/global-error.tsx — Root fallback (no i18n available here)
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  // Cannot use useTranslations here — no NextIntlClientProvider above
  // Use inline styles — no Tailwind in root error boundaries
  return (
    <html>
      <body>
        <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '2rem' }}>
          <h2 style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>Something went wrong</h2>
          <button
            onClick={reset}
            style={{ marginTop: '1rem', padding: '0.5rem 1rem', cursor: 'pointer' }}
          >
            Try again
          </button>
        </div>
      </body>
    </html>
  );
}

global-error.tsx 在触发时会替换根 layout.tsx,因此它必须包含 <html><body> 标记。它无法访问布局中的上下文提供程序 — 没有 i18n、没有主题、没有身份验证上下文。


中间件:proxy.ts 与 middleware.ts

Next.js 使用 middleware.ts (或 middleware.js)作为边缘中间件。在使用 next-intl v4 的项目中,中间件设置通常会重命名为 proxy.ts 并重新导出,从而产生一个严重的陷阱:不要同时创建 middleware.tsproxy.ts。两者同时存在会导致构建错误。

将 i18n 与身份验证保护相结合的正确模式:

// src/proxy.ts — This IS the Next.js middleware
import createMiddleware from 'next-intl/middleware';
import { NextRequest, NextResponse } from 'next/server';
import { routing } from './i18n/routing';

const intlMiddleware = createMiddleware(routing);

const protectedPaths = ['/dashboard', '/portal'];

export default function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Check auth for protected paths
  const isProtected = protectedPaths.some((path) =>
    pathname.includes(path)
  );

  if (isProtected) {
    const token = request.cookies.get('ecosire_auth');
    if (!token) {
      const url = request.nextUrl.clone();
      url.pathname = '/auth/login';
      // Prevent open redirect — never allow // prefix
      const redirect = pathname.startsWith('//') ? '/' : pathname;
      url.searchParams.set('redirect', redirect);
      return NextResponse.redirect(url);
    }
  }

  return intlMiddleware(request);
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};

这个单个文件处理 i18n 区域设置检测和身份验证保护。 config.matcher 不包括静态文件、API 路由和 Next.js 内部。


JSON-LD 结构化数据安全

JSON-LD 脚本是常见的 XSS 向量。如果您的数据包含结束脚本标记,则它可以突破脚本块。该修复是强制性的——对小于字符进行编码:

// components/seo/JsonLd.tsx
interface JsonLdProps {
  data: Record<string, unknown>;
}

export function JsonLd({ data }: JsonLdProps) {
  // Sanitize script tag breakout attempts by encoding '<' as unicode
  const json = JSON.stringify(data).replace(/</g, '\\u003c');

  return (
    <script
      type="application/ld+json"
      // Safe: content is JSON-serialized and sanitized above
      suppressHydrationWarning
      ref={(el) => { if (el) el.textContent = json; }}
    />
  );
}

在页面中的用法:

<JsonLd
  data={{
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.description,
    inLanguage: locale,
    author: {
      '@type': 'Organization',
      name: 'ECOSIRE',
    },
  }}
/>

inLanguage 字段对于多语言 SEO 至关重要 - 它告诉 AI 爬虫和搜索引擎内容采用哪种语言,从而提高非英语查询的排名准确性。


动态 OG 图像

Next.js 16 的 ImageResponse API 在边缘生成每页 OG 图像。博客文章的设置:

// app/api/og/blog/[slug]/route.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export async function GET(
  request: Request,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params;
  const post = await getPostMeta(slug);

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'flex-end',
          width: '100%',
          height: '100%',
          background: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%)',
          padding: '60px',
        }}
      >
        <div style={{ fontSize: 48, fontWeight: 700, color: 'white', lineHeight: 1.2 }}>
          {post.title}
        </div>
        <div style={{ fontSize: 24, color: '#94a3b8', marginTop: 20 }}>
          {post.description}
        </div>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

常见陷阱和解决方案

陷阱 1:当服务器组件工作时在客户端组件中获取数据

如果一个组件仅显示数据并且不需要React状态或浏览器API,那么它应该是服务器组件。添加 'use client' 会强制将所有内容导入到客户端捆绑包中。

陷阱 2:缺少异步服务器组件周围的 Suspense 边界

// Without Suspense — entire page blocks until data loads
export default async function Page() {
  const data = await slowFetch(); // 2 seconds
  return <div>{data}</div>;
}

// With Suspense — page streams, slow part shows skeleton
export default function Page() {
  return (
    <Suspense fallback={<DataSkeleton />}>
      <SlowDataComponent />
    </Suspense>
  );
}

陷阱 3:在缓存函数中使用 cookies() 或 headers()

cookies()headers() 是动态 API,用于选择动态渲染的路径。在 unstable_cache 回调中使用它们会默默地破坏缓存。

陷阱 4:忘记在 Next.js 16 中等待参数

在 Next.js 16 中,params 现在是一个 Promise。忘记 await 会导致运行时错误:

// Next.js 15 — params was synchronous
export default function Page({ params }: { params: { slug: string } }) {
  const { slug } = params;
}

// Next.js 16 — params is async
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params; // Required
}

常见问题

什么时候应该使用generateStaticParams() 与动态渲染?

对于不经常更改的内容(博客文章、产品页面、文档)使用 generateStaticParams()。这会在构建时生成静态 HTML,运行时成本为零。对个性化内容、实时数据或具有数千种变体的页面使用动态渲染,而构建时间会令人望而却步。将两者与 ISR (revalidate) 结合起来以获取偶尔更改的内容。

如何在 App Router 中处理身份验证?

将身份验证令牌存储在 HttpOnly cookie 中,而不是 localStorage 或 sessionStorage。使用中间件 (proxy.ts) 检查受保护路由的 cookie 是否存在。在服务器组件中,从 next/headers 读取带有 cookies() 的 cookie。通过 props 或 React Context 将用户数据从服务器传递到客户端,而不是通过重新读取客户端组件中的 cookie。

loading.tsx 和 Suspense 有什么区别?

loading.tsx 是一个特殊文件,Next.js 自动将整个路由段包装在 Suspense 边界中。当片段的异步内容加载时,它会立即显示。手动 Suspense 边界为您提供更好的控制 - 您可以为页面的不同部分显示不同的骨架。两者都使用相同的底层 React Suspense 机制。

如何在服务器和客户端组件之间共享状态?

你不能——服务器组件渲染一次并且没有运行时状态。通过 props 将数据从服务器传递到客户端。对于需要跨导航保留的客户端状态,请使用 URL 搜索参数(用于可共享状态)、Zustand 或 Redux(用于临时状态)或 cookie(用于用户首选项)。切勿尝试将客户端组件的状态导入服务器组件。

如何设置 Turbopack 进行开发?

--turbo 添加到您的开发脚本:"dev": "next dev --turbo"。 Turbopack 是 Next.js 16 基于 Rust 的捆绑器,具有显着更快的冷启动和 HMR。它适用于大多数项目,但有一些 webpack 插件不兼容。在切换之前,根据 Turbopack 兼容性列表检查您的特定依赖项。


后续步骤

构建一个处理 11 个区域设置、数千个页面和企业级性能要求的生产 Next.js 16 应用程序是一项重大的工程任务。 ECOSIRE 的前端团队已经交付了这一点——一个 249 页的 Next.js 16 平台,具有多语言 SEO、动态 OG 图像和亚秒级响应时间。

如果您正在扩展 Next.js 应用程序或需要企业前端工程支持,请探索我们的开发服务 以了解我们如何提供帮助。

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天