0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js App Router で、翻訳が揃う前から多言語 hreflang を破綻させない設計

0
Posted at

Next.js App Router で、翻訳が揃う前から多言語 hreflang を破綻させない設計

はじめに

14言語で展開しているトレード練習ツール「ENTRIQ」を個人開発しています。多言語サイトで地味に厄介なのが hreflang で、特に「記事を1言語ずつ翻訳して公開していく」運用だと、素朴に実装すると簡単に破綻します。この記事は、その失敗と、Next.js(App Router)でどう設計し直したかの共有です。

素朴な実装はなぜ壊れるか

最初は、全言語版のURLを相互に hreflang で出していました。しかし翻訳は1言語ずつ公開されるので、まだ存在しない言語版へのリンクを出すことになります。結果、クローラやユーザーを未公開ページ(404)へ誘導してしまい、SEO・UXの両方で損をします。

hreflang は「用意した言語」ではなく「実際に公開されている言語」だけを出す——これが出発点でした。

設計方針

公開状態は記事ごとに変わるので、hreflangDBの公開状態から動的に組み立てることにしました。ルールはこの4つです。

  • alternates に出すのは status=published の言語版だけ
  • x-defaulten が公開されているときだけ出す(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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?