mcpField
N
Next.js

Next.js

github.com/oriolrius開発2437 回インストール6 日前に更新
リポジトリ

Next.js について

Next.jsは、ReactのWebアプリを開発する人向けのスキルです。App Routerを中心に、Pages Router、サーバーサイドレンダリング、静的生成、APIルートも扱います。ルーティング、データ取得、エラー処理、デプロイのパターンを紹介します。

主な機能

  1. 01App Routerのファイルベースルーティング
  2. 02サーバー/クライアントコンポーネントの解説
  3. 03データ取得とキャッシュのパターン
  4. 04ローディング、エラー、未検出状態
  5. 05APIルートとミドルウェアの例
  6. 06メタデータと画像最適化の解説

活用シーン

  1. 01Next.jsアプリのルート構成を設計
  2. 02Server Componentでデータを取得・キャッシュ
  3. 03APIエンドポイントとアクセス確認を実装
  4. 04ページにメタデータや状態表示を追加

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/oriolrius/pki-manager-web --skill next.js

インストール後、Claude Code で/next.jsを入力して読み込まれたか確認します。

詳細

Next.js

Next.js React フレームワークと最新の Web アプリケーション開発に関する専門的なサポートです。

概要

Next.js は、以下の機能を備えた React フレームワークです。

  • App Router(Next.js 13 以降):React Server Components を使用したファイルベースのルーティング
  • Pages Router(レガシー):従来の Next.js ルーティング
  • サーバーサイドレンダリング(SSR)
  • 静的サイト生成(SSG)
  • API ルート
  • 組み込みの最適化機能

このガイドでは、App Router(最新のアプローチ)に焦点を当てます。

プロジェクトのセットアップ

新しいプロジェクトの作成
# Create Next.js app (interactive)
npx create-next-app@latest

# With specific options
npx create-next-app@latest my-app --typescript --tailwind --app --use-npm

# Project structure
my-app/
├── app/                 # App Router directory
│   ├── layout.tsx       # Root layout
│   ├── page.tsx         # Home page
│   ├── globals.css      # Global styles
│   └── api/             # API routes
├── public/              # Static assets
├── components/          # React components
├── lib/                 # Utility functions
└── next.config.js       # Next.js configuration
開発コマンド
# Start dev server
npm run dev

# Build for production
npm run build

# Start production server
npm start

# Run linter
npm run lint

App Router(Next.js 13 以降)

ファイルベースのルーティング

特別なファイル:

  • layout.tsx - セグメントで共有される UI
  • page.tsx - ルート固有の UI
  • loading.tsx - 読み込み中の UI
  • error.tsx - エラー UI
  • not-found.tsx - 404 UI
  • route.tsx - API エンドポイント

構成例:

app/
├── layout.tsx           # Root layout
├── page.tsx             # / route
├── about/
│   └── page.tsx         # /about route
├── blog/
│   ├── layout.tsx       # Blog layout
│   ├── page.tsx         # /blog route
│   └── [slug]/
│       └── page.tsx     # /blog/:slug route
└── dashboard/
    ├── layout.tsx
    ├── page.tsx         # /dashboard route
    ├── settings/
    │   └── page.tsx     # /dashboard/settings
    └── [id]/
        └── page.tsx     # /dashboard/:id
ルートレイアウト
// app/layout.tsx
import type { Metadata } from 'next'
import './globals.css'

export const metadata: Metadata = {
  title: 'My App',
  description: 'Built with Next.js',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}
基本的なページ
// app/page.tsx
export default function Home() {
  return (
    <main>
      <h1>Welcome to Next.js</h1>
      <p>This is a Server Component by default</p>
    </main>
  )
}
動的ルート
// app/blog/[slug]/page.tsx
export default function BlogPost({
  params,
}: {
  params: { slug: string }
}) {
  return <h1>Blog Post: {params.slug}</h1>
}

// Generate static params for SSG
export async function generateStaticParams() {
  const posts = await getPosts()

  return posts.map((post) => ({
    slug: post.slug,
  }))
}
キャッチオールルート
// app/shop/[...slug]/page.tsx - Matches /shop/a, /shop/a/b, etc.
// app/docs/[[...slug]]/page.tsx - Optional catch-all, also matches /docs

export default function Page({
  params,
}: {
  params: { slug: string[] }
}) {
  return <div>Path: {params.slug.join('/')}</div>
}
ルートグループ
// Group routes without affecting URL structure
app/
├── (marketing)/
│   ├── about/
│   │   └── page.tsx     # /about
│   └── blog/
│       └── page.tsx     # /blog
└── (shop)/
    ├── layout.tsx       # Shop layout
    ├── products/
    │   └── page.tsx     # /products
    └── cart/
        └── page.tsx     # /cart

サーバーコンポーネントとクライアントコンポーネント

サーバーコンポーネント(デフォルト)
// app/posts/page.tsx
// Server Component by default - runs on server
async function getPosts() {
  const res = await fetch('https://api.example.com/posts')
  return res.json()
}

