Documentation Next.js

はじめに

現代のWebアプリケーションはJavaScriptに大きく依存していますが、すべてのユーザーが常にJavaScriptを利用できるわけではありません。ネットワークの遅延、古いブラウザ、スクリーンリーダーの使用、あるいは企業のセキュリティポリシーによるJavaScript無効化など、様々な理由でJavaScriptが動作しない状況があります。

この記事では、プログレッシブエンハンスメント(Progressive Enhancement)の考え方をNext.jsで実践する方法を解説します。Server Actionsを活用したフォーム実装、セマンティックHTMLの重要性、そしてJavaScriptが有効な場合のリッチな機能拡張まで、段階的に学んでいきましょう。

プログレッシブエンハンスメントとは

プログレッシブエンハンスメントは、Web開発における設計哲学の一つで、基本的な機能をすべてのユーザーに提供しつつ、高機能な環境ではより豊かな体験を追加するアプローチです。

3つの層で考える

プログレッシブエンハンスメントは、以下の3つの層で構成されます。

技術役割
第1層(コンテンツ)HTML意味のある構造とコンテンツを提供
第2層(プレゼンテーション)CSS視覚的なデザインとレイアウトを追加
第3層(振る舞い)JavaScriptインタラクティブな機能を追加
<!-- 第1層: セマンティックなHTML構造 -->
<form action="/api/contact" method="POST">
  <label for="email">メールアドレス</label>
  <input type="email" id="email" name="email" required />

  <label for="message">メッセージ</label>
  <textarea id="message" name="message" required></textarea>

  <button type="submit">送信</button>
</form>

この基本的なHTMLフォームは、JavaScriptがなくても動作します。CSSで見た目を整え、JavaScriptで入力補助やバリデーションを追加するのがプログレッシブエンハンスメントの考え方です。

なぜ今でも重要なのか

モダンなフレームワークが主流の現在でも、プログレッシブエンハンスメントが重要な理由があります。

  1. アクセシビリティ: スクリーンリーダーなどの支援技術との互換性が向上
  2. パフォーマンス: JavaScript読み込み前でもコンテンツが表示される
  3. 信頼性: ネットワークエラーやスクリプトエラーでも基本機能が動作
  4. SEO: 検索エンジンがコンテンツを正しく認識できる

Server Actionsを使ったフォーム実装

Next.js 14以降では、Server Actionsを使用することで、JavaScriptが無効でも動作するフォームを簡単に実装できます。これはプログレッシブエンハンスメントを実現する強力な機能です。

基本的なServer Actionの定義

まず、サーバーサイドで実行されるアクションを定義します。

// app/actions/contact.ts
'use server'

import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

// フォームデータの型定義
interface ContactFormData {
  email: string
  message: string
}

// Server Action: フォーム送信を処理
export async function submitContactForm(formData: FormData) {
  // FormDataから値を取得
  const email = formData.get('email') as string
  const message = formData.get('message') as string

  // バリデーション
  if (!email || !message) {
    // エラー時はリダイレクト(JavaScriptなしでも動作)
    redirect('/contact?error=required')
  }

  // メール形式のバリデーション
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  if (!emailRegex.test(email)) {
    redirect('/contact?error=invalid-email')
  }

  try {
    // データベースへの保存やメール送信などの処理
    await saveToDatabase({ email, message })

    // 成功時はキャッシュを更新してリダイレクト
    revalidatePath('/contact')
    redirect('/contact?success=true')
  } catch (error) {
    // エラーハンドリング
    console.error('Form submission error:', error)
    redirect('/contact?error=server')
  }
}

// ダミーのデータベース保存関数
async function saveToDatabase(data: ContactFormData) {
  // 実際のアプリケーションでは、ここでDBに保存
  console.log('Saving to database:', data)
  await new Promise(resolve => setTimeout(resolve, 1000))
}

フォームコンポーネントの実装

Server Actionを使用するフォームコンポーネントを作成します。

// app/contact/page.tsx
import { submitContactForm } from '@/app/actions/contact'

// URLパラメータの型定義
interface ContactPageProps {
  searchParams: {
    error?: string
    success?: string
  }
}

