はじめに
企業サイトには高いパフォーマンスと優れたSEOの両方が求められます。この記事では、Next.js App Routerを使用して、パフォーマンスとSEOを両立させた企業サイトを構築する方法を解説します。
プロジェクト構成
推奨ディレクトリ構造
src/
├── app/
│ ├── (marketing)/ # マーケティングページ
│ │ ├── page.tsx # トップページ
│ │ ├── about/
│ │ ├── services/
│ │ └── contact/
│ ├── (blog)/ # ブログセクション
│ │ └── blog/
│ │ ├── page.tsx
│ │ └── [slug]/
│ ├── news/ # ニュースセクション
│ │ ├── page.tsx
│ │ └── [id]/
│ ├── sitemap.ts # サイトマップ
│ ├── robots.ts # robots.txt
│ └── layout.tsx
├── components/
│ ├── seo/
│ │ ├── JsonLd.tsx
│ │ └── BreadcrumbJsonLd.tsx
│ └── ui/
└── lib/
├── seo.ts
└── cms.ts
SEOメタデータの実装
基本レイアウトのメタデータ
// app/layout.tsx
import type { Metadata, Viewport } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: {
default: '株式会社Example | デジタルソリューション',
template: '%s | 株式会社Example',
},
description:
'株式会社Exampleは、企業のDX推進を支援するデジタルソリューションを提供しています。',
keywords: ['DX', 'デジタルトランスフォーメーション', 'ITコンサルティング'],
authors: [{ name: '株式会社Example' }],
creator: '株式会社Example',
publisher: '株式会社Example',
formatDetection: {
email: false,
address: false,
telephone: false,
},
openGraph: {
type: 'website',
locale: 'ja_JP',
url: 'https://example.com',
siteName: '株式会社Example',
title: '株式会社Example | デジタルソリューション',
description:
'株式会社Exampleは、企業のDX推進を支援するデジタルソリューションを提供しています。',
images: [
{
url: '/og-image.jpg',
width: 1200,
height: 630,
alt: '株式会社Example',
},
],
},
twitter: {
card: 'summary_large_image',
site: '@example_corp',
creator: '@example_corp',
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
alternates: {
canonical: 'https://example.com',
languages: {
'ja-JP': 'https://example.com',
'en-US': 'https://example.com/en',
},
},
verification: {
google: 'google-site-verification-code',
},
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 5,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#000000' },
],
};
動的メタデータの生成
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getBlogPost, getAllBlogSlugs } from '@/lib/cms';
interface Props {
params: { slug: string };
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getBlogPost(params.slug);
if (!post) {
return {
title: '記事が見つかりません',
};
}
return {
title: post.title,
description: post.excerpt,
authors: [{ name: post.author.name }],
openGraph: {
type: 'article',
title: post.title,
description: post.excerpt,
url: `https://example.com/blog/${params.slug}`,
images: [
{
url: post.featuredImage,
width: 1200,
height: 630,
alt: post.title,
},
],
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
tags: post.tags,
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.featuredImage],
},
alternates: {
canonical: `https://example.com/blog/${params.slug}`,
},
};
}
export async function generateStaticParams() {
const slugs = await getAllBlogSlugs();
return slugs.map((slug) => ({ slug }));
}
export default async function BlogPostPage({ params }: Props) {
const post = await getBlogPost(params.slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
構造化データ(JSON-LD)
組織情報
// components/seo/OrganizationJsonLd.tsx
export function OrganizationJsonLd() {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: '株式会社Example',
url: 'https://example.com',
logo: 'https://example.com/logo.png',
sameAs: [
'https://twitter.com/example_corp',
'https://www.facebook.com/example.corp',
'https://www.linkedin.com/company/example-corp',
],
contactPoint: {
'@type': 'ContactPoint',
telephone: '+81-3-1234-5678',
contactType: 'customer service',
areaServed: 'JP',
availableLanguage: ['Japanese', 'English'],
},
address: {
'@type': 'PostalAddress',
streetAddress: '〇〇区△△ 1-2-3',
addressLocality: '東京都',
addressCountry: 'JP',
postalCode: '100-0001',
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
記事の構造化データ
// components/seo/ArticleJsonLd.tsx
interface ArticleJsonLdProps {
title: string;
description: string;
url: string;
imageUrl: string;
publishedTime: string;
modifiedTime: string;
authorName: string;
}
export function ArticleJsonLd({
title,
description,
url,
imageUrl,
publishedTime,
modifiedTime,
authorName,
}: ArticleJsonLdProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: title,
description: description,
image: imageUrl,
url: url,
datePublished: publishedTime,
dateModified: modifiedTime,
author: {
'@type': 'Person',
name: authorName,
},
publisher: {
'@type': 'Organization',
name: '株式会社Example',
logo: {
'@type': 'ImageObject',
url: 'https://example.com/logo.png',
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url,
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
パンくずリスト
// components/seo/BreadcrumbJsonLd.tsx
interface BreadcrumbItem {
name: string;
url: string;
}
interface BreadcrumbJsonLdProps {
items: BreadcrumbItem[];
}
export function BreadcrumbJsonLd({ items }: BreadcrumbJsonLdProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
// 使用例
// app/blog/[slug]/page.tsx
<BreadcrumbJsonLd
items={[
{ name: 'ホーム', url: 'https://example.com' },
{ name: 'ブログ', url: 'https://example.com/blog' },
{ name: post.title, url: `https://example.com/blog/${params.slug}` },
]}
/>
FAQ構造化データ
// components/seo/FAQJsonLd.tsx
interface FAQItem {
question: string;
answer: string;
}
interface FAQJsonLdProps {
items: FAQItem[];
}
export function FAQJsonLd({ items }: FAQJsonLdProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
サイトマップとrobots.txt
動的サイトマップ
// app/sitemap.ts
import { MetadataRoute } from 'next';
import { getAllBlogSlugs, getAllNewsSlugs } from '@/lib/cms';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://example.com';
// 静的ページ
const staticPages = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 1,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
},
{
url: `${baseUrl}/services`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
},
{
url: `${baseUrl}/contact`,
lastModified: new Date(),
changeFrequency: 'yearly' as const,
priority: 0.5,
},
];
// ブログ記事
const blogSlugs = await getAllBlogSlugs();
const blogPages = blogSlugs.map((slug) => ({
url: `${baseUrl}/blog/${slug}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.6,
}));
// ニュース
const newsSlugs = await getAllNewsSlugs();
const newsPages = newsSlugs.map((slug) => ({
url: `${baseUrl}/news/${slug}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.6,
}));
return [...staticPages, ...blogPages, ...newsPages];
}
robots.txt
// app/robots.ts
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const baseUrl = 'https://example.com';
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/_next/', '/private/'],
},
{
userAgent: 'Googlebot',
allow: '/',
disallow: ['/api/', '/admin/'],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
host: baseUrl,
};
}
パフォーマンス最適化
ISRによる静的生成
// app/news/page.tsx
import { getNewsList } from '@/lib/cms';
// ISR: 1時間ごとに再生成
export const revalidate = 3600;
export default async function NewsPage() {
const news = await getNewsList();
return (
<div>
<h1>ニュース</h1>
<ul>
{news.map((item) => (
<li key={item.id}>
<a href={`/news/${item.id}`}>{item.title}</a>
<time>{item.publishedAt}</time>
</li>
))}
</ul>
</div>
);
}
オンデマンド再検証
// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function POST(request: NextRequest) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
// シークレットトークンの検証
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid token' }, { status: 401 });
}
const body = await request.json();
const { type, slug } = body;
try {
switch (type) {
case 'blog':
revalidatePath(`/blog/${slug}`);
revalidatePath('/blog');
revalidateTag('blog');
break;
case 'news':
revalidatePath(`/news/${slug}`);
revalidatePath('/news');
revalidateTag('news');
break;
default:
revalidatePath('/');
}
return NextResponse.json({ revalidated: true, now: Date.now() });
} catch (error) {
return NextResponse.json(
{ message: 'Error revalidating' },
{ status: 500 }
);
}
}
画像の最適化
// components/OptimizedImage.tsx
import Image from 'next/image';
interface OptimizedImageProps {
src: string;
alt: string;
width?: number;
height?: number;
priority?: boolean;
className?: string;
}
export function OptimizedImage({
src,
alt,
width,
height,
priority = false,
className,
}: OptimizedImageProps) {
// 外部URLかどうかを判定
const isExternal = src.startsWith('http');
return (
<Image
src={src}
alt={alt}
width={width || 800}
height={height || 450}
priority={priority}
className={className}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 75vw, 50vw"
quality={85}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAIAAoDASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAUG/8QAIBAAAgEDBAMAAAAAAAAAAAAAAQIDAAQRBRIhMQZBUf/EABUBAQEAAAAAAAAAAAAAAAAAAAAB/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8Am1/S7WC/t0s7m4lMcrJIzx7csAcDk+xRRQf/2Q=="
{...(isExternal && {
unoptimized: false,
})}
/>
);
}
コンポーネントの遅延読み込み
// app/(marketing)/page.tsx
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
import { HeroSection } from '@/components/HeroSection';
import { ServicesSkeleton, TestimonialsSkeleton } from '@/components/Skeletons';
// ファーストビュー以下のコンポーネントを遅延読み込み
const ServicesSection = dynamic(() => import('@/components/ServicesSection'), {
loading: () => <ServicesSkeleton />,
});
const TestimonialsSection = dynamic(
() => import('@/components/TestimonialsSection'),
{
loading: () => <TestimonialsSkeleton />,
}
);
const ContactCTA = dynamic(() => import('@/components/ContactCTA'));
export default function HomePage() {
return (
<>
{/* ファーストビュー - 即座にレンダリング */}
<HeroSection />
{/* スクロール後に表示される要素 */}
<Suspense fallback={<ServicesSkeleton />}>
<ServicesSection />
</Suspense>
<Suspense fallback={<TestimonialsSkeleton />}>
<TestimonialsSection />
</Suspense>
<ContactCTA />
</>
);
}
多言語対応(i18n)
言語別ルーティング
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
const locales = ['ja', 'en'];
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();
return match(languages, locales, defaultLocale);
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// ロケールがパスに含まれているか確認
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return;
// ロケールを検出してリダイレクト
const locale = getLocale(request);
request.nextUrl.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
言語別メタデータ
// app/[lang]/layout.tsx
import type { Metadata } from 'next';
interface Props {
params: { lang: string };
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { lang } = params;
const titles = {
ja: '株式会社Example | デジタルソリューション',
en: 'Example Inc. | Digital Solutions',
};
const descriptions = {
ja: '株式会社Exampleは、企業のDX推進を支援するデジタルソリューションを提供しています。',
en: 'Example Inc. provides digital solutions to support corporate DX initiatives.',
};
return {
title: titles[lang as keyof typeof titles] || titles.ja,
description: descriptions[lang as keyof typeof descriptions] || descriptions.ja,
alternates: {
canonical: `https://example.com/${lang}`,
languages: {
'ja-JP': 'https://example.com/ja',
'en-US': 'https://example.com/en',
},
},
};
}
export default function LangLayout({
children,
params,
}: {
children: React.ReactNode;
params: { lang: string };
}) {
return (
<html lang={params.lang}>
<body>{children}</body>
</html>
);
}
アクセシビリティ
セマンティックHTML
// components/Header.tsx
export function Header() {
return (
<header role="banner">
<nav aria-label="メインナビゲーション">
<ul>
<li><a href="/">ホーム</a></li>
<li><a href="/about">会社概要</a></li>
<li><a href="/services">サービス</a></li>
<li><a href="/contact">お問い合わせ</a></li>
</ul>
</nav>
<a href="#main-content" className="skip-link">
本文へスキップ
</a>
</header>
);
}
// components/Main.tsx
export function Main({ children }: { children: React.ReactNode }) {
return (
<main id="main-content" role="main" tabIndex={-1}>
{children}
</main>
);
}
// components/Footer.tsx
export function Footer() {
return (
<footer role="contentinfo">
<p>© 2024 株式会社Example. All rights reserved.</p>
</footer>
);
}
まとめ
企業サイト構築のポイントをまとめます。
| カテゴリ | 実装内容 |
|---|---|
| SEO | Metadata API、構造化データ、サイトマップ |
| パフォーマンス | ISR、画像最適化、Dynamic Import |
| アクセシビリティ | セマンティックHTML、ARIAラベル |
| 多言語対応 | ミドルウェアによるルーティング、hreflang |