export default async function PostsPage() {
  const posts = await getPosts()

  return (
    <div>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
        </article>
      ))}
    </div>
  )
}

メリット:

  • バックエンドのリソースにアクセスできる
  • 機密データをサーバー上に保持できる
  • クライアント側の JavaScript を削減できる
  • パフォーマンスが向上する
クライアントコンポーネント
// components/Counter.tsx
'use client'  // Required for client components

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  )
}

次の機能が必要な場合に使用します:

  • 状態(useState、useReducer)
  • エフェクト(useEffect)
  • ブラウザー API
  • イベントリスナー
  • カスタムフック
コンポジションパターン
// app/page.tsx (Server Component)
import ClientComponent from '@/components/ClientComponent'
import ServerComponent from '@/components/ServerComponent'

export default async function Page() {
  const data = await fetchData()

  return (
    <div>
      <ServerComponent data={data} />
      <ClientComponent />
    </div>
  )
}

データ取得

サーバーコンポーネントでのデータ取得
// app/posts/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 } // Revalidate every hour
  })

  if (!res.ok) throw new Error('Failed to fetch')
  return res.json()
}

export default async function PostsPage() {
  const posts = await getPosts()
  return <PostsList posts={posts} />
}
キャッシュ戦略
// No caching (dynamic)
fetch('https://api.example.com/data', { cache: 'no-store' })

// Cache indefinitely (static)
fetch('https://api.example.com/data', { cache: 'force-cache' })

// Revalidate after time
fetch('https://api.example.com/data', {
  next: { revalidate: 60 } // 60 seconds
})

// Revalidate with tag
fetch('https://api.example.com/data', {
  next: { tags: ['posts'] }
})

// Then revalidate programmatically
import { revalidateTag } from 'next/cache'
revalidateTag('posts')
並列データ取得
export default async function Page() {
  // Initiate both requests in parallel
  const userPromise = getUser()
  const postsPromise = getPosts()

  // Wait for both
  const [user, posts] = await Promise.all([
    userPromise,
    postsPromise,
  ])

  return <Dashboard user={user} posts={posts} />
}
順次データ取得
export default async function Page() {
  // Fetch user first
  const user = await getUser()

  // Then fetch user's posts
  const posts = await getUserPosts(user.id)

  return <Profile user={user} posts={posts} />
}

読み込み状態とエラー状態

読み込み中の UI
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="flex items-center justify-center">
      <div className="animate-spin rounded-full h-32 w-32 border-b-2" />
    </div>
  )
}
エラー処理
// app/dashboard/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}
見つからない場合
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

export default async function BlogPost({
  params,
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)

  if (!post) {
    notFound()
  }

  return <article>{post.content}</article>
}

// app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return <div>Blog post not found</div>
}

API ルート

ルートハンドラー
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'

// GET /api/posts
export async function GET(request: NextRequest) {
  const posts = await getPosts()
  return NextResponse.json(posts)
}

// POST /api/posts
export async function POST(request: NextRequest) {
  const body = await request.json()
  const post = await createPost(body)
  return NextResponse.json(post, { status: 201 })
}
動的 API ルート
// app/api/posts/[id]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const post = await getPost(params.id)

  if (!post) {
    return NextResponse.json(
      { error: 'Post not found' },
      { status: 404 }
    )
  }

  return NextResponse.json(post)
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  await deletePost(params.id)
  return NextResponse.json({ success: true })
}
リクエストヘルパー
// app/api/search/route.ts
export async function GET(request: NextRequest) {
  // Query parameters
  const searchParams = request.nextUrl.searchParams
  const query = searchParams.get('q')

  // Headers
  const token = request.headers.get('authorization')

  // Cookies
  const session = request.cookies.get('session')

  const results = await search(query)

  // Set cookies in response
  const response = NextResponse.json(results)
  response.cookies.set('last-search', query || '', {
    httpOnly: true,
    secure: true,
    maxAge: 3600,
  })

  return response
}

ミドルウェア

// middleware.ts (root level)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // Check authentication
  const token = request.cookies.get('token')

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  // Add custom header
  const response = NextResponse.next()
  response.headers.set('x-custom-header', 'value')

  return response
}

// Configure which routes to run middleware on
export const config = {
  matcher: [
    '/dashboard/:path*',
    '/api/:path*',
  ],
}

ナビゲーション

Link コンポーネント
import Link from 'next/link'

export default function Nav() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/about">About</Link>
      <Link href="/blog/my-post">Blog Post</Link>

      {/* With prefetching disabled */}
      <Link href="/heavy-page" prefetch={false}>
        Heavy Page
      </Link>
    </nav>
  )
}
プログラムによるナビゲーション
'use client'

import { useRouter, usePathname, useSearchParams } from 'next/navigation'

export default function NavigationExample() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()

  const handleNavigate = () => {
    router.push('/dashboard')
    // router.replace('/dashboard') // No history entry
    // router.back()
    // router.forward()
    // router.refresh() // Refresh current route
  }

  return (
    <div>
      <p>Current path: {pathname}</p>
      <p>Query param: {searchParams.get('id')}</p>
      <button onClick={handleNavigate}>Go to Dashboard</button>
    </div>
  )
}

