Next.js App Router で、翻訳が揃う前から多言語 hreflang を破綻させない設計
はじめに
14言語で展開しているトレード練習ツール「ENTRIQ」を個人開発しています。多言語サイトで地味に厄介なのが hreflang で、特に「記事を1言語ずつ翻訳して公開していく」運用だと、素朴に実装すると簡単に破綻します。この記事は、その失敗と、Next.js(App Router)でどう設計し直したかの共有です。
素朴な実装はなぜ壊れるか
最初は、全言語版のURLを相互に hreflang で出していました。しかし翻訳は1言語ずつ公開されるので、まだ存在しない言語版へのリンクを出すことになります。結果、クローラやユーザーを未公開ページ(404)へ誘導してしまい、SEO・UXの両方で損をします。
hreflang は「用意した言語」ではなく「実際に公開されている言語」だけを出す——これが出発点でした。
設計方針
公開状態は記事ごとに変わるので、hreflang は DBの公開状態から動的に組み立てることにしました。ルールはこの4つです。
-
alternatesに出すのはstatus=publishedの言語版だけ -
x-defaultはenが公開されているときだけ出す(en未公開なら省略して404回避) - 公開言語が1つだけなら
canonicalのみ - 公開言語が増減したら
<head>の hreflang も sitemap も自動で追従する
実装
1. hreflang生成を純関数に切り出す
公開済みの言語だけを渡す前提にして、判定ロジックを純関数に閉じ込めます。テストしやすく、後述の frozen test で固定できます。
type LangSlug = { lang: string; slug: string };
const SITE_URL = "https://entriq.ai";
export function buildBlogHreflangAlternates(
publicId: number,
publishedLangs: LangSlug[], // 公開済みの言語だけを渡す
currentLang: string,
) {
const current = publishedLangs.find((l) => l.lang === currentLang);
const canonical = `${SITE_URL}/${currentLang}/blog/${publicId}-${current?.slug ?? ""}`;
// 公開言語が1つだけなら canonical のみ(未公開言語へは相互リンクしない)
if (publishedLangs.length <= 1) return { canonical };
const languages: Record<string, string> = {};
for (const { lang, slug } of publishedLangs) {
languages[lang] = `${SITE_URL}/${lang}/blog/${publicId}-${slug}`;
}
// x-default は en が公開されているときだけ
const en = publishedLangs.find((l) => l.lang === "en");
if (en) languages["x-default"] = `${SITE_URL}/en/blog/${publicId}-${en.slug}`;
return { canonical, languages };
}
2. 公開言語は実行時にDBから取得して generateMetadata で使う
export async function generateMetadata(
{ params }: { params: Promise<{ lang: string; slugId: string }> },
): Promise<Metadata> {
const { lang, slugId } = await params;
const publicId = extractPublicId(slugId);
// 公開されている言語版だけをDBから取得(status=published のみ)
const publishedLangs = await listPublishedLangsForPublicId(publicId);
return { alternates: buildBlogHreflangAlternates(publicId, publishedLangs, lang) };
}
こうしておくと、記事の公開言語を増やした瞬間に head の hreflang が追従します。sitemap 側も同じ「公開言語のみ」ロジックを共有すれば、両者が自動で一致します。
3. App Router で pathname を知る(middlewareでヘッダに載せる)
静的ページ側では「今どのパスか」を generateMetadata で知る必要がありますが、App Router では素直に pathname が取れません。そこで middleware でリクエストヘッダに載せて渡します。
// middleware.ts
export function middleware(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-entriq-pathname", request.nextUrl.pathname);
return NextResponse.next({ request: { headers: requestHeaders } });
}
// layout / generateMetadata 側
import { headers } from "next/headers";
const pathname = (await headers()).get("x-entriq-pathname") ?? "/";
注意点として、headers() を読むとそのルートは動的レンダリング(SSR)になります。静的最適化を効かせたいページでは影響を確認してから入れてください(自分の場合 /[lang] はもともと動的だったので実害ゼロでした)。
4. frozen test で「壊してはいけない出力形式」を固定する
hreflangの出力形式(公開言語のみ・x-defaultの条件・1言語時はcanonicalのみ)と対応言語の並び順は、うっかり壊すとSEOに直撃します。純関数に対して「新しい言語を足すのはOK、既存の形式・順序は変えるな」というテストを凍結しておくと安心です。
test("未公開言語は alternates に出さない / x-default は en 公開時のみ", () => {
const r = buildBlogHreflangAlternates(52, [
{ lang: "ja", slug: "entriq-web-release" },
{ lang: "en", slug: "entriq-web-release" },
], "ja");
expect(Object.keys(r.languages ?? {})).toEqual(["ja", "en", "x-default"]);
});
まとめ
肝は「翻訳が全部揃うのを待たず、揃っている分だけ正しく出す」こと。部分公開でも破綻しない設計にしておくと、1言語ずつ増やしていく個人開発と相性が良いです。
(この設計は14言語のトレード練習ツール ENTRIQ を作る中で固めたものです:https://entriq.ai )