neptay
インサイト一覧

エンジニアリング

2言語対応のNext.js App Routerサイト:i18n、hreflang、構造化データを正しく実装する

2言語(EN/TR)のNext.js App Routerサイトをどう公開しているかを、実践的に解説します。ロケールごとのルーティング、hreflangの代替URL、ロケール別のJSON-LD、そして両方の言語を等しく主役に保つ、ミドルウェアの小さな工夫。

この記事の内容

2言語対応をうたうWebサイトの多くは、実のところ単一言語のサイトに翻訳レイヤーを後付けしたものです。最初に公開されるのも、フォームがきちんと動くのも、検索エンジンが最初に覚えるのも英語版。翻訳版は、翻訳メモリから吐き出されたJSONファイルと、誰もテストしていないヘッダーのドロップダウンにすぎません。

私たちがつくりたかったのは、そういうものではありません。neptay.comは英語とトルコ語で始まり、いまでは同じコンテンツレイヤーから10言語で運用しています。ロケールに応じたルーティング、メタデータ、サイトマップ、hreflang、構造化データもそろっています。トルコ語版は後付けの改修ではなく、同じサイトを別の声で語ったものです。この記事では、そのエンジニアリングを順を追って解説します。Next.js App Routerでの構成方法、ミドルウェアに何を置くか、Googleに信頼されるhreflangの書き方、そして後になって効いてくる小さな構造上の判断について。

スタジオのページ、レストランのメニュー、地域向けのランディングページといった小規模な2言語サイトなら、このパターンをほぼそのまま流用できます。50言語のECサイトを公開するなら、これが土台になります。辞書をCMSのフィードに差し替えるだけです。

URL空間の設計

2言語対応のNext.jsサイトでURLをどう構成するかには、基本的に3つの選択肢があります。

  1. ロケールごとのサブドメイン(en.example.com、tr.example.com)。分離は明確ですが、TLSや分析の管理にコストがかかり、小規模なサイトでは見た目にも分断されます。
  2. ロケールごとの国別TLD(example.com、example.com.tr)。ジオターゲティングには最適ですが、本当にトルコ向けの事業を運営している場合にしか見合いません。それ以外ではコストがかさみます。
  3. パスごとのロケールプレフィックス(example.com/en/、example.com/tr/)。ドメインも、デプロイも、分析プロパティもひとつ。私たちが採用しているのはこの方式で、小規模な2言語サイトの多くもこれを選ぶべきです。

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は複数のルートレイアウトに対応しており、ロケールの境界こそ、それを使うのにふさわしい場所です。

ミドルウェア:正しいロケールを判定してリダイレクトする

誰かがドメイン直下にアクセスしてきたとき、どこへ案内するかを決めなければなりません。手がかりは3つあります。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)).*)",
  ],
};

ここでは2つの細部が効いています。Accept-Languageのチェックは、startsWithではなく、ヘッダーのカンマ区切りリストをきちんと解析しています。「en-GB,tr;q=0.8」を送ってくるブラウザを、どこかに「tr」が含まれているというだけでトルコ語として扱うべきではありません。また、Cookieは手動で言語を切り替えたときだけでなく、ロケールのプレフィックス付きのURLにアクセスするたびに書き込まれます。だから次にドメイン直下を訪れたとき、訪問者は最後に読んでいた言語に着地します。これがないと、言語の切り替えが壊れているように感じられます。明示的にトルコ語を選んだリピーターが、Accept-Languageの推測に押し戻されてしまうのです。

ロケールごと、ルートごとのメタデータ

App Routerのページは、どれもgenerateMetadataをエクスポートできます。2言語サイトでは、この関数の中で3つのことを行う必要があります。

  1. 現在のロケールでタイトルとディスクリプションを設定する。
  2. このパスそのものを指すcanonical URLを設定する(末尾スラッシュの問題も、プロトコルの不整合もなく)。
  3. このページが存在するすべてのロケールについてhreflangの代替URLを設定し、x-defaultも加える。

Next.jsなら、alternates.languagesオブジェクトでこれを簡単に実現できます。ルートごとの形は次のとおりです。