メタデータと SEO

静的メタデータ
// app/about/page.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'About Us',
  description: 'Learn more about our company',
  keywords: ['about', 'company', 'team'],
  openGraph: {
    title: 'About Us',
    description: 'Learn more about our company',
    images: ['/og-image.png'],
  },
  twitter: {
    card: 'summary_large_image',
    title: 'About Us',
    description: 'Learn more about our company',
    images: ['/twitter-image.png'],
  },
}

export default function AboutPage() {
  return <div>About Us</div>
}
動的メタデータ
// app/blog/[slug]/page.tsx
export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}): Promise<Metadata> {
  const post = await getPost(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}

画像の最適化

import Image from 'next/image'

export default function Gallery() {
  return (
    <div>
      {/* Local image */}
      <Image
        src="/hero.jpg"
        alt="Hero"
        width={800}
        height={600}
        priority // Load eagerly
      />

      {/* Remote image */}
      <Image
        src="https://example.com/image.jpg"
        alt="Remote"
        width={800}
        height={600}
        loading="lazy"
      />

      {/* Fill container */}
      <div className="relative h-64">
        <Image
          src="/background.jpg"
          alt="Background"
          fill
          className="object-cover"
        />
      </div>
    </div>
  )
}

環境変数

// .env.local
DATABASE_URL=postgresql://...
NEXT_PUBLIC_API_URL=https://api.example.com

// Server component (private vars)
const dbUrl = process.env.DATABASE_URL

// Client component (public vars only)
const apiUrl = process.env.NEXT_PUBLIC_API_URL

設定

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Image domains
  images: {
    domains: ['example.com', 'cdn.example.com'],
  },

  // Redirects
  async redirects() {
    return [
      {
        source: '/old-path',
        destination: '/new-path',
        permanent: true,
      },
    ]
  },

  // Rewrites
  async rewrites() {
    return [
      {
        source: '/api/:path*',
        destination: 'https://api.example.com/:path*',
      },
    ]
  },

  // Headers
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Origin', value: '*' },
        ],
      },
    ]
  },

  // Environment variables
  env: {
    CUSTOM_VAR: 'value',
  },
}

module.exports = nextConfig

ベストプラクティス

1. デフォルトでサーバーコンポーネントを使用する
// ✅ Use Server Components when possible
// app/page.tsx
async function getData() {
  const res = await fetch('https://api.example.com/data')
  return res.json()
}

export default async function Page() {
  const data = await getData()
  return <div>{data.title}</div>
}

// ❌ Don't use Client Component unnecessarily
'use client'
export default function Page() {
  // This doesn't need to be a Client Component
  return <div>Static content</div>
}
2. 適切なデータ取得
// ✅ Fetch in Server Components
async function Page() {
  const data = await fetch('...').then(r => r.json())
  return <List data={data} />
}

// ❌ Don't fetch in Client Components if avoidable
'use client'
function Page() {
  const [data, setData] = useState([])
  useEffect(() => {
    fetch('...').then(r => r.json()).then(setData)
  }, [])
  return <List data={data} />
}
3. 読み込み状態
// ✅ Use loading.tsx for automatic loading UI
// app/dashboard/loading.tsx
export default function Loading() {
  return <Skeleton />
}

// ✅ Or use Suspense for granular control
import { Suspense } from 'react'

export default function Page() {
  return (
    <Suspense fallback={<Skeleton />}>
      <DataComponent />
    </Suspense>
  )
}
4. エラー処理
// ✅ Use error.tsx for error boundaries
// app/dashboard/error.tsx
'use client'
export default function Error({ error, reset }) {
  return (
    <div>
      <h2>Error: {error.message}</h2>
      <button onClick={reset}>Retry</button>
    </div>
  )
}
5. メタデータ
// ✅ Always add metadata for SEO
export const metadata = {
  title: 'Page Title',
  description: 'Page description',
}

// ✅ Use dynamic metadata for dynamic routes
export async function generateMetadata({ params }) {
  const data = await getData(params.id)
  return { title: data.title }
}

よくあるパターン

認証チェック
// middleware.ts
export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}
保護された API ルート
// app/api/protected/route.ts
export async function GET(request: NextRequest) {
  const token = request.headers.get('authorization')

  if (!token) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    )
  }

  const data = await getProtectedData()
  return NextResponse.json(data)
}
フォーム処理
'use client'

export default function ContactForm() {
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)

    const res = await fetch('/api/contact', {
      method: 'POST',
      body: JSON.stringify(Object.fromEntries(formData)),
      headers: { 'Content-Type': 'application/json' },
    })

    if (res.ok) {
      alert('Submitted!')
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Submit</button>
    </form>
  )
}

デプロイ

Vercel(推奨)
# Install Vercel CLI
npm i -g vercel

# Deploy
vercel

# Production deployment
vercel --prod
Docker
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

リソース

コメント