Next.js 15実践:週末だけで生産性レベルのブログシステムを構築した話

はじめに
2ヶ月前の金曜日の夜のことを覚えています。会社のプロジェクトが無事リリースされ、少しリラックスして新しい技術でも学ぼうとPCを開きました。
Next.js 15がリリースされたばかりで、公式ドキュメントを読み、チュートリアル動画も見漁っていました。しかし、Server ActionsやApp Routerといった概念は頭では理解できても、「で、実際のプロジェクトでどう使うの?」というモヤモヤが晴れずにいました。
チュートリアルを見終わってブラウザを閉じた瞬間、頭が真っ白になるあの感覚、あなたにも経験がありませんか?
そこでその週末、私はチュートリアルを見るのをやめ、腕まくりをして本物のプロジェクトを作ることにしました。そう、フルスタックブログシステムです。
正直、最初は自信がありませんでした。フロントエンド、バックエンド、データベース、デプロイ……これらを全部一人で、しかも週末だけでやるなんて無謀に思えました。
しかし2日後、構築したブログがVercelに無事デプロイされ、Lighthouseのスコアで96点を叩き出した時、言葉にできない達成感を味わいました。
この記事では、その全プロセスを共有します。単なるコードのコピペ用チュートリアルではありません。「なぜその技術を選んだのか」「どこでハマったのか」という生々しい実体験をお伝えします。
使用する技術スタックは Next.js 15 + Server Actions + Prisma + PostgreSQL。これらはすべて、実際のプロダクション(本番)環境で通用する構成です。
もしあなたも週末だけでNext.jsフルスタック開発をマスターしたいなら、ぜひ私の旅に付き合ってください。
なぜNext.js 15なのか?
技術選定にはかなり悩みました。
Next.js 15が出た当初、コミュニティでは「またアプデかよ、追いつけないよ」という悲鳴が上がっていました。正直、私もNext.js 14すら完全に使いこなせていない中で、抵抗感がありました。
しかし、新機能を詳しく見ていくうちに、「これは試す価値がある」と確信しました。
Server Actions:API Routesの煩雑さからの解放
以前のフルスタック開発で一番面倒だったのが、API Routesの記述でした。api/posts/route.ts を作り、POSTメソッドを定義し、リクエストボディをパースし、レスポンスを返す……この定型作業にうんざりしていました。
Server Actionsはこのゲームのルールを完全に変えました。関数の冒頭に 'use server' と書くだけで、コンポーネントから直接その関数を呼び出せるのです。最初は「これ、バックエンドのコードをフロントに書いてるだけじゃないの?」と疑いましたが、一度使ってみると——最高でした。
例を見てください。以前ならこう書いていました:
// 昔の方法:API Routeが必要
// app/api/posts/route.ts
export async function POST(request: Request) {
const body = await request.json()
// 処理ロジック...
}
// フロントエンドでの呼び出し
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(data)
})それが今ではこうです:
// app/actions/post-actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title')
const content = formData.get('content')
// データベース操作を直接記述!
return await prisma.post.create({
data: { title, content }
})
}コンポーネント内での呼び出しは、まるでローカル関数を呼ぶのと同じ感覚です。例えるなら、**以前は自分でレストランまでテイクアウトを取りに行っていた(API Routes)のが、今はウーバーイーツが勝手に届けてくれる(Server Actions)**ようなものです。
Turbopackで開発速度が倍増
Next.js 15でTurbopackが安定版になりました。公式は「ローカルサーバー起動76.7%高速化」「コード更新96.3%高速化」と謳っていますが、これはマーケティング用語ではありません。事実でした。
私のプロジェクトは30個ほどのコンポーネントがありましたが、Webpackでは起動に7-8秒かかっていたのが、Turbopackでは2秒以内に短縮されました。コード修正後の反映も一瞬です。この体験の向上は計り知れません。
なぜブログにこのスタックなのか?
- SSR/SSGの天然の強み:ブログにとってSEOは命です。サーバーサイドレンダリングと静的生成が得意なNext.jsは、Googleのクローラーに対して最強の相性を持っています。
- Prismaの型安全性:以前Mongoose(MongoDB)を使っていた時は型定義の手動メンテが地獄でしたが、PrismaはスキーマからTypeScriptの型を自動生成してくれます。開発体験が段違いです。
- Server Actionsによる効率化:API Routesを書かなくていいので、コード量が30%は減りました。
技術スタック選定とアーキテクチャ設計
「なぜ」がわかったところで、「どうやって」の話に入りましょう。
私の完全な技術スタック
- フロントエンド: Next.js 15 + TypeScript + Tailwind CSS
- バックエンド: Next.js Server Actions + NextAuth.js
- データベース: PostgreSQL + Prisma ORM
- デプロイ: Vercel
「なんでMongoDBじゃないの?」と思うかもしれません。MongoDBの方が柔軟なのは確かですが、PrismaはPostgreSQLとの相性が抜群に良く、記事・ユーザー・コメントといった「リレーション(関係性)が明確なデータ」には、RDB(リレーショナルデータベース)の方が適していると判断しました。
ディレクトリ構造
my-blog/
├── app/
│ ├── (auth)/ # 認証関連ページ
│ ├── blog/ # ブログ関連ページ
│ │ └── [slug]/ # 動的ルーティング
│ ├── dashboard/ # 管理画面
│ ├── actions/ # Server Actionsはここに集約
│ └── api/auth/ # NextAuth設定
├── components/ # 共通コンポーネント
├── lib/
│ ├── prisma.ts # Prismaシングルトン(これ超重要!)
│ └── utils.ts # ユーティリティ
├── prisma/
│ └── schema.prisma # データベーススキーマ
└── public/ # 静的ファイル最初はServer Actionsを各ページのファイルに書いていましたが、管理しきれなくなり actions ディレクトリにまとめました。今のところこれがベストプラクティスだと感じています。
データベーススキーマ設計
ここで一度やらかしました。最初はタグ機能、カテゴリ、閲覧数カウントなど盛り込みすぎてテーブルが複雑怪奇になり、途中で破綻しました。
最終的に落ち着いた「必要十分な」Schemaはこれです:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
image String?
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String @db.Text
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([slug])
@@index([authorId])
}注目してほしいのは @@index です。ブログ記事はSlugで検索され、管理画面ではAuthorIdでフィルタリングされます。このインデックスがあるだけで、クエリ速度は何倍にもなります。
コア機能の実装詳細
さあ、コードを書いていきましょう。
3.1 環境構築と初期化
npx create-next-app@latest my-blog
cd my-blog
npm install prisma @prisma/client zod next-authPrismaの初期化:
npx prisma initこれで prisma/schema.prisma と .env が生成されます。.env にPostgreSQLの接続文字列を設定します。
ローカル開発にはDockerを使うのが手軽です:
docker run --name blog-postgres -e POSTGRES_PASSWORD=mypassword -p 5432:5432 -d postgres3.2 Prismaデータベース連携
スキーマを書いたらマイグレーションを実行:
npx prisma migrate dev --name initここで最大のトラップがあります。「Prisma Clientのシングルトン化」です。
Vercelにデプロイした時、サイトが数時間で “Too many connections” エラーを出して落ちました。原因は、Next.jsのホットリロード機能が、コード更新のたびに新しいPrisma Clientインスタンス(=新しいDB接続)を作っていたからでした。
正しい実装はこれです:
// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}このコードは「開発環境ではグローバル変数にインスタンスを保存し、再利用する」ことを保証します。これを忘れると本番環境で確実に死にます。
3.3 Server Actionsの実戦投入
記事投稿機能を作ります。
// app/actions/post-actions.ts
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
// Zodでバリデーション
const PostSchema = z.object({
title: z.string().min(1, 'タイトルは必須です').max(100),
content: z.string().min(10, '内容は10文字以上必要です'),
slug: z.string().regex(/^[a-z0-9-]+$/, 'Slugは英数字とハイフンのみです')
})
export async function createPost(formData: FormData) {
// データ検証
const validatedFields = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
slug: formData.get('slug')
})
if (!validatedFields.success) {
return { error: '入力内容に誤りがあります' }
}
try {
const post = await prisma.post.create({
data: {
...validatedFields.data,
authorId: 'user-id-here' // 本番ではセッションから取得
}
})
// キャッシュを更新して、新しい記事を即座に反映
revalidatePath('/blog')
return { success: true, post }
} catch (error) {
return { error: '投稿に失敗しました' }
}
}コンポーネント側での使用:
// app/dashboard/new-post/page.tsx
import { createPost } from '@/app/actions/post-actions'
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" placeholder="タイトル" />
<textarea name="content" placeholder="本文" />
<input name="slug" placeholder="URLスラッグ" />
<button type="submit">公開</button>
</form>
)
}見てください、このシンプルさ。useState も fetch も onSubmit もありません。フォームが直接サーバー関数を叩く。この体験は革命的です。
3.4 ユーザー認証システム
認証にはNextAuth.jsを使います。
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import GitHubProvider from 'next-auth/providers/github'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/prisma'
export const authOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GitHubProvider({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!
})
]
}
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }GitHubでOAuthアプリを作成し、IDとSecretを環境変数に入れるだけ。10分で終わります。JWT認証を自前で実装していた頃が嘘のようです。
3.5 SSR/SSG最適化戦略
最初はすべてSSR(サーバーサイドレンダリング)にしていましたが、トップページの表示が遅いことに気づきました。毎回DBアクセスが発生していたからです。
そこで混合戦略に変更しました。
ブログ一覧ページ —— SSG + ISR:
// app/blog/page.tsx
import { prisma } from '@/lib/prisma'
// 1時間に1回再生成
export const revalidate = 3600
export default async function BlogList() {
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' }
})
return (
<div>{/* 記事リスト表示 */}</div>
)
}これで、ユーザーは事前にビルドされたHTMLを受け取るだけになり、爆速になります。
ブログ詳細ページ —— 動的SSR:
// app/blog/[slug]/page.tsx
export default async function Post({ params }: { params: { slug: string } }) {
const post = await prisma.post.findUnique({
where: { slug: params.slug }
})
if (!post) notFound()
return (
<article>{/* 記事詳細表示 */}</article>
)
}こちらは常に最新の内容を表示したいので、アクセス毎にレンダリングします。
デプロイと最適化
いよいよVercelへデプロイです。GitHubリポジトリを連携するだけで自動設定されますが、環境変数の設定(DATABASE_URLなど)を忘れずに。DBはSupabaseの無料枠を使いました。
重要な最適化設定:Prismaの接続プール
Serverless環境ではDB接続数が枯渇しやすいので、Prismaの設定に directUrl を追加し、コネクションプーリングを有効にします。
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}最終的なパフォーマンス:
Lighthouseで計測した結果、デスクトップ96点、モバイル92点を記録しました。
まとめ
この週末プロジェクトで学んだこと:
- Server Actionsは本物だ:API Routesを置き換えるに十分なパワーと利便性がある。
- Prismaの型安全性は依存性がある:これに慣れると、もう他のORMには戻れない。
- SSGとSSRの使い分けが肝:全部SSRにするのではなく、適材適所でISRを使うのがNext.jsの真骨頂。
もしあなたが「フルスタック開発」という言葉に尻込みしているなら、今すぐNext.js 15で小さなプロジェクトを始めてみてください。手を動かせば、必ず理解できます。そして、自分の書いたコードが世界に公開される瞬間の喜びを、ぜひ味わってください。
Next.js 15による生産性レベルのブログ構築フロー
環境構築からServer Actionsの実装、デプロイ、パフォーマンス最適化までの完全手順
⏱️ Estimated time: 16 hr
- 1
Step1: 環境構築と初期化
1. Next.js 15プロジェクト作成: npx create-next-app@latest my-blog
2. 依存関係インストール: npm install prisma @prisma/client zod next-auth
3. Prisma初期化: npx prisma init
4. DB接続設定: .envファイルにDATABASE_URLを設定(ローカルはDocker推奨) - 2
Step2: データベースSchema設計
UserとPostモデルを定義。
Postモデルには、slugとauthorIdにインデックス(@@index)を追加し、クエリパフォーマンスを最適化する。
定義後、npx prisma migrate dev --name init でDBに反映。 - 3
Step3: Prisma Clientシングルトン設定
開発環境でのホットリロードによるDB接続数枯渇を防ぐため、lib/prisma.tsでグローバル変数を用いたシングルトンパターンを実装する。これは必須設定。 - 4
Step4: Server Actions実装
app/actions/post-actions.tsを作成し、'use server'ディレクティブを付与。
Zodで入力値を検証し、prisma.post.createでDBに保存。revalidatePathでキャッシュを更新する。 - 5
Step5: レンダリング戦略の適用
一覧ページはISR(export const revalidate = 3600)で高速化。
詳細ページは動的SSRで常に最新情報を表示。
これによりSEOとパフォーマンスを両立させる。 - 6
Step6: デプロイと最適化
VercelにGitHub経由でデプロイ。
環境変数(DATABASE_URL等)を設定。
Supabase等の外部DBを使用する場合は、PrismaでdirectUrlを設定し、コネクションプーリングを活用する。
FAQ
Server ActionsとAPI Routes、どちらを使うべき?
ただし、外部サービスからのWebhook受け取りや、複雑なHTTPヘッダー制御が必要なREST APIを提供する場合などは、従来のAPI Routesの方が適しています。
Prismaのシングルトン設定を忘れるとどうなりますか?
ISR(Incremental Static Regeneration)とは何ですか?
「ビルド時にHTMLを生成するが、設定した時間(例:1時間)が経過すると、バックグラウンドでHTMLを再生成する」という挙動になります。
SupabaseとVercel Postgres、どちらが良いですか?
4 min read · 公開日: 2025年11月24日 · 更新日: 2026年1月22日
関連記事
Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践

Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践
Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド

Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド
Next.js ユニットテスト実践:Jest + React Testing Library 完全設定ガイド


コメント
GitHubアカウントでログインしてコメントできます