Next.js 15入門チュートリアル2026年版 - App Routerで作るモダンWebアプリ開発
Next.js 15とApp Routerを使ったモダンWebアプリ開発の完全ガイド。Server Actions、Streaming、最新の機能を実際にコードを書きながら学べます。
Next.js 15入門チュートリアル2026年版 - App Routerで作るモダンWebアプリ
Next.js 15がリリースされ、より高速で使いやすくなりました。この記事では、2026年最新のNext.js 15とApp Routerを使って、実際のWebアプリケーションを作りながらステップバイステップで学んでいきます。
何を作るのか?
このチュートリアルでは、ブログアプリケーションを作成します。以下の機能を実装していきます:
- 📄 記事一覧表示
- 📖 記事詳細表示
- 🔍 検索機能
- 📝 記事作成・編集(Server Actions使用)
- 🎨 レスポンシブデザイン
- ⚡ ストリーミング表示
前提条件
このチュートリアルを始める前に、以下の知識が必要です:
- HTML/CSS の基礎知識
- JavaScript(ES6+)の基礎知識
- React の基本的な理解(useState、useEffect など)
開発環境の準備
Node.js のインストール
最新のLTSバージョン(Node.js 20.x以上)をインストールしてください。
# バージョン確認
node --version
npm --version
Next.js プロジェクトの作成
# Next.js 15 プロジェクト作成
npx create-next-app@latest blog-app --typescript --tailwind --app
# プロジェクトディレクトリに移動
cd blog-app
# 開発サーバー起動
npm run dev
ブラウザで http://localhost:3000 にアクセスして、Welcome画面が表示されることを確認してください。
プロジェクト構成の理解
Next.js 15 + App Routerの基本構成を確認しましょう:
blog-app/
├── app/ # App Router(新)
│ ├── layout.tsx # ルートレイアウト
│ ├── page.tsx # ホームページ
│ ├── globals.css # グローバルCSS
│ └── blog/ # ブログ関連ページ
├── components/ # 再利用可能なコンポーネント
├── lib/ # ユーティリティ関数
└── public/ # 静的ファイル
ステップ1: 基本レイアウトの作成
まず、アプリケーション全体で使用するレイアウトを作成します。
app/layout.tsx の更新
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'モダンブログアプリ',
description: 'Next.js 15で作るモダンなブログアプリケーション',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ja">
<body className={inter.className}>
<header className="bg-white shadow-sm border-b">
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<h1 className="text-xl font-bold text-gray-900">
モダンブログ
</h1>
<div className="space-x-4">
<a href="/" className="text-gray-600 hover:text-gray-900">
ホーム
</a>
<a href="/blog" className="text-gray-600 hover:text-gray-900">
ブログ
</a>
<a href="/blog/new" className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
記事作成
</a>
</div>
</div>
</nav>
</header>
<main className="min-h-screen bg-gray-50">
{children}
</main>
<footer className="bg-gray-900 text-white py-8">
<div className="max-w-7xl mx-auto px-4 text-center">
<p>© 2026 モダンブログアプリ. All rights reserved.</p>
</div>
</footer>
</body>
</html>
)
}
ホームページの作成
app/page.tsx を更新します:
import Link from 'next/link'
export default function HomePage() {
return (
<div className="max-w-4xl mx-auto py-12 px-4">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-6">
Next.js 15で作るモダンブログ
</h1>
<p className="text-xl text-gray-600 mb-8">
App Router、Server Actions、Streamingを活用した
<br />
高速で使いやすいブログアプリケーション
</p>
<div className="space-y-4 sm:space-y-0 sm:space-x-4 sm:flex sm:justify-center">
<Link
href="/blog"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 font-semibold"
>
ブログを読む
</Link>
<Link
href="/blog/new"
className="inline-block border border-blue-600 text-blue-600 px-6 py-3 rounded-lg hover:bg-blue-50 font-semibold"
>
記事を書く
</Link>
</div>
</div>
<div className="mt-16 grid md:grid-cols-3 gap-8">
<div className="text-center p-6 bg-white rounded-lg shadow-sm">
<div className="text-3xl mb-4">⚡</div>
<h3 className="text-lg font-semibold mb-2">高速パフォーマンス</h3>
<p className="text-gray-600">
App Routerによる最適化されたルーティングとStreaming機能
</p>
</div>
<div className="text-center p-6 bg-white rounded-lg shadow-sm">
<div className="text-3xl mb-4">🚀</div>
<h3 className="text-lg font-semibold mb-2">Server Actions</h3>
<p className="text-gray-600">
サーバーサイドでの安全なデータ操作とフォーム処理
</p>
</div>
<div className="text-center p-6 bg-white rounded-lg shadow-sm">
<div className="text-3xl mb-4">📱</div>
<h3 className="text-lg font-semibold mb-2">レスポンシブ</h3>
<p className="text-gray-600">
Tailwind CSSによるモバイルファーストなデザイン
</p>
</div>
</div>
</div>
)
}
ステップ2: データ型とユーティリティの準備
ブログ記事の型定義
lib/types.ts を作成します:
export interface BlogPost {
id: string
title: string
content: string
excerpt: string
author: string
publishedAt: Date
updatedAt: Date
slug: string
tags: string[]
}
export interface CreatePostData {
title: string
content: string
excerpt: string
author: string
tags: string[]
}
モックデータとユーティリティ
lib/blog-data.ts を作成します:
import { BlogPost } from './types'
// モックデータ(実際のアプリではデータベースを使用)
export const mockPosts: BlogPost[] = [
{
id: '1',
title: 'Next.js 15の新機能を徹底解説',
content: `Next.js 15では多くの新機能が追加されました...`,
excerpt: 'Next.js 15の主要な新機能をわかりやすく解説します。',
author: 'テックライター',
publishedAt: new Date('2026-01-15'),
updatedAt: new Date('2026-01-15'),
slug: 'nextjs-15-features',
tags: ['Next.js', 'React', 'Web開発']
},
{
id: '2',
title: 'TypeScriptで型安全なReact開発',
content: `TypeScriptを使ったReact開発のベストプラクティス...`,
excerpt: 'TypeScriptでより安全で保守性の高いReactアプリを作る方法',
author: 'フロントエンドエンジニア',
publishedAt: new Date('2026-01-10'),
updatedAt: new Date('2026-01-10'),
slug: 'typescript-react-development',
tags: ['TypeScript', 'React', '型安全']
}
]
// 記事を取得する関数
export async function getPosts(): Promise<BlogPost[]> {
// 実際のAPIコールをシミュレート
await new Promise(resolve => setTimeout(resolve, 1000))
return mockPosts.sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime())
}
// スラッグから記事を取得
export async function getPostBySlug(slug: string): Promise<BlogPost | null> {
await new Promise(resolve => setTimeout(resolve, 500))
return mockPosts.find(post => post.slug === slug) || null
}
// 記事を作成
export async function createPost(data: CreatePostData): Promise<BlogPost> {
await new Promise(resolve => setTimeout(resolve, 1000))
const newPost: BlogPost = {
id: String(Date.now()),
...data,
publishedAt: new Date(),
updatedAt: new Date(),
slug: data.title.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')
}
mockPosts.unshift(newPost)
return newPost
}
ステップ3: ブログ一覧ページの作成
Loading UIとStreaming
app/blog/loading.tsx を作成します:
export default function BlogLoading() {
return (
<div className="max-w-4xl mx-auto py-12 px-4">
<div className="animate-pulse">
<div className="h-8 bg-gray-300 rounded w-1/3 mb-8"></div>
<div className="grid gap-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-white p-6 rounded-lg shadow-sm">
<div className="h-6 bg-gray-300 rounded w-3/4 mb-4"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-300 rounded w-full"></div>
<div className="h-4 bg-gray-300 rounded w-2/3"></div>
</div>
<div className="flex gap-2 mt-4">
<div className="h-6 bg-gray-300 rounded w-16"></div>
<div className="h-6 bg-gray-300 rounded w-20"></div>
</div>
</div>
))}
</div>
</div>
</div>
)
}
ブログ一覧ページ本体
app/blog/page.tsx を作成します:
import { Suspense } from 'react'
import Link from 'next/link'
import { getPosts } from '@/lib/blog-data'
async function BlogList() {
const posts = await getPosts()
return (
<div className="grid gap-6">
{posts.map((post) => (
<article key={post.id} className="bg-white p-6 rounded-lg shadow-sm hover:shadow-md transition-shadow">
<Link href={`/blog/${post.slug}`}>
<h2 className="text-xl font-semibold text-gray-900 hover:text-blue-600 mb-3">
{post.title}
</h2>
</Link>
<p className="text-gray-600 mb-4">
{post.excerpt}
</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center space-x-4">
<span>by {post.author}</span>
<span>{post.publishedAt.toLocaleDateString('ja-JP')}</span>
</div>
<div className="flex gap-2">
{post.tags.map((tag) => (
<span
key={tag}
className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs"
>
{tag}
</span>
))}
</div>
</div>
</article>
))}
</div>
)
}
export default function BlogPage() {
return (
<div className="max-w-4xl mx-auto py-12 px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
ブログ記事一覧
</h1>
<p className="text-gray-600">
最新の技術記事をお届けします
</p>
</div>
<Suspense fallback={<BlogListSkeleton />}>
<BlogList />
</Suspense>
</div>
)
}
function BlogListSkeleton() {
return (
<div className="grid gap-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-white p-6 rounded-lg shadow-sm animate-pulse">
<div className="h-6 bg-gray-300 rounded w-3/4 mb-4"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-300 rounded w-full"></div>
<div className="h-4 bg-gray-300 rounded w-2/3"></div>
</div>
<div className="flex gap-2 mt-4">
<div className="h-6 bg-gray-300 rounded w-16"></div>
<div className="h-6 bg-gray-300 rounded w-20"></div>
</div>
</div>
))}
</div>
)
}
ステップ4: 記事詳細ページの作成
動的ルーティング
app/blog/[slug]/page.tsx を作成します:
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getPostBySlug } from '@/lib/blog-data'
interface PageProps {
params: {
slug: string
}
}
export default async function BlogPostPage({ params }: PageProps) {
const post = await getPostBySlug(params.slug)
if (!post) {
notFound()
}
return (
<div className="max-w-4xl mx-auto py-12 px-4">
{/* パンくずナビ */}
<nav className="mb-8">
<Link href="/blog" className="text-blue-600 hover:text-blue-800">
← ブログ一覧に戻る
</Link>
</nav>
<article>
{/* ヘッダー */}
<header className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
{post.title}
</h1>
<div className="flex items-center space-x-4 text-gray-600 mb-4">
<span>by {post.author}</span>
<span>•</span>
<time dateTime={post.publishedAt.toISOString()}>
{post.publishedAt.toLocaleDateString('ja-JP', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
</div>
<div className="flex gap-2">
{post.tags.map((tag) => (
<span
key={tag}
className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm"
>
{tag}
</span>
))}
</div>
</header>
{/* コンテンツ */}
<div className="prose prose-lg max-w-none">
<div className="bg-white p-8 rounded-lg shadow-sm">
{post.content.split('\n').map((paragraph, index) => (
<p key={index} className="mb-4 text-gray-700 leading-relaxed">
{paragraph}
</p>
))}
</div>
</div>
</article>
</div>
)
}
// 404ページ
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
return (
<div className="max-w-4xl mx-auto py-12 px-4 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
記事が見つかりません
</h2>
<p className="text-gray-600 mb-8">
お探しの記事は削除されたか、URLが変更された可能性があります。
</p>
<Link
href="/blog"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700"
>
ブログ一覧に戻る
</Link>
</div>
)
}
ステップ5: Server Actionsで記事作成機能
Server Actions の実装
lib/actions.ts を作成します:
'use server'
import { redirect } from 'next/navigation'
import { createPost } from './blog-data'
import { CreatePostData } from './types'
export async function createBlogPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
const excerpt = formData.get('excerpt') as string
const author = formData.get('author') as string
const tagsString = formData.get('tags') as string
// バリデーション
if (!title || !content || !excerpt || !author) {
throw new Error('必須フィールドが入力されていません')
}
const tags = tagsString
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0)
const postData: CreatePostData = {
title,
content,
excerpt,
author,
tags
}
const newPost = await createPost(postData)
// 作成した記事ページにリダイレクト
redirect(`/blog/${newPost.slug}`)
}
記事作成フォーム
app/blog/new/page.tsx を作成します:
import { createBlogPost } from '@/lib/actions'
export default function NewBlogPostPage() {
return (
<div className="max-w-4xl mx-auto py-12 px-4">
<h1 className="text-3xl font-bold text-gray-900 mb-8">
新しい記事を作成
</h1>
<form action={createBlogPost} className="bg-white p-8 rounded-lg shadow-sm">
<div className="space-y-6">
{/* タイトル */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
タイトル *
</label>
<input
type="text"
id="title"
name="title"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="記事のタイトルを入力してください"
/>
</div>
{/* 要約 */}
<div>
<label htmlFor="excerpt" className="block text-sm font-medium text-gray-700 mb-2">
要約 *
</label>
<textarea
id="excerpt"
name="excerpt"
required
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="記事の要約を入力してください(一覧ページで表示されます)"
/>
</div>
{/* 本文 */}
<div>
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
本文 *
</label>
<textarea
id="content"
name="content"
required
rows={12}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="記事の本文を入力してください"
/>
</div>
{/* 著者 */}
<div>
<label htmlFor="author" className="block text-sm font-medium text-gray-700 mb-2">
著者名 *
</label>
<input
type="text"
id="author"
name="author"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="著者名を入力してください"
/>
</div>
{/* タグ */}
<div>
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-2">
タグ
</label>
<input
type="text"
id="tags"
name="tags"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="タグをカンマ区切りで入力してください(例: React, Next.js, TypeScript)"
/>
<p className="mt-1 text-sm text-gray-500">
複数のタグはカンマで区切って入力してください
</p>
</div>
</div>
<div className="mt-8 flex gap-4">
<button
type="submit"
className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 font-semibold"
>
記事を投稿
</button>
<a
href="/blog"
className="bg-gray-200 text-gray-700 px-6 py-3 rounded-lg hover:bg-gray-300 font-semibold"
>
キャンセル
</a>
</div>
</form>
</div>
)
}
ステップ6: 検索機能の実装
検索用のServer Component
components/SearchForm.tsx を作成します:
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function SearchForm() {
const [query, setQuery] = useState('')
const router = useRouter()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (query.trim()) {
router.push(`/blog/search?q=${encodeURIComponent(query.trim())}`)
}
}
return (
<form onSubmit={handleSubmit} className="mb-8">
<div className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="記事を検索..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
検索
</button>
</div>
</form>
)
}
検索結果ページ
app/blog/search/page.tsx を作成します:
import Link from 'next/link'
import { getPosts } from '@/lib/blog-data'
interface SearchPageProps {
searchParams: {
q?: string
}
}
export default async function SearchPage({ searchParams }: SearchPageProps) {
const query = searchParams.q || ''
const allPosts = await getPosts()
const filteredPosts = allPosts.filter(post =>
post.title.toLowerCase().includes(query.toLowerCase()) ||
post.content.toLowerCase().includes(query.toLowerCase()) ||
post.excerpt.toLowerCase().includes(query.toLowerCase()) ||
post.tags.some(tag => tag.toLowerCase().includes(query.toLowerCase()))
)
return (
<div className="max-w-4xl mx-auto py-12 px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
検索結果
</h1>
{query && (
<p className="text-gray-600">
「{query}」の検索結果: {filteredPosts.length}件
</p>
)}
</div>
{filteredPosts.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 text-lg mb-4">
検索条件に一致する記事が見つかりませんでした
</p>
<Link
href="/blog"
className="text-blue-600 hover:text-blue-800"
>
すべての記事を見る
</Link>
</div>
) : (
<div className="grid gap-6">
{filteredPosts.map((post) => (
<article key={post.id} className="bg-white p-6 rounded-lg shadow-sm">
<Link href={`/blog/${post.slug}`}>
<h2 className="text-xl font-semibold text-gray-900 hover:text-blue-600 mb-3">
{post.title}
</h2>
</Link>
<p className="text-gray-600 mb-4">
{post.excerpt}
</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center space-x-4">
<span>by {post.author}</span>
<span>{post.publishedAt.toLocaleDateString('ja-JP')}</span>
</div>
<div className="flex gap-2">
{post.tags.map((tag) => (
<span
key={tag}
className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs"
>
{tag}
</span>
))}
</div>
</div>
</article>
))}
</div>
)}
</div>
)
}
ブログページに検索フォームを追加
app/blog/page.tsx を更新:
import { Suspense } from 'react'
import Link from 'next/link'
import { getPosts } from '@/lib/blog-data'
import SearchForm from '@/components/SearchForm'
// ... 既存のBlogList, BlogListSkeleton コンポーネント
export default function BlogPage() {
return (
<div className="max-w-4xl mx-auto py-12 px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
ブログ記事一覧
</h1>
<p className="text-gray-600">
最新の技術記事をお届けします
</p>
</div>
<SearchForm />
<Suspense fallback={<BlogListSkeleton />}>
<BlogList />
</Suspense>
</div>
)
}
ステップ7: パフォーマンスの最適化
画像最適化
Next.js の Image コンポーネントを使用して画像を最適化します:
import Image from 'next/image'
// 記事内で画像を使用する場合
<Image
src="/images/blog-hero.jpg"
alt="ブログのヒーロー画像"
width={800}
height={400}
className="rounded-lg"
priority // 最初の画像の場合
/>
メタデータの最適化
各ページに適切なSEOメタデータを追加:
// app/blog/[slug]/page.tsx に追加
import type { Metadata } from 'next'
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const post = await getPostBySlug(params.slug)
if (!post) {
return {
title: '記事が見つかりません'
}
}
return {
title: `${post.title} | モダンブログ`,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt.toISOString(),
authors: [post.author],
tags: post.tags,
},
}
}
ステップ8: デプロイメント
Vercelでのデプロイ
# Vercel CLIをインストール
npm i -g vercel
# デプロイ
vercel
# 本番デプロイ
vercel --prod
環境変数の設定
本番環境用の環境変数を .env.production に設定:
NEXT_PUBLIC_APP_URL=https://your-app.vercel.app
DATABASE_URL=your-database-url
まとめ
このチュートリアルで学んだことを振り返ってみましょう:
✅ 習得したスキル
- Next.js 15とApp Routerの基本
- Server Actionsを使ったフォーム処理
- SuspenseとStreamingによる段階的表示
- 動的ルーティング(
[slug]) - TypeScriptを使った型安全な開発
- Tailwind CSSでのレスポンシブデザイン
🚀 次のステップ
このブログアプリをさらに発展させるために、以下の機能追加に挑戦してみてください:
- 認証機能 - NextAuthまたはClerkを使用
- データベース統合 - PrismaとPostgreSQLの導入
- コメント機能 - 記事へのコメント投稿
- 管理画面 - 記事の編集・削除機能
- RSS配信 - RSS/Atomフィードの生成
- SEO強化 - structured dataとsitemap.xml
📚 参考リソース
これで Next.js 15 を使ったモダンなWebアプリケーション開発の基礎をマスターできました!実際にコードを書きながら学ぶことで、理解が深まったのではないでしょうか。
お疲れさまでした! 🎉 ぜひ今回学んだ知識を活用して、オリジナルのWebアプリケーション開発に挑戦してみてください。