neptay
全部洞察

工程技术

构建双语 Next.js App Router 网站:把 i18n、hreflang 和结构化数据做对

实战讲解我们如何交付双语(英语/土耳其语)Next.js App Router 网站——语言区域路由、hreflang 替代链接、按语言区域输出的 JSON-LD,以及让两种语言同样重要的几个中间件小技巧。

本文目录

大多数双语网站,其实是外挂了一层翻译的单语网站。英语版最先上线,表单能正常工作,也最先被搜索引擎收录。翻译版则是一个从翻译记忆库里随手导出的 JSON 文件,外加一个从没人测试过的页头下拉菜单。

这不是我们想要构建的东西。neptay.com 最初只有英语和土耳其语,如今已基于同一内容层以十种语言运行,路由、元数据、站点地图、hreflang 和结构化数据都能感知语言区域。土耳其语版并不是后期改造的产物——它就是同一个网站,只是换了一种声音。本文是一次工程层面的完整讲解:我们如何在 Next.js App Router 上完成搭建,中间件里该放什么,hreflang 要怎样写才能赢得 Google 的信任,以及那些日后会带来回报的结构性小决策。

如果你要上线的是一个小型双语网站——工作室主页、餐厅菜单、区域性落地页——这套模式几乎可以整体照搬。如果你要做的是一个五十种语言的电商网站,这就是地基;你只需把词典换成 CMS 数据源。

URL 空间的形态

双语 Next.js 网站的 URL 布局,基本上有三种选择:

  1. 每种语言一个子域名(en.example.com、tr.example.com)。隔离彻底,但 TLS 证书和数据分析的管理成本高,对小网站来说在观感上也显得割裂。
  2. 每种语言一个国家顶级域名(example.com、example.com.tr)。地理定位效果极佳,但只有当你真的在经营土耳其本地业务时才值得——否则代价不菲。
  3. 每条路径加语言前缀(example.com/en/、example.com/tr/)。单一域名、单次部署、单一数据分析属性。这正是我们采用的方案,也是大多数小型双语网站应该采用的方案。

Next.js App Router 能干净利落地处理语言前缀,因为它的路由由文件系统驱动。整个网站都放在 app/[locale]/ 下,每个叶子页面都会以路由参数的形式拿到语言区域。首页没有特殊处理,“已翻译”与“未翻译”的路由之间也没有隐藏的分界——语言区域不过是路径中的又一个段。

我们采用的目录结构

代码示例
app/
  [locale]/
    layout.tsx       ← root layout: <html lang>, nav, footer, JSON-LD, hreflang
    page.tsx         ← home
    services/page.tsx
    work/
      page.tsx       ← work index
      [slug]/page.tsx← case study
    studio/page.tsx
    contact/page.tsx
    insights/
      page.tsx
      [slug]/page.tsx
    [...rest]/page.tsx ← catch-all → notFound()
    not-found.tsx    ← localized 404
  sitemap.ts
  robots.ts

注意少了什么:这里没有 app/layout.tsx。语言区域布局本身就是根布局——它直接掌管 <html> 元素,所以 <html lang="…"> 直接来自路由参数。不用在请求头上做文章,不用在客户端打补丁,更关键的是不调用 headers()——那会悄无声息地让每条路由都退出静态生成。位于语言区域体系之外的板块(我们的在线演示)有各自的根布局——Next.js 支持多个根布局,而语言区域的边界正是使用它的最佳位置。

中间件:检测并重定向到正确的语言区域

当有人直接访问裸域名时,你必须决定:把他们送到哪里?你手上有三个信号——URL(如果对方明确输入了 /tr/)、Accept-Language 请求头(浏览器想要什么),以及 Cookie(对方上次的选择)。我们用的规则朴素,但管用:

  1. 如果 URL 已带有语言前缀,就尊重它,并写入 Cookie 记住这次选择。
  2. 否则,如果存在上次访问留下的 Cookie,就重定向到该语言区域。
  3. 否则,如果 Accept-Language 请求头能对应到我们支持的语言区域,就重定向过去。
  4. 否则,回退到默认语言区域(英语)。

我们把这段逻辑放在 middleware.ts 中,而不是布局里——这样更快,在边缘运行,还能避免水合不匹配。下面是 neptay.com 上运行的中间件的简化版本:

middleware.tsTypeScript
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { defaultLocale, isLocale, locales } from "@/lib/i18n";

const LOCALE_COOKIE = "neptay.locale";

function pickLocaleFromAcceptLanguage(header: string | null): "en" | "tr" {
  if (!header) return defaultLocale;
  const lower = header.toLowerCase();
  if (/(^|[,;\s])tr(-|;|,|$)/.test(lower)) return "tr";
  return defaultLocale;
}

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

  // Live demos live outside the locale system — they handle their own language.
  if (pathname === "/demo" || pathname.startsWith("/demo/")) {
    return NextResponse.next();
  }

  const matchedLocale = locales.find(
    (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
  );
  if (matchedLocale) {
    const res = NextResponse.next();
    // Remember the locale so the next bare-domain visit lands on it.
    if (req.cookies.get(LOCALE_COOKIE)?.value !== matchedLocale) {
      res.cookies.set(LOCALE_COOKIE, matchedLocale, {
        path: "/",
        maxAge: 60 * 60 * 24 * 365,
        sameSite: "lax",
      });
    }
    return res;
  }

  // Honour the user's manual choice if it was already remembered.
  const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
  const remembered = cookieLocale && isLocale(cookieLocale) ? cookieLocale : null;
  const detected = remembered ?? pickLocaleFromAcceptLanguage(req.headers.get("accept-language"));

  const url = req.nextUrl.clone();
  url.pathname = `/${detected}${pathname === "/" ? "" : pathname}`;
  return NextResponse.redirect(url);
}

export const config = {
  // Skip Next internals + static assets so they aren't redirected.
  matcher: [
    "/((?!_next/|api/|favicon\\.|robots\\.txt|sitemap\\.xml|.*\\.(?:png|svg|jpg|jpeg|gif|webp|ico|txt|xml)).*)",
  ],
};

这里有两个细节功不可没。Accept-Language 检查是对请求头中逗号分隔列表的真正解析,而不是简单的 startsWith——浏览器发送 "en-GB,tr;q=0.8" 时,不应仅仅因为某处出现了 "tr" 就被当作土耳其语。此外,每次访问带语言前缀的页面都会写入 Cookie,而不只是在手动切换时才写入,所以下次访问裸域名时,访客会落在他上次阅读的语言上。少了这一点,语言切换就会显得像坏了一样:一位明确选择了土耳其语的回访者,会被弹回按 Accept-Language 猜测的语言。

元数据:按语言区域、按路由

App Router 中的每个页面都可以导出 generateMetadata。对双语网站来说,这个函数里需要做三件事:

  1. 用当前语言设置 title 和 description。
  2. 设置指向这条确切路径的规范网址(canonical URL)——没有末尾斜杠问题,也没有协议上的古怪之处。
  3. 为这个页面存在的每个语言区域设置 hreflang 替代链接,外加一个 x-default。

借助 alternates.languages 对象,Next.js 让这件事变得轻而易举。下面是单条路由的写法:

代码示例TypeScript
export async function generateMetadata({ params }: { params: { locale: string } }): Promise<Metadata> {
  if (!isLocale(params.locale)) return {};
  const seo = WORK_SEO[params.locale];
  return {
    title: seo.title,
    description: seo.description,
    alternates: {
      canonical: `/${params.locale}/work`,
      languages: {
        en: "/en/work",
        tr: "/tr/work",
        "x-default": "/en/work",
      },
    },
    openGraph: { title: seo.title, description: seo.description, type: "website" },
  };
}

关于 hreflang,人们常犯两个错误:

  • 每个替代链接都必须互相指回。如果 /en/work 把 /tr/work 列为替代版本,/tr/work 也必须列出 /en/work。缺少回指会让 Google 忽略整组 hreflang。
  • x-default 不是一种语言区域。它是给语言与你所有版本都不匹配的用户准备的 URL。我们把它指向 /en/work,是因为英语是覆盖面最广的回退语言——而不是因为英语更重要。

如果页面很多,就写一个小工具函数,为任意路径生成 alternates 对象。我们直接内联书写,因为我们的页面屈指可数;而当你深夜十一点盯着 Search Console 排查问题时,显式写法更容易调试。

站点地图,以及站点地图中的 hreflang

在元数据中按路由设置 hreflang,只完成了一半工作。Google 也会从站点地图读取 hreflang,而且站点地图是更具权威性的信号,因为它一次就覆盖整个网站。App Router 内置了 sitemap.ts 约定,我们的写法如下:

app/sitemap.tsTypeScript
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { locales } from "@/lib/i18n";

const SITE = "https://neptay.com";
const PATHS = ["", "services", "work", "studio", "contact", "insights"] as const;

export default function sitemap(): MetadataRoute.Sitemap {
  const lastModified = new Date();
  return locales.flatMap((locale) =>
    PATHS.map((p) => ({
      url: `${SITE}/${locale}${p ? `/${p}` : ""}`,
      lastModified,
      changeFrequency: "monthly" as const,
      priority: p === "" ? 1 : 0.7,
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${SITE}/${l}${p ? `/${p}` : ""}`])
        ),
      },
    }))
  );
}

注意,站点地图为每个(语言区域,路径)组合各输出一条记录,每条记录都列出所有语言区域的替代版本。这很重复——但这是刻意为之。比起从 URL 规律中猜测,搜索引擎更乐于抓取明确的对应关系。

结构化数据:一张基础图谱,按路由扩展

Schema.org JSON-LD 是 Google 理解多语言网站的另一条腿。我们把它分成两层:

  1. 基础图谱(Organization + WebSite),由根布局注入一次。它声明网站归谁所有、Logo 在哪里,以及网站以哪些语言发布。
  2. 按路由的 schema(CreativeWork、BreadcrumbList、Article、FAQPage),由各个页面注入。它们是一些小型 JSON-LD 块,用来描述这个具体页面是什么。

两层都要把 inLanguage 设为当前语言区域。千万别省略——没有 inLanguage,Google 就只能从页面文字推断语言,对于内容较短的页面,这既慢又不准确。

lib/structured-data.tsTypeScript
// lib/structured-data.ts
export const organizationLd = {
  "@type": "Organization",
  "@id": `${SITE_URL}/#organization`,
  name: "Neptay",
  url: SITE_URL,
  logo: { "@type": "ImageObject", url: `${SITE_URL}/neptay-logo.png` },
  knowsLanguage: ["en", "tr"],
  areaServed: "Worldwide",
};

export function articleLd(input: ArticleInput) {
  return {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: input.headline,
    inLanguage: input.locale,            // ← critical for bilingual sites
    datePublished: input.datePublished,
    author: { "@id": `${SITE_URL}/#organization` },
    publisher: { "@id": `${SITE_URL}/#organization` },
    // …
  };
}

词典模式

翻译字符串存放在一个带类型的词典中,以按语言区域为键的常量映射导出。这种做法并不时髦——如今的主流建议是使用 next-intl 或 react-intl 这类框架,配合 ICU 消息格式。但对小网站来说,带类型的词典更快、更小,还能在编译时发现缺失的翻译:

代码示例TypeScript
export type Locale = "en" | "tr";
export const locales: Locale[] = ["en", "tr"];

export interface Dictionary {
  locale: Locale;
  nav: { services: string; work: string; studio: string; contact: string };
  // …
}

export const dictionaries: Record<Locale, Dictionary> = {
  en: { /* … */ },
  tr: { /* … */ },
};

如果某个字符串缺少土耳其语翻译,TypeScript 会在构建时报错。如果某个键在英语中新增了而土耳其语中没有,构建就会失败。当你维护两种语言时,这一点价值连城——它意味着唯一的上线方式,就是让两种语言步调一致。

当语言达到二十种左右,或者需要非工程人员编辑文案时,这种模式就撑不住了。到那时,把词典迁移到 CMS,并接受翻译会有所滞后。对于两种语言、一个工作室网站来说,纯 TypeScript 完胜。

静态渲染、边缘缓存,以及它们为何关乎 SEO

对单次页面加载而言,以上种种都不影响性能。真正起作用的是规模。让每个页面都保持静态渲染(没有运行时数据库查询,没有逐请求的翻译调用),每个页面的每种语言版本都是可缓存、在边缘缓存的 HTML 文档。首字节时间取决于你的 CDN——在全球任何地方,通常都是 30–80ms。

这对 SEO 的意义有两点:Core Web Vitals 对 TTFB 和 LCP 的权重很高,而 Google 的爬虫有抓取预算。一个静态、边缘缓存的页面,只需爬虫付出一次低成本的往返;一个服务端渲染的页面,成本可能是它的十倍。对一个只有五个页面的工作室网站来说,抓取预算不是问题;但对一个拥有两百篇文章的多语言博客来说,它很快就会成为问题。

我们还想改进的地方

打好基础之后,一个双语 App Router 网站还有哪些事值得做?下面是一份坦诚的清单:

  • 按语言区域生成 OpenGraph 图片。目前两种语言共用同一张 OG 图片;理想情况下,图片上的标题也应该翻译。
  • 自动检测翻译偏差。在 CI 中加一个步骤,比对英语和土耳其语词典的键,一旦不一致就让构建失败。简单,且很有价值。
  • 一个语言切换组件:如果存在对应翻译,就让用户停留在同一页面;如果没有,就优雅地回退(并附上说明)。

如果你正在规划一个双语网站,希望有人帮你把把架构关——或者想让我们来构建——欢迎写信至 hello@neptay.com。

Neptay Media & Technology Services

聊聊吧

正在筹划类似的项目?

这些文章中的方法,正是我们在客户项目中使用的方法——涵盖软件开发、AI 自动化、内容制作、社交媒体与直播制作。告诉我们您的想法,我们将在 24 小时内回复。