Documentation Next.js

はじめに

企業サイトには高いパフォーマンスと優れた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>&copy; 2024 株式会社Example. All rights reserved.</p>
    </footer>
  );
}

まとめ

企業サイト構築のポイントをまとめます。

カテゴリ実装内容
SEOMetadata API、構造化データ、サイトマップ
パフォーマンスISR、画像最適化、Dynamic Import
アクセシビリティセマンティックHTML、ARIAラベル
多言語対応ミドルウェアによるルーティング、hreflang

参考文献

円