nextjs について
Next.js アプリの構築と保守に取り組む開発者向けのリファレンススキルです。App Router、レンダリング、データ取得、最適化を扱います。
主な機能
- 01App Router のルーティングとレイアウト
- 02Server Components と Client Components
- 03データ取得とキャッシュ
- 04ルートハンドラーとナビゲーション
- 05メタデータと SEO の設定
- 06画像とフォントの最適化
活用シーン
- 01Next.js アプリの開始
- 02Pages Router プロジェクトの移行
- 03サーバーレンダリングページの実装
- 04SEO とメタデータの設定
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ timelessco/ recollect --skill nextjsインストール後、Claude Code で/nextjsを入力して読み込まれたか確認します。
詳細
Next.js スキル
Next.js は、サーバーサイドレンダリング、静的生成、強力な最適化機能を標準で備えた、フルスタックの Web アプリケーションを構築するための React フレームワークです。
リファレンス
https://nextjs.org/docs/llms.txt
このスキルを使用する場面
このスキルは、次のような場合に使用します。
- 新しい Next.js アプリケーション(v15 以降)を構築する
- App Router アーキテクチャを実装する
- Server Components と Client Components を扱う
- ルーティング、レイアウト、ナビゲーションを設定する
- データ取得パターンを実装する
- 画像、フォント、パフォーマンスを最適化する
- メタデータと SEO を設定する
- API ルートとルートハンドラーを設定する
- Pages Router から App Router に移行する
- Next.js アプリケーションをデプロイする
主な概念
App Router と Pages Router
App Router(v13 以降で推奨):
- React Server Components を採用したモダンなアーキテクチャ
app/ディレクトリを使用するファイルシステムベースのルーティング- レイアウト、読み込み状態、エラーバウンダリー
- ストリーミングと Suspense のサポート
- レイアウトを使用したネストされたルーティング
Pages Router(レガシー):
pages/ディレクトリを使用する従来のページベースのルーティングgetStaticProps、getServerSideProps、getInitialPropsを使用- 既存のプロジェクトでは引き続きサポート
主なアーキテクチャ原則
- デフォルトでは Server Components:
'use client'を指定しない限り、app/内のコンポーネントは Server Components です - ファイルベースのルーティング:ファイルシステムによってアプリケーションのルートが定義されます
- ネストされたレイアウト:レイアウトを使用してルート間で UI を共有します
- プログレッシブエンハンスメント:可能な場合は JavaScript なしで動作します
- 自動最適化:画像、フォント、スクリプトが自動的に最適化されます
インストールとセットアップ
新しいプロジェクトの作成
npx create-next-app@latest my-app
# or
yarn create next-app my-app
# or
pnpm create next-app my-app
# or
bun create next-app my-app
対話型セットアップのプロンプト:
- TypeScript?(はいを推奨)
- ESLint?(はいを推奨)
- Tailwind CSS?(任意)
src/ディレクトリ?(任意)- App Router?(新しいプロジェクトでは「はい」)
- インポートエイリアス?(デフォルト:@/*)
手動でのセットアップ
npm install next@latest react@latest react-dom@latest
package.json のスクリプト:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
}
}
プロジェクト構成
my-app/
├── app/ # App Router (v13+)
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Home page
│ ├── loading.tsx # Loading UI
│ ├── error.tsx # Error UI
│ ├── not-found.tsx # 404 page
│ ├── global.css # Global styles
│ └── [folder]/ # Route segments
├── public/ # Static assets
├── components/ # React components
├── lib/ # Utility functions
├── next.config.js # Next.js configuration
├── package.json
└── tsconfig.json
ルーティング
ファイルの規約
page.tsx- ルートのページ UIlayout.tsx- セグメントと子要素で共有する UIloading.tsx- 読み込み中の UI(ページを Suspense でラップ)error.tsx- エラー UI(ページを Error Boundary でラップ)not-found.tsx- 404 UIroute.ts- API エンドポイント(Route Handler)template.tsx- 再レンダリングされるレイアウト UIdefault.tsx- 並列ルートのフォールバック
基本的なルーティング
静的ルート:
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
└── blog/
└── page.tsx → /blog
動的ルート:
// app/blog/[slug]/page.tsx
export default function BlogPost({ params }: { params: { slug: string } }) {
return <h1>Post: {params.slug}</h1>;
}
キャッチオールルート:
// app/shop/[...slug]/page.tsx
export default function Shop({ params }: { params: { slug: string[] } }) {
return <h1>Category: {params.slug.join("/")}</h1>;
}
オプショナルキャッチオール:
// app/docs/[[...slug]]/page.tsx
// Matches /docs, /docs/a, /docs/a/b, etc.
ルートグループ
URL に影響を与えずにルートを整理します。
app/
├── (marketing)/ # Group without URL segment
│ ├── about/page.tsx → /about
│ └── blog/page.tsx → /blog
└── (shop)/
├── products/page.tsx → /products
└── cart/page.tsx → /cart
並列ルート
同じレイアウト内に複数のページをレンダリングします。
app/
├── @team/ # Slot
│ └── page.tsx
├── @analytics/ # Slot
│ └── page.tsx
└── layout.tsx # Uses both slots
// app/layout.tsx
export default function Layout({
children,
team,
analytics,
}: {
children: React.ReactNode;
team: React.ReactNode;
analytics: React.ReactNode;
}) {
return (
<>
{children}
{team}
{analytics}
</>
);
}
インターセプトルート
ルートをインターセプトしてモーダル内に表示します。
app/
├── feed/
│ └── page.tsx
├── photo/
│ └── [id]/
│ └── page.tsx
└── (..)photo/ # Intercepts /photo/[id]
└── [id]/
└── page.tsx
レイアウト
ルートレイアウト(必須)
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
ネストされたレイアウト
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section>
<nav>Dashboard Nav</nav>
{children}
</section>
);
}
レイアウトの特徴:
- 複数のページ間で共有されます
- ナビゲーション時に状態を保持します
- ナビゲーション時に再レンダリングされません
- データを取得できます
Server Components と Client Components
Server Components(デフォルト)
app/ 内のコンポーネントは、デフォルトで Server Components です。
// app/page.tsx (Server Component)
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>;
}
利点:
- サーバー上でデータを取得できる
- バックエンドのリソースに直接アクセスできる
- 機密データをサーバー上に保持できる
- クライアント側の JavaScript を削減できる
- 初回ページ読み込みを改善できる
制限事項:
- フック(useState、useEffect)は使用できません
- ブラウザー API は使用できません
- イベントリスナーは追加できません
Client Components
'use client' ディレクティブでコンポーネントを指定します。
// components/counter.tsx
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
Client Components を使用する場面:
- インタラクティブな UI(onClick、onChange)
- 状態管理(useState、useReducer)
- エフェクト(useEffect、useLayoutEffect)
- ブラウザー API(localStorage、navigator)
- カスタムフック
- React クラスコンポーネント
コンポジションパターン
// app/page.tsx (Server Component)
import { ClientComponent } from "./client-component";
export default function Page() {
return (
<div>
<h1>Server-rendered content</h1>
<ClientComponent />
</div>
);
}
データ取得
Server Component でのデータ取得
// 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 (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
キャッシュ戦略
Force Cache(デフォルト):
fetch("https://api.example.com/data", { cache: "force-cache" });
No Store(動的):
fetch("https://api.example.com/data", { cache: "no-store" });
再検証:
fetch("https://api.example.com/data", {
next: { revalidate: 3600 }, // Seconds
});
タグベースの再検証:
fetch("https://api.example.com/data", {
next: { tags: ["posts"] },
});
// Revalidate elsewhere:
import { revalidateTag } from "next/cache";
revalidateTag("posts");
並列データ取得
async function getData() {
const [posts, users] = await Promise.all([
fetch("https://api.example.com/posts").then((r) => r.json()),
fetch("https://api.example.com/users").then((r) => r.json()),
]);
return { posts, users };
}
順次データ取得
async function getData() {
const post = await fetch(`https://api.example.com/posts/${id}`).then((r) =>
r.json(),
);
const author = await fetch(
`https://api.example.com/users/${post.authorId}`,
).then((r) => r.json());
return { post, author };
}
ルートハンドラー(API ルート)
基本的なルートハンドラー
// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: "Hello" });
}
export async function POST(request: Request) {
const body = await request.json();
return Response.json({ received: body });
}
動的ルートハンドラー
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } },
) {
const post = await getPost(params.id);
return Response.json(post);
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } },
) {
await deletePost(params.id);
return new Response(null, { status: 204 });
}
リクエストヘルパー
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const cookies = request.headers.get("cookie");
return Response.json({ id });
}
レスポンスタイプ
// JSON
return Response.json({ data: "value" });
// Text
return new Response("Hello", { headers: { "Content-Type": "text/plain" } });
// Redirect
return Response.redirect("https://example.com");
// Status codes
return new Response("Not Found", { status: 404 });
ナビゲーション
Link コンポーネント
import Link from "next/link";
export default function Page() {
return (
<>
<Link href="/about">About</Link>
<Link href="/blog/post-1">Post 1</Link>
<Link href={{ pathname: "/blog/[slug]", query: { slug: "post-1" } }}>
Post 1 (alternative)
</Link>
</>
);
}
useRouter フック(クライアント)
"use client";
import { useRouter } from "next/navigation";
export function NavigateButton() {
const router = useRouter();
return <button onClick={() => router.push("/dashboard")}>Dashboard</button>;
}
ルーターのメソッド:
router.push(href)- ルートに移動しますrouter.replace(href)- 現在の履歴を置き換えますrouter.refresh()- 現在のルートを更新しますrouter.back()- 前のページに戻りますrouter.forward()- 次のページに進みますrouter.prefetch(href)- ルートを事前取得します
プログラムによるナビゲーション(サーバー)
import { redirect } from "next/navigation";
export default async function Page() {
const session = await getSession();
if (!session) {
redirect("/login");
}
return <div>Protected content</div>;
}
メタデータと SEO
静的メタデータ
// app/page.tsx
import { Metadata } from "next";
export const metadata: Metadata = {
title: "My Page",
description: "Page description",
keywords: ["nextjs", "react"],
openGraph: {
title: "My Page",
description: "Page description",
images: ["/og-image.jpg"],
},
twitter: {
card: "summary_large_image",
title: "My Page",
description: "Page description",
images: ["/twitter-image.jpg"],
},
};
export default function Page() {
return <div>Content</div>;
}
動的メタデータ
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }): 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],
},
};
}
メタデータファイル
favicon.ico、icon.png、apple-icon.png- ファビコンopengraph-image.png、twitter-image.png- ソーシャル画像robots.txt- robots ファイルsitemap.xml- サイトマップ
画像の最適化
Image コンポーネント
import Image from "next/image";
export default function Page() {
return (
<>
{/* Local image */}
<Image src="/profile.png" alt="Profile" width={500} height={500} />
{/* Remote image */}
<Image
src="https://example.com/image.jpg"
alt="Remote"
width={500}
height={500}
/>
{/* Responsive fill */}
<div style={{ position: "relative", width: "100%", height: "400px" }}>
<Image src="/hero.jpg" alt="Hero" fill style={{ objectFit: "cover" }} />
</div>
{/* Priority loading */}
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority />
</>
);
}
画像のプロパティ:
src- 画像のパス(ローカルまたは URL)alt- 代替テキスト(必須)width、height- 寸法(fillを使用する場合を除き必須)fill- 親コンテナーを埋めますsizes- レスポンシブサイズquality- 1~100(デフォルトは 75)priority- 画像を事前読み込みしますplaceholder- 'blur-sm' | 'empty'blurDataURL- ぼかし用のデータ URL
リモート画像の設定
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "example.com",
pathname: "/images/**",
},
],
},
};
フォントの最適化
Google Fonts
// app/layout.tsx
import { Inter, Roboto_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
});
const robotoMono = Roboto_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-roboto-mono",
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.className} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
);
}
ローカルフォント
import localFont from "next/font/local";
const myFont = localFont({
src: "./fonts/my-font.woff2",
display: "swap",
variable: "--font-my-font",
});
読み込み状態
ローディングファイル
// app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading dashboard...</div>;
}
Suspense によるストリーミング
// app/page.tsx
import { Suspense } from "react";
async function Posts() {
const posts = await getPosts();
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
export default function Page() {
return (
<div>
<h1>My Posts</h1>
<Suspense fallback={<div>Loading posts...</div>}>
<Posts />
</Suspense>
</div>
);
}
エラー処理
エラーファイル
// app/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/global-error.tsx
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
);
}
見つからない場合
// app/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>404 - Not Found</h2>
<p>Could not find requested resource</p>
</div>
);
}
// Trigger programmatically
import { notFound } from "next/navigation";
export default async function Page({ params }) {
const post = await getPost(params.id);
if (!post) {
notFound();
}
return <div>{post.title}</div>;
}
ミドルウェア
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Authentication check
const token = request.cookies.get("token");
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Add custom header
const response = NextResponse.next();
response.headers.set("x-custom-header", "value");
return response;
}
export const config = {
matcher: ["/dashboard/:path*", "/api/:path*"],
};
環境変数
# .env.local
DATABASE_URL=postgresql://...
NEXT_PUBLIC_API_URL=https://api.example.com
// Server-side only
const dbUrl = process.env.DATABASE_URL;
// Client and server (NEXT_PUBLIC_ prefix)
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
設定
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// React strict mode
reactStrictMode: true,
// Image domains
images: {
remotePatterns: [{ protocol: "https", hostname: "example.com" }],
},
// Redirects
async redirects() {
return [
{
source: "/old-page",
destination: "/new-page",
permanent: true,
},
];
},
// Rewrites
async rewrites() {
return [
{
source: "/api/:path*",
destination: "https://api.example.com/:path*",
},
];
},
// Headers
async headers() {
return [
{
source: "/(.*)",
headers: [{ key: "X-Frame-Options", value: "DENY" }],
},
];
},
// Environment variables
env: {
CUSTOM_KEY: "value",
},
};
module.exports = nextConfig;
ベストプラクティス
- サーバーコンポーネントを使用する:デフォルトではサーバーコンポーネントを使用し、必要な場合にのみクライアントコンポーネントを使用します
- 画像を最適化する:自動最適化のため、常に
next/imageを使用します - メタデータ:SEO に適したメタデータを設定します
- 読み込み状態:Suspense を使用してローディング UI を提供します
- エラー処理:エラーバウンダリーを実装します
- ルートハンドラー:個別のバックエンドの代わりに、API エンドポイントに使用します
- キャッシュ:組み込みのキャッシュ戦略を活用します
- レイアウト:入れ子になったレイアウトを使用して UI を共有します
- TypeScript:型安全性のため、TypeScript を有効にします
- パフォーマンス:ファーストビューの画像には
priorityを使用し、ファーストビューより下の画像は遅延読み込みします
よくあるパターン
保護されたルート
// app/dashboard/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
export default async function DashboardLayout({ children }) {
const session = await getSession();
if (!session) {
redirect("/login");
}
return <>{children}</>;
}
データの変更(サーバーアクション)
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title");
await db.post.create({ data: { title } });
revalidatePath("/posts");
}
// app/posts/new/page.tsx
import { createPost } from "@/app/actions";
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" type="text" required />
<button type="submit">Create</button>
</form>
);
}
静的生成
// Generate static params for dynamic routes
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function Post({ params }) {
const post = await getPost(params.slug);
return <article>{post.content}</article>;
}
デプロイ
Vercel(推奨)
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
セルフホスティング
# Build
npm run build
# Start production server
npm start
要件:
- Node.js 18.17 以降
- next.config.js の
output: 'standalone'(任意。サイズを削減します)
Docker
FROM node:18-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base 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"]
トラブルシューティング
よくある問題
-
ハイドレーションエラー
- サーバーとクライアントが同じコンテンツをレンダリングすることを確認します
- サーバーコンポーネント内にブラウザー専用のコードがないか確認します
- ブラウザー API に基づく条件付きレンダリングがないことを確認します
-
画像が読み込まれない
- リモートドメインを
next.config.jsに追加します - 画像のパスを確認します(public では先頭に
/を付けます) - 幅と高さが指定されていることを確認します
- リモートドメインを
-
API ルートが 404 になる
- ファイル名が
index.tsではなくroute.ts/jsであることを確認します - GET/POST という名前付きエクスポートがあり、デフォルトエクスポートではないことを確認します
app/api/ディレクトリ内にあることを確認します
- ファイル名が
-
「use client」エラー
- フックを使用するコンポーネントに
'use client'を追加します - クライアントコンポーネントはサーバーコンポーネントにインポートします。その逆はできません
- イベントハンドラーに
'use client'が指定されていることを確認します
- フックを使用するコンポーネントに
-
メタデータが更新されない
- ブラウザーのキャッシュをクリアします
- メタデータのエクスポート名が正しいことを確認します
- 非同期の generateMetadata が Promise を返すことを確認します
リソース
- ドキュメント:https://nextjs.org/docs
- 学習コース:https://nextjs.org/learn
- サンプル:https://github.com/vercel/next.js/tree/canary/examples
- ブログ:https://nextjs.org/blog
- GitHub:https://github.com/vercel/next.js
実装チェックリスト
Next.js で構築する場合:
-
create-next-appでプロジェクトを作成する - TypeScript と ESLint を設定する
- メタデータを含むルートレイアウトを設定する
- ルーティング構造を実装する
- 読み込み状態とエラー状態を追加する
- 画像の最適化を設定する
- フォントの最適化を設定する
- データ取得のパターンを実装する
- 必要に応じて API ルートを追加する
- 環境変数を設定する
- 必要に応じてミドルウェアを設定する
- 本番ビルド向けに最適化する
- 本番モードでテストする
- デプロイプラットフォームを設定する
- モニタリングと分析を設定する
コメント
Markdown 対応