export default function ContactPage({ searchParams }: ContactPageProps) {
  const { error, success } = searchParams

  return (
    <main className="max-w-2xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">お問い合わせ</h1>

      {/* エラーメッセージ(JavaScriptなしでも表示) */}
      {error && (
        <div
          className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
          role="alert"
        >
          <p>{getErrorMessage(error)}</p>
        </div>
      )}

      {/* 成功メッセージ */}
      {success && (
        <div
          className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4"
          role="status"
        >
          <p>お問い合わせを受け付けました。ありがとうございます。</p>
        </div>
      )}

      {/* フォーム: action属性でServer Actionを指定 */}
      <form action={submitContactForm} className="space-y-4">
        <div>
          <label
            htmlFor="email"
            className="block text-sm font-medium mb-1"
          >
            メールアドレス
          </label>
          <input
            type="email"
            id="email"
            name="email"
            required
            className="w-full border rounded px-3 py-2"
            placeholder="example@example.com"
          />
        </div>

        <div>
          <label
            htmlFor="message"
            className="block text-sm font-medium mb-1"
          >
            メッセージ
          </label>
          <textarea
            id="message"
            name="message"
            required
            rows={5}
            className="w-full border rounded px-3 py-2"
            placeholder="お問い合わせ内容をご記入ください"
          />
        </div>

        <button
          type="submit"
          className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 transition-colors"
        >
          送信
        </button>
      </form>
    </main>
  )
}

// エラーコードをメッセージに変換
function getErrorMessage(code: string): string {
  const messages: Record<string, string> = {
    required: '必須項目を入力してください。',
    'invalid-email': '正しいメールアドレスを入力してください。',
    server: 'サーバーエラーが発生しました。しばらく経ってから再度お試しください。',
  }
  return messages[code] || '入力内容をご確認ください。'
}

このフォームは、JavaScriptが無効でも以下のように動作します。

  1. ユーザーがフォームに入力して送信ボタンをクリック
  2. ブラウザが通常のHTMLフォームとしてPOSTリクエストを送信
  3. Server Actionがサーバーサイドで実行される
  4. 処理結果に応じてリダイレクトが行われ、メッセージが表示される

JavaScriptによる機能拡張

プログレッシブエンハンスメントの第3層として、JavaScriptが有効な環境ではより豊かなユーザー体験を提供します。

クライアントサイドのバリデーション追加

// components/EnhancedContactForm.tsx
'use client'

import { useFormStatus } from 'react-dom'
import { useActionState } from 'react'
import { submitContactForm } from '@/app/actions/contact'

// 送信ボタンコンポーネント(ローディング状態を表示)
function SubmitButton() {
  // useFormStatusでフォームの送信状態を取得
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      className={`
        px-6 py-2 rounded transition-colors
        ${pending
          ? 'bg-gray-400 cursor-not-allowed'
          : 'bg-blue-600 hover:bg-blue-700'}
        text-white
      `}
    >
      {pending ? (
        <span className="flex items-center gap-2">
          <LoadingSpinner />
          送信中...
        </span>
      ) : (
        '送信'
      )}
    </button>
  )
}

// ローディングスピナーコンポーネント
function LoadingSpinner() {
  return (
    <svg
      className="animate-spin h-4 w-4"
      viewBox="0 0 24 24"
      aria-hidden="true"
    >
      <circle
        className="opacity-25"
        cx="12"
        cy="12"
        r="10"
        stroke="currentColor"
        strokeWidth="4"
        fill="none"
      />
      <path
        className="opacity-75"
        fill="currentColor"
        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
      />
    </svg>
  )
}