コード例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について、よくある間違いが2つあります。

  • 代替URLは、必ず相互に参照し合う必要があります。/en/workが/tr/workを代替として挙げているなら、/tr/workも/en/workを挙げなければなりません。戻りの参照が欠けていると、Googleはそのhreflangグループ全体を無視します。
  • x-defaultはロケールではありません。どの言語にも当てはまらないユーザーのためのURLです。私たちが/en/workを指定しているのは、英語が最も幅広いフォールバックだからであって、英語のほうが重要だからではありません。

ページ数が多いなら、任意のパスに対してalternatesオブジェクトを生成する小さなヘルパーを書きましょう。私たちはインラインで書いています。ページが数えるほどしかなく、夜11時に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を列挙している点に注目してください。冗長ですが、それは意図したものです。検索エンジンは、URLのパターンから推測するより、明示されたペアをクロールするほうを好みます。

構造化データ:ひとつの基本グラフと、ルートごとの拡張

Schema.orgのJSON-LDは、Googleが多言語サイトを理解するためのもうひとつの柱です。私たちはこれを2つの層に分けています。

  1. ルートレイアウトが一度だけ挿入する基本グラフ(Organization + WebSite)。サイトの所有者、ロゴの場所、サイトが公開されている言語を宣言します。
  2. 各ページが挿入するルートごとのスキーマ(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` },
    // …
  };
}

辞書パターン

翻訳文字列は、ロケールをキーにしたconstのマップとしてエクスポートされる、ひとつの型付き辞書にまとめています。流行りのやり方ではありません。いまどきの定番は、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がエラーを出します。英語にキーを追加してトルコ語に追加し忘れれば、ビルドは失敗します。2つの言語を扱ううえで、これには大きな価値があります。公開するには、両方を足並みそろえて保つしかないということだからです。

このパターンが限界を迎えるのは、20言語前後になったときか、エンジニア以外の人がコピーを編集する必要が出てきたときです。そうなったら辞書をCMSに移し、翻訳が遅れがちになることを受け入れましょう。2言語のスタジオサイトなら、素のTypeScriptが勝ちます。

静的レンダリング、エッジキャッシュ、そしてSEOにとっての意味

ここまでの内容は、ページを1回読み込むだけならパフォーマンスに影響しません。効いてくるのは規模が大きくなったときです。すべてのページを静的にレンダリングしておけば(実行時のデータベース参照も、リクエストごとの翻訳呼び出しもなし)、すべてのページのすべてのロケールが、キャッシュ可能でエッジにキャッシュされたHTMLドキュメントになります。最初の1バイトが届くまでの時間はCDN次第で、地球上のどこでも通常30〜80msです。

これがSEOにとって重要な理由は2つあります。Core Web VitalsはTTFBとLCPを重く評価しますし、Googleのクローラーには予算があります。静的でエッジにキャッシュされたページなら、クローラーのコストは安い往復1回分。サーバーレンダリングのページでは、その10倍かかることもあります。5ページのスタジオサイトならクロールバジェットは問題になりませんが、200本の記事を抱える多言語ブログでは、すぐに問題になります。

まだ改善したいこと

基本を押さえたあとも、2言語のApp Routerサイトでやる価値のあることを、正直にリストにしました。

  • ロケールごとのOpenGraph画像。現在は両方のロケールで同じOG画像を使っています。理想は、画像内の見出しも翻訳されることです。
  • 翻訳のずれの自動検出。ENとTRの辞書のキーを比較し、不一致があれば失敗させるCIステップ。簡単で、価値があります。
  • 翻訳があればユーザーを同じページにとどめ、なければ(注記付きで)スムーズにフォールバックする言語切り替えコンポーネント。

2言語サイトを計画中で、アーキテクチャをもうひとつの目で確認したい方、あるいは構築をお任せいただける方は、hello@neptay.comまでご連絡ください。

Neptay Media & Technology Services

ご相談ください

同様のご計画はありますか?

これらの記事で紹介している手法は、ソフトウェア開発、AI自動化、コンテンツ制作、SNS運用、ライブ配信制作など、クライアントのプロジェクトでも実際に使っているものです。お考えのことをお聞かせください。24時間以内にご返信します。