Next.js 15 Streaming SSRとDynamic Segmentsを組み合わせた複雑なルーティング実装【多言語・SEO対応】
Next.js 15のStreaming SSRとDynamic Segmentsを組み合わせた多言語対応ルーティングの実装方法。言語別サイトマップ自動生成とSEO最適化の完全ガイド
Next.js 15のApp Routerでは、Streaming SSRとDynamic Segmentsを組み合わせることで、複雑な多言語ルーティングを効率的に実装できます。本記事では、言語パラメータと動的コンテンツを組み合わせた実装パターンと、SEO対策として重要な言語別サイトマップの自動生成手法を解説します。
Streaming SSRとDynamic Segmentsの組み合わせ基礎
Next.js 15のApp Routerでは、app/[locale]/[...slug]/page.tsxのように複数のDynamic Segmentsを組み合わせることで、多言語かつ動的なルーティングを実現できます。Streaming SSRを併用することで、初回表示速度を維持しながら動的コンテンツを配信可能です。
基本的なルーティング構造
以下は、言語パラメータとカテゴリ・スラッグを組み合わせたルーティング例です。
// app/[locale]/blog/[category]/[slug]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { getArticle, getRelatedArticles } from '@/lib/blog';
import ArticleContent from '@/components/ArticleContent';
import RelatedArticles from '@/components/RelatedArticles';
import ArticleSkeleton from '@/components/ArticleSkeleton';
type Params = {
locale: string;
category: string;
slug: string;
};
export async function generateStaticParams() {
const locales = ['ja', 'en', 'zh', 'ko'];
const categories = ['web-development', 'ai', 'seo', 'tools'];
const params: Params[] = [];
for (const locale of locales) {
for (const category of categories) {
const articles = await getArticlesByCategory(locale, category);
params.push(
...articles.map(article => ({
locale,
category,
slug: article.slug,
}))
);
}
}
return params;
}
export async function generateMetadata({ params }: { params: Params }) {
const { locale, category, slug } = params;
const article = await getArticle(locale, category, slug);
if (!article) return {};
return {
title: article.title,
description: article.description,
alternates: {
canonical: `https://example.com/${locale}/blog/${category}/${slug}`,
languages: {
'ja': `https://example.com/ja/blog/${category}/${slug}`,
'en': `https://example.com/en/blog/${category}/${slug}`,
'zh': `https://example.com/zh/blog/${category}/${slug}`,
'ko': `https://example.com/ko/blog/${category}/${slug}`,
},
},
};
}
export default async function ArticlePage({ params }: { params: Params }) {
const { locale, category, slug } = params;
const article = await getArticle(locale, category, slug);
if (!article) {
notFound();
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<article>
<h1 className="text-4xl font-bold mb-4">{article.title}</h1>
<ArticleContent content={article.content} />
</article>
<Suspense fallback={<ArticleSkeleton />}>
<RelatedArticles locale={locale} category={category} currentSlug={slug} />
</Suspense>
</div>
);
}
この実装では、generateStaticParamsで全言語・全カテゴリの組み合わせを事前生成し、generateMetadataでhreflang属性を自動設定しています。
Streaming SSRによる段階的レンダリング
関連記事の取得には時間がかかる可能性があるため、Suspenseで非同期コンポーネントとして分離します。
// components/RelatedArticles.tsx
import { getRelatedArticles } from '@/lib/blog';
import ArticleCard from './ArticleCard';
type Props = {
locale: string;
category: string;
currentSlug: string;
};
export default async function RelatedArticles({ locale, category, currentSlug }: Props) {
// この非同期処理はStreaming SSRで段階的に配信される
const relatedArticles = await getRelatedArticles(locale, category, currentSlug);
return (
<section className="mt-12">
<h2 className="text-2xl font-bold mb-6">関連記事</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{relatedArticles.map(article => (
<ArticleCard key={article.slug} article={article} locale={locale} />
))}
</div>
</section>
);
}
// components/ArticleSkeleton.tsx
export default function ArticleSkeleton() {
return (
<section className="mt-12 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[1, 2, 3].map(i => (
<div key={i} className="h-64 bg-gray-200 rounded"></div>
))}
</div>
</section>
);
}
多言語対応のためのMiddleware実装
言語検出とリダイレクトをMiddlewareで処理することで、ユーザー体験を向上させます。
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
const locales = ['ja', 'en', 'zh', 'ko'];
const defaultLocale = 'ja';
function getLocale(request: NextRequest): string {
const negotiatorHeaders: Record<string, string> = {};
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
try {
return match(languages, locales, defaultLocale);
} catch {
return defaultLocale;
}
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 既に言語プレフィックスがある場合はスキップ
const pathnameHasLocale = locales.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) {
return NextResponse.next();
}
// Accept-Languageヘッダーから言語を検出
const locale = getLocale(request);
// 言語プレフィックスを付けてリダイレクト
request.nextUrl.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}
export const config = {
matcher: [
// 静的ファイルとAPIルートを除外
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
この実装により、/blog/web-development/article-slugへのアクセスが自動的に/ja/blog/web-development/article-slugにリダイレクトされます。
複雑なルーティングパターンの実装
以下の図は、多言語対応のルーティングフローを示しています。
flowchart TD
A["ユーザーアクセス<br/>/blog/category/slug"] --> B{"Middleware<br/>言語検出"}
B --> C["Accept-Language<br/>ヘッダー解析"]
C --> D["言語プレフィックス付与<br/>/ja/blog/category/slug"]
D --> E["Dynamic Segments<br/>マッチング"]
E --> F["generateStaticParams<br/>事前生成確認"]
F -->|Hit| G["Static Page<br/>即座に配信"]
F -->|Miss| H["Dynamic Rendering<br/>オンデマンド生成"]
G --> I["メインコンテンツ<br/>即座に表示"]
H --> I
I --> J["Suspense Boundary<br/>関連記事取得開始"]
J --> K["Skeleton UI表示"]
K --> L["Streaming SSR<br/>段階的配信"]
L --> M["関連記事表示完了"]
この図は、ユーザーアクセスから最終的なコンテンツ配信までのフローを示しています。Middlewareによる言語検出、Dynamic Segmentsのマッチング、Streaming SSRによる段階的配信の流れが可視化されています。
Catch-all SegmentsとOptional Catch-all Segmentsの使い分け
より柔軟なルーティングには、Catch-all Segments ([...slug]) またはOptional Catch-all Segments ([[...slug]]) を使用します。
// app/[locale]/docs/[[...slug]]/page.tsx
import { notFound } from 'next/navigation';
import { getDocPage } from '@/lib/docs';
type Params = {
locale: string;
slug?: string[];
};
export default async function DocsPage({ params }: { params: Params }) {
const { locale, slug = [] } = params;
// slugが空の場合はトップページ
const docPath = slug.length === 0 ? 'index' : slug.join('/');
const doc = await getDocPage(locale, docPath);
if (!doc) {
notFound();
}
return (
<div className="prose max-w-4xl mx-auto">
<h1>{doc.title}</h1>
<div dangerouslySetInnerHTML={{ __html: doc.html }} />
</div>
);
}
この実装により、以下のようなURLがすべて同じコンポーネントで処理されます。
/ja/docs(slug = [])/ja/docs/introduction(slug = [‘introduction’])/ja/docs/api/authentication(slug = [‘api’, ‘authentication’])
言語別サイトマップの自動生成
SEO対策として、各言語ごとにサイトマップを自動生成します。
// app/sitemap.ts
import { MetadataRoute } from 'next';
import { getAllArticles, getAllDocs } from '@/lib/content';
const baseUrl = 'https://example.com';
const locales = ['ja', 'en', 'zh', 'ko'];
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const entries: MetadataRoute.Sitemap = [];
// 静的ページ
const staticPages = ['', '/about', '/contact'];
for (const locale of locales) {
for (const page of staticPages) {
entries.push({
url: `${baseUrl}/${locale}${page}`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: page === '' ? 1.0 : 0.8,
alternates: {
languages: Object.fromEntries(
locales.map(l => [l, `${baseUrl}/${l}${page}`])
),
},
});
}
}
// ブログ記事
for (const locale of locales) {
const articles = await getAllArticles(locale);
for (const article of articles) {
entries.push({
url: `${baseUrl}/${locale}/blog/${article.category}/${article.slug}`,
lastModified: new Date(article.updatedAt),
changeFrequency: 'weekly',
priority: 0.7,
alternates: {
languages: Object.fromEntries(
locales.map(l => [
l,
`${baseUrl}/${l}/blog/${article.category}/${article.slug}`,
])
),
},
});
}
}
// ドキュメントページ
for (const locale of locales) {
const docs = await getAllDocs(locale);
for (const doc of docs) {
entries.push({
url: `${baseUrl}/${locale}/docs/${doc.path}`,
lastModified: new Date(doc.updatedAt),
changeFrequency: 'weekly',
priority: 0.6,
alternates: {
languages: Object.fromEntries(
locales.map(l => [l, `${baseUrl}/${l}/docs/${doc.path}`])
),
},
});
}
}
return entries;
}
この実装により、/sitemap.xmlにアクセスすると全言語のURLが含まれたサイトマップが自動生成されます。各URLには<xhtml:link rel="alternate" hreflang="ja" href="..."/>のような形式でhreflang属性が含まれます。
言語別サイトマップの分割
規模が大きいサイトでは、言語ごとにサイトマップを分割することが推奨されます。
// app/[locale]/sitemap.ts
import { MetadataRoute } from 'next';
import { getAllArticles, getAllDocs } from '@/lib/content';
const baseUrl = 'https://example.com';
export default async function sitemap({ params }: { params: { locale: string } }): Promise<MetadataRoute.Sitemap> {
const { locale } = params;
const entries: MetadataRoute.Sitemap = [];
// 静的ページ
const staticPages = ['', '/about', '/contact'];
for (const page of staticPages) {
entries.push({
url: `${baseUrl}/${locale}${page}`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: page === '' ? 1.0 : 0.8,
});
}
// ブログ記事(該当言語のみ)
const articles = await getAllArticles(locale);
for (const article of articles) {
entries.push({
url: `${baseUrl}/${locale}/blog/${article.category}/${article.slug}`,
lastModified: new Date(article.updatedAt),
changeFrequency: 'weekly',
priority: 0.7,
});
}
return entries;
}
この場合、/ja/sitemap.xml, /en/sitemap.xmlのように言語ごとにサイトマップが生成されます。
パフォーマンス最適化とキャッシング戦略
Dynamic SegmentsとStreaming SSRを組み合わせた場合、適切なキャッシング戦略が重要です。
// app/[locale]/blog/[category]/[slug]/page.tsx
export const revalidate = 3600; // 1時間ごとに再検証
export default async function ArticlePage({ params }: { params: Params }) {
const { locale, category, slug } = params;
// fetch APIにキャッシュ戦略を指定
const article = await fetch(
`${process.env.CMS_API_URL}/articles/${locale}/${category}/${slug}`,
{
next: {
revalidate: 3600,
tags: [`article-${slug}`, `category-${category}`, `locale-${locale}`],
},
}
).then(res => res.json());
// ... 以下同様
}
On-Demand Revalidationを実装することで、コンテンツ更新時に即座にキャッシュを無効化できます。
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret');
// セキュリティトークン検証
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
const body = await request.json();
const { slug, category, locale } = body;
// タグベースでキャッシュ無効化
revalidateTag(`article-${slug}`);
revalidateTag(`category-${category}`);
revalidateTag(`locale-${locale}`);
return NextResponse.json({ revalidated: true, now: Date.now() });
}
以下の図は、キャッシング戦略の全体像を示しています。
flowchart LR
A["CMS<br/>コンテンツ更新"] --> B["Webhook発火"]
B --> C["POST /api/revalidate"]
C --> D{"シークレット検証"}
D -->|無効| E["401エラー"]
D -->|有効| F["revalidateTag実行"]
F --> G["article-{slug}"]
F --> H["category-{category}"]
F --> I["locale-{locale}"]
G --> J["Next.js<br/>Data Cache無効化"]
H --> J
I --> J
J --> K["次回リクエスト時<br/>再生成"]
K --> L["新しいコンテンツ配信"]
この図は、CMSでのコンテンツ更新からNext.jsのキャッシュ無効化、再生成までのフローを示しています。WebhookとrevalidateTagを組み合わせることで、コンテンツ更新が即座に反映されます。
まとめ
Next.js 15のStreaming SSRとDynamic Segmentsを組み合わせることで、以下のような複雑な多言語ルーティングを効率的に実装できます。
- Middlewareによる自動言語検出: Accept-Languageヘッダーから最適な言語を自動選択
- Dynamic Segmentsの柔軟な組み合わせ:
[locale]/[category]/[slug]のような多層構造に対応 - Streaming SSRによる段階的配信: メインコンテンツを即座に表示し、関連コンテンツを非同期で読み込み
- 言語別サイトマップの自動生成: hreflang属性を含むSEO対応サイトマップを自動作成
- タグベースのOn-Demand Revalidation: コンテンツ更新時に該当ページのキャッシュを即座に無効化
この実装パターンにより、多言語対応の大規模サイトでも高速な初回表示とSEO最適化を両立できます。