export function EnhancedContactForm() {
  // クライアントサイドのバリデーション状態
  const [errors, setErrors] = useState<Record<string, string>>({})

  // リアルタイムバリデーション
  const validateEmail = (email: string) => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    if (!email) {
      return 'メールアドレスを入力してください'
    }
    if (!emailRegex.test(email)) {
      return '正しいメールアドレス形式で入力してください'
    }
    return ''
  }

  const handleEmailBlur = (e: React.FocusEvent<HTMLInputElement>) => {
    const error = validateEmail(e.target.value)
    setErrors(prev => ({ ...prev, email: error }))
  }

  return (
    <form action={submitContactForm} className="space-y-4">
      <div>
        <label htmlFor="email" className="block text-sm font-medium mb-1">
          メールアドレス
        </label>
        <input
          type="email"
          id="email"
          name="email"
          required
          onBlur={handleEmailBlur}
          aria-describedby={errors.email ? 'email-error' : undefined}
          aria-invalid={errors.email ? 'true' : 'false'}
          className={`
            w-full border rounded px-3 py-2
            ${errors.email ? 'border-red-500' : 'border-gray-300'}
          `}
        />
        {/* JavaScriptが有効な場合のみ表示されるリアルタイムエラー */}
        {errors.email && (
          <p id="email-error" className="text-red-500 text-sm mt-1">
            {errors.email}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium mb-1">
          メッセージ
        </label>
        <textarea
          id="message"
          name="message"
          required
          rows={5}
          className="w-full border rounded px-3 py-2"
        />
      </div>

      <SubmitButton />
    </form>
  )
}

import { useState } from 'react'

条件付きでクライアントコンポーネントを使用

サーバーコンポーネントとクライアントコンポーネントを組み合わせることで、JavaScriptの有無に関わらず動作する設計が可能です。

// app/contact/page.tsx
import { Suspense } from 'react'
import { submitContactForm } from '@/app/actions/contact'

// 基本的なサーバーサイドフォーム(フォールバック)
function BasicForm() {
  return (
    <form action={submitContactForm} className="space-y-4">
      <div>
        <label htmlFor="email">メールアドレス</label>
        <input type="email" id="email" name="email" required />
      </div>
      <div>
        <label htmlFor="message">メッセージ</label>
        <textarea id="message" name="message" required rows={5} />
      </div>
      <button type="submit">送信</button>
    </form>
  )
}

// 拡張フォームを遅延読み込み
const EnhancedForm = dynamic(
  () => import('@/components/EnhancedContactForm').then(mod => mod.EnhancedContactForm),
  {
    // JavaScript読み込み中は基本フォームを表示
    loading: () => <BasicForm />,
    // サーバーサイドでは基本フォームをレンダリング
    ssr: false,
  }
)

import dynamic from 'next/dynamic'

export default function ContactPage() {
  return (
    <main className="max-w-2xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">お問い合わせ</h1>
      <EnhancedForm />
    </main>
  )
}

セマンティックHTMLの重要性

プログレッシブエンハンスメントの基盤となるのは、正しく構造化されたHTMLです。セマンティックHTMLを使用することで、アクセシビリティとSEOが向上します。

適切な要素の選択

// Good: セマンティックなHTML構造
export function ArticlePage({ article }: { article: Article }) {
  return (
    <article>
      <header>
        <h1>{article.title}</h1>
        <time dateTime={article.publishedAt}>
          {formatDate(article.publishedAt)}
        </time>
        <address rel="author">{article.author.name}</address>
      </header>

      <main>
        <section aria-labelledby="introduction">
          <h2 id="introduction">はじめに</h2>
          <p>{article.introduction}</p>
        </section>

        <section aria-labelledby="main-content">
          <h2 id="main-content">本文</h2>
          {article.content}
        </section>
      </main>

      <footer>
        <nav aria-label="関連記事">
          <h3>関連記事</h3>
          <ul>
            {article.relatedArticles.map(related => (
              <li key={related.id}>
                <a href={`/articles/${related.slug}`}>{related.title}</a>
              </li>
            ))}
          </ul>
        </nav>
      </footer>
    </article>
  )
}

フォーム要素のアクセシビリティ

// アクセシブルなフォームフィールドコンポーネント
interface FormFieldProps {
  id: string
  label: string
  type?: string
  required?: boolean
  error?: string
  helpText?: string
}

export function FormField({
  id,
  label,
  type = 'text',
  required = false,
  error,
  helpText,
}: FormFieldProps) {
  // 一意のIDを生成
  const errorId = `${id}-error`
  const helpId = `${id}-help`

  // aria-describedbyの値を構築
  const describedBy = [
    error ? errorId : null,
    helpText ? helpId : null,
  ].filter(Boolean).join(' ') || undefined

  return (
    <div className="form-field">
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only">(必須)</span>}
      </label>

      <input
        type={type}
        id={id}
        name={id}
        required={required}
        aria-required={required}
        aria-invalid={error ? 'true' : 'false'}
        aria-describedby={describedBy}
      />

      {helpText && (
        <p id={helpId} className="help-text">
          {helpText}
        </p>
      )}

      {error && (
        <p id={errorId} className="error-text" role="alert">
          {error}
        </p>
      )}
    </div>
  )
}

回線速度やアクセシビリティへの配慮

プログレッシブエンハンスメントは、多様なユーザー環境に対応するための重要な戦略です。

低速回線への対応

// ローディング状態を考慮したコンポーネント設計
export function ProductList({ products }: { products: Product[] }) {
  return (
    <section aria-labelledby="product-list-heading">
      <h2 id="product-list-heading">商品一覧</h2>

      {/*
        noscriptタグでJavaScript無効時のメッセージを表示
        JavaScriptが有効な場合は表示されない
      */}
      <noscript>
        <p className="notice">
          JavaScriptを有効にすると、より快適にご利用いただけます。
        </p>
      </noscript>

      <ul className="product-grid">
        {products.map(product => (
          <li key={product.id}>
            {/*
              画像は遅延読み込みを設定
              alt属性で画像の説明を提供
            */}
            <img
              src={product.imageUrl}
              alt={product.name}
              loading="lazy"
              decoding="async"
              width={300}
              height={300}
            />
            <h3>{product.name}</h3>
            <p>{product.price.toLocaleString()}円</p>

            {/* 基本的なリンクとして動作 */}
            <a href={`/products/${product.id}`}>
              詳細を見る
            </a>
          </li>
        ))}
      </ul>
    </section>
  )
}

プリファレンスの尊重

// ユーザーの設定を尊重するコンポーネント
'use client'

import { useEffect, useState } from 'react'

export function AnimatedSection({ children }: { children: React.ReactNode }) {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(true)

  useEffect(() => {
    // ユーザーのモーション設定を確認
    const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
    setPrefersReducedMotion(mediaQuery.matches)

    // 設定変更を監視
    const handler = (e: MediaQueryListEvent) => {
      setPrefersReducedMotion(e.matches)
    }
    mediaQuery.addEventListener('change', handler)

    return () => mediaQuery.removeEventListener('change', handler)
  }, [])

  return (
    <section
      className={prefersReducedMotion ? '' : 'animate-fade-in'}
      // アニメーションが無効な場合はaria-liveで変更を通知
      aria-live={prefersReducedMotion ? 'polite' : undefined}
    >
      {children}
    </section>
  )
}

テストと検証

プログレッシブエンハンスメントが正しく機能しているか確認する方法を紹介します。

JavaScriptを無効にしてテスト

ブラウザの開発者ツールでJavaScriptを無効にして、以下を確認します。

// テスト用のチェックリスト
const progressiveEnhancementChecklist = {
  // コンテンツの確認
  content: [
    'ページの主要コンテンツが表示されるか',
    'ナビゲーションリンクが機能するか',
    'フォームが送信できるか',
  ],
  // 機能の確認
  functionality: [
    'ページ間の遷移が可能か',
    'フォームのバリデーションがサーバーサイドで行われるか',
    'エラーメッセージが適切に表示されるか',
  ],
  // アクセシビリティの確認
  accessibility: [
    'スクリーンリーダーで内容が読み上げられるか',
    'キーボードのみで操作可能か',
    '適切なフォーカス管理がされているか',
  ],
}

Lighthouseでの検証

# Lighthouseを使用してアクセシビリティをチェック
npx lighthouse https://your-site.com --only-categories=accessibility

まとめ

Next.jsでプログレッシブエンハンスメントを実践することで、以下のメリットが得られます。

  • 堅牢性: JavaScriptの読み込み失敗やエラーでも基本機能が動作
  • アクセシビリティ: 多様なユーザーに対応した設計
  • パフォーマンス: 初期表示が高速でユーザー体験が向上
  • 保守性: 段階的な機能追加が容易

Server Actionsを活用したフォーム実装、セマンティックHTMLの使用、そしてJavaScriptによる段階的な機能拡張を組み合わせることで、すべてのユーザーに快適な体験を提供できるWebアプリケーションを構築しましょう。

参考文献

円