はじめに
SNS(ソーシャル・ネットワーキング・サービス)は、現代のWeb開発において最も複雑で挑戦的なプロジェクトの一つです。ユーザー認証、リアルタイム更新、大量のデータ処理など、多岐にわたる技術的課題を解決する必要があります。
Next.jsは、Reactベースのフレームワークとして、SEOやパフォーマンスに優れたアプリケーションを構築するのに最適です。サーバーサイドレンダリング(SSR)、静的サイト生成(SSG)、API Routesなどの機能により、SNSプラットフォーム開発において強力な選択肢となります。
この記事では、Next.jsを使ったSNSプラットフォーム開発に必要な主要な技術、機能実装の具体例、そして開発フローを詳しく解説します。
この記事の対象読者
- Next.jsの基本を理解している開発者
- SNSのような複雑なWebアプリケーションを構築したい方
- フルスタック開発に興味がある方
SNSプラットフォームに必要な機能
SNS開発では、以下の機能が一般的に求められます。
1. ユーザー認証・認可
ユーザーのサインアップ、ログイン、セッション管理は、SNSの基盤となる機能です。NextAuth.jsを使用すると、簡単に実装できます。
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export const authOptions = {
// データベースアダプター(ユーザー情報の永続化)
adapter: PrismaAdapter(prisma),
providers: [
// Googleログイン
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
// メール・パスワード認証
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("メールアドレスとパスワードを入力してください");
}
// ユーザーをデータベースから検索
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
if (!user || !user.hashedPassword) {
throw new Error("ユーザーが見つかりません");
}
// パスワードの検証
const isValid = await bcrypt.compare(
credentials.password,
user.hashedPassword
);
if (!isValid) {
throw new Error("パスワードが正しくありません");
}
return user;
},
}),
],
session: {
strategy: "jwt", // JWTベースのセッション管理
},
pages: {
signIn: "/login", // カスタムログインページ
error: "/auth/error", // エラーページ
},
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
2. 投稿作成・編集
ユーザーがテキストや画像を投稿し、編集できる機能です。Next.jsのServer Actionsを使用すると、型安全な実装が可能です。
// app/actions/post.ts
"use server";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { revalidatePath } from "next/cache";
import { z } from "zod";
// バリデーションスキーマ
const postSchema = z.object({
content: z
.string()
.min(1, "投稿内容を入力してください")
.max(280, "280文字以内で入力してください"),
imageUrl: z.string().url().optional(),
});
// 投稿作成アクション
export async function createPost(formData: FormData) {
// セッションからユーザー情報を取得
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return { error: "ログインが必要です" };
}
// フォームデータの取得とバリデーション
const rawData = {
content: formData.get("content") as string,
imageUrl: formData.get("imageUrl") as string | undefined,
};
const validatedData = postSchema.safeParse(rawData);
if (!validatedData.success) {
return { error: validatedData.error.errors[0].message };
}
try {
// データベースに投稿を保存
const post = await prisma.post.create({
data: {
content: validatedData.data.content,
imageUrl: validatedData.data.imageUrl,
authorId: session.user.id,
},
include: {
author: {
select: { id: true, name: true, image: true },
},
},
});
// キャッシュの再検証(フィードを更新)
revalidatePath("/");
revalidatePath("/profile");
return { success: true, post };
} catch (error) {
console.error("投稿作成エラー:", error);
return { error: "投稿の作成に失敗しました" };
}
}
// 投稿編集アクション
export async function updatePost(postId: string, formData: FormData) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return { error: "ログインが必要です" };
}
// 投稿の所有者確認
const existingPost = await prisma.post.findUnique({
where: { id: postId },
});
if (!existingPost || existingPost.authorId !== session.user.id) {
return { error: "この投稿を編集する権限がありません" };
}
const content = formData.get("content") as string;
const validatedData = postSchema.safeParse({ content });
if (!validatedData.success) {
return { error: validatedData.error.errors[0].message };
}
try {
const updatedPost = await prisma.post.update({
where: { id: postId },
data: { content: validatedData.data.content },
});
revalidatePath("/");
return { success: true, post: updatedPost };
} catch (error) {
console.error("投稿更新エラー:", error);
return { error: "投稿の更新に失敗しました" };
}
}
3. コメント機能
投稿に対して他のユーザーがコメントを残せる機能です。
// app/actions/comment.ts
"use server";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { revalidatePath } from "next/cache";
export async function createComment(postId: string, content: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return { error: "ログインが必要です" };
}
if (!content.trim()) {
return { error: "コメント内容を入力してください" };
}
try {
const comment = await prisma.comment.create({
data: {
content: content.trim(),
postId,
authorId: session.user.id,
},
include: {
author: {
select: { id: true, name: true, image: true },
},
},
});
// 投稿詳細ページのキャッシュを更新
revalidatePath(`/post/${postId}`);
return { success: true, comment };
} catch (error) {
console.error("コメント作成エラー:", error);
return { error: "コメントの作成に失敗しました" };
}
}
4. いいね機能
投稿に対する「いいね」機能は、SNSの核となるエンゲージメント機能です。
// app/actions/like.ts
"use server";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { revalidatePath } from "next/cache";
export async function toggleLike(postId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return { error: "ログインが必要です" };
}
try {
// 既存のいいねを確認
const existingLike = await prisma.like.findUnique({
where: {
// 複合ユニークキー
userId_postId: {
userId: session.user.id,
postId,
},
},
});
if (existingLike) {
// いいねを取り消し
await prisma.like.delete({
where: { id: existingLike.id },
});
revalidatePath("/");
return { success: true, liked: false };
} else {
// いいねを追加
await prisma.like.create({
data: {
userId: session.user.id,
postId,
},
});
revalidatePath("/");
return { success: true, liked: true };
}
} catch (error) {
console.error("いいね処理エラー:", error);
return { error: "処理に失敗しました" };
}
}
5. フォロー機能
ユーザー間のフォロー・フォロワー関係を管理します。
// app/actions/follow.ts
"use server";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { revalidatePath } from "next/cache";
export async function toggleFollow(targetUserId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return { error: "ログインが必要です" };
}
// 自分自身をフォローすることはできない
if (session.user.id === targetUserId) {
return { error: "自分自身をフォローすることはできません" };
}
try {
const existingFollow = await prisma.follow.findUnique({
where: {
followerId_followingId: {
followerId: session.user.id,
followingId: targetUserId,
},
},
});
if (existingFollow) {
// フォロー解除
await prisma.follow.delete({
where: { id: existingFollow.id },
});
revalidatePath(`/profile/${targetUserId}`);
return { success: true, following: false };
} else {
// フォロー
await prisma.follow.create({
data: {
followerId: session.user.id,
followingId: targetUserId,
},
});
revalidatePath(`/profile/${targetUserId}`);
return { success: true, following: true };
}
} catch (error) {
console.error("フォロー処理エラー:", error);
return { error: "処理に失敗しました" };
}
}
6. プロフィール管理
ユーザーが自分のプロフィール情報を編集できる機能です。
// app/actions/profile.ts
"use server";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const profileSchema = z.object({
name: z.string().min(1, "名前を入力してください").max(50),
bio: z.string().max(160, "160文字以内で入力してください").optional(),
location: z.string().max(30).optional(),
website: z.string().url().optional().or(z.literal("")),
});
export async function updateProfile(formData: FormData) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return { error: "ログインが必要です" };
}
const rawData = {
name: formData.get("name") as string,
bio: formData.get("bio") as string,
location: formData.get("location") as string,
website: formData.get("website") as string,
};
const validatedData = profileSchema.safeParse(rawData);
if (!validatedData.success) {
return { error: validatedData.error.errors[0].message };
}
try {
await prisma.user.update({
where: { id: session.user.id },
data: {
name: validatedData.data.name,
bio: validatedData.data.bio || null,
location: validatedData.data.location || null,
website: validatedData.data.website || null,
},
});
revalidatePath(`/profile/${session.user.id}`);
revalidatePath("/settings");
return { success: true };
} catch (error) {
console.error("プロフィール更新エラー:", error);
return { error: "プロフィールの更新に失敗しました" };
}
}
データベース設計
SNSプラットフォームのデータベース設計は、スケーラビリティを考慮して行う必要があります。Prismaを使用したスキーマ例を示します。
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ユーザーモデル
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
hashedPassword String?
bio String?
location String?
website String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// リレーション
accounts Account[]
sessions Session[]
posts Post[]
comments Comment[]
likes Like[]
followers Follow[] @relation("Following")
following Follow[] @relation("Follower")
}
// 投稿モデル
model Post {
id String @id @default(cuid())
content String
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorId String
// リレーション
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
comments Comment[]
likes Like[]
@@index([authorId])
@@index([createdAt])
}
// コメントモデル
model Comment {
id String @id @default(cuid())
content String
createdAt DateTime @default(now())
postId String
authorId String
// リレーション
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
@@index([postId])
@@index([authorId])
}
// いいねモデル
model Like {
id String @id @default(cuid())
createdAt DateTime @default(now())
userId String
postId String
// リレーション
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@unique([userId, postId]) // 同じユーザーが同じ投稿に複数回いいねできない
@@index([postId])
}
// フォローモデル
model Follow {
id String @id @default(cuid())
createdAt DateTime @default(now())
followerId String
followingId String
// リレーション
follower User @relation("Follower", fields: [followerId], references: [id], onDelete: Cascade)
following User @relation("Following", fields: [followingId], references: [id], onDelete: Cascade)
@@unique([followerId, followingId]) // 同じユーザーを複数回フォローできない
@@index([followerId])
@@index([followingId])
}
// NextAuth.js用のモデル
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
フロントエンドコンポーネント
UIコンポーネントの実装例を紹介します。
投稿フォームコンポーネント
// components/PostForm.tsx
"use client";
import { useState, useTransition } from "react";
import { createPost } from "@/app/actions/post";
import { useSession } from "next-auth/react";
export function PostForm() {
const { data: session } = useSession();
const [content, setContent] = useState("");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
// 未ログイン時は何も表示しない
if (!session) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const formData = new FormData();
formData.append("content", content);
startTransition(async () => {
const result = await createPost(formData);
if (result.error) {
setError(result.error);
} else {
setContent(""); // フォームをリセット
}
});
};
const remainingChars = 280 - content.length;
return (
<form onSubmit={handleSubmit} className="border-b border-gray-200 p-4">
<div className="flex gap-3">
{/* ユーザーアバター */}
<img
src={session.user?.image || "/default-avatar.png"}
alt="アバター"
className="h-12 w-12 rounded-full"
/>
<div className="flex-1">
{/* 投稿入力エリア */}
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="いまどうしてる?"
className="w-full resize-none border-none p-2 text-xl focus:outline-none"
rows={3}
maxLength={280}
/>
{/* エラーメッセージ */}
{error && (
<p className="mt-2 text-sm text-red-500">{error}</p>
)}
{/* フッター */}
<div className="flex items-center justify-between border-t pt-3">
{/* 文字数カウンター */}
<span
className={`text-sm ${
remainingChars < 20 ? "text-red-500" : "text-gray-500"
}`}
>
{remainingChars}
</span>
{/* 投稿ボタン */}
<button
type="submit"
disabled={!content.trim() || isPending || remainingChars < 0}
className="rounded-full bg-blue-500 px-4 py-2 font-bold text-white
hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? "投稿中..." : "投稿する"}
</button>
</div>
</div>
</div>
</form>
);
}
投稿カードコンポーネント
// components/PostCard.tsx
"use client";
import { useState, useTransition } from "react";
import { formatDistanceToNow } from "date-fns";
import { ja } from "date-fns/locale";
import { Heart, MessageCircle, Share, MoreHorizontal } from "lucide-react";
import { toggleLike } from "@/app/actions/like";
import Link from "next/link";
interface PostCardProps {
post: {
id: string;
content: string;
imageUrl?: string | null;
createdAt: Date;
author: {
id: string;
name: string | null;
image: string | null;
};
_count: {
likes: number;
comments: number;
};
isLiked: boolean;
};
}
export function PostCard({ post }: PostCardProps) {
const [isLiked, setIsLiked] = useState(post.isLiked);
const [likeCount, setLikeCount] = useState(post._count.likes);
const [isPending, startTransition] = useTransition();
const handleLike = () => {
// 楽観的UI更新
setIsLiked(!isLiked);
setLikeCount(isLiked ? likeCount - 1 : likeCount + 1);
startTransition(async () => {
const result = await toggleLike(post.id);
// エラー時は元に戻す
if (result.error) {
setIsLiked(isLiked);
setLikeCount(likeCount);
}
});
};
return (
<article className="border-b border-gray-200 p-4 hover:bg-gray-50">
<div className="flex gap-3">
{/* ユーザーアバター */}
<Link href={`/profile/${post.author.id}`}>
<img
src={post.author.image || "/default-avatar.png"}
alt={post.author.name || "ユーザー"}
className="h-12 w-12 rounded-full"
/>
</Link>
<div className="flex-1">
{/* ヘッダー */}
<div className="flex items-center gap-2">
<Link
href={`/profile/${post.author.id}`}
className="font-bold hover:underline"
>
{post.author.name || "名無し"}
</Link>
<span className="text-gray-500">
{formatDistanceToNow(new Date(post.createdAt), {
addSuffix: true,
locale: ja,
})}
</span>
<button className="ml-auto text-gray-500 hover:text-blue-500">
<MoreHorizontal size={20} />
</button>
</div>
{/* 投稿本文 */}
<Link href={`/post/${post.id}`}>
<p className="mt-2 whitespace-pre-wrap">{post.content}</p>
</Link>
{/* 画像 */}
{post.imageUrl && (
<img
src={post.imageUrl}
alt="投稿画像"
className="mt-3 max-h-96 rounded-2xl object-cover"
/>
)}
{/* アクションボタン */}
<div className="mt-3 flex justify-between text-gray-500">
{/* コメント */}
<Link
href={`/post/${post.id}`}
className="flex items-center gap-2 hover:text-blue-500"
>
<MessageCircle size={20} />
<span>{post._count.comments}</span>
</Link>
{/* いいね */}
<button
onClick={handleLike}
disabled={isPending}
className={`flex items-center gap-2 transition-colors ${
isLiked ? "text-red-500" : "hover:text-red-500"
}`}
>
<Heart size={20} fill={isLiked ? "currentColor" : "none"} />
<span>{likeCount}</span>
</button>
{/* シェア */}
<button className="flex items-center gap-2 hover:text-green-500">
<Share size={20} />
</button>
</div>
</div>
</div>
</article>
);
}
使用する主要技術
SNSプラットフォーム開発で使用する主要な技術スタックを整理します。
| カテゴリ | 技術 | 用途 |
|---|---|---|
| フレームワーク | Next.js 14+ (App Router) | SSR/SSG/API Routes |
| 言語 | TypeScript | 型安全な開発 |
| スタイリング | Tailwind CSS | ユーティリティファーストCSS |
| 認証 | NextAuth.js | ユーザー認証・セッション管理 |
| ORM | Prisma | データベース操作 |
| データベース | PostgreSQL | リレーショナルDB |
| バリデーション | Zod | スキーマバリデーション |
| 状態管理 | React Server Components + Client Components | サーバー/クライアント状態の分離 |
| リアルタイム | Pusher / Socket.io | リアルタイム通知 |
| ファイルストレージ | AWS S3 / Cloudflare R2 | 画像・動画のアップロード |
| デプロイ | Vercel | ホスティング・CI/CD |
開発フロー
1. プロジェクトのセットアップ
# Next.jsプロジェクトの作成
npx create-next-app@latest my-sns --typescript --tailwind --app
# 依存パッケージのインストール
cd my-sns
npm install next-auth @auth/prisma-adapter prisma @prisma/client
npm install zod bcryptjs date-fns lucide-react
npm install -D @types/bcryptjs
# Prismaの初期化
npx prisma init
2. 環境変数の設定
# .env.local
DATABASE_URL="postgresql://user:password@localhost:5432/sns_db"
NEXTAUTH_SECRET="your-secret-key"
NEXTAUTH_URL="http://localhost:3000"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
3. データベースのマイグレーション
# スキーマの適用
npx prisma migrate dev --name init
# Prisma Clientの生成
npx prisma generate
# 開発用GUIの起動
npx prisma studio
4. 開発サーバーの起動
npm run dev
5. デプロイ
Vercelを使用すると、GitHubリポジトリと連携して自動デプロイが可能です。
# Vercel CLIのインストール
npm install -g vercel
# デプロイ
vercel
パフォーマンス最適化のポイント
1. データ取得の最適化
// 無限スクロールの実装例
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const cursor = searchParams.get("cursor");
const limit = 20;
const posts = await prisma.post.findMany({
take: limit + 1, // 次のページがあるか確認するため+1
...(cursor && {
cursor: { id: cursor },
skip: 1, // カーソルの投稿をスキップ
}),
orderBy: { createdAt: "desc" },
include: {
author: {
select: { id: true, name: true, image: true },
},
_count: {
select: { likes: true, comments: true },
},
},
});
// 次のページがあるかどうか
const hasMore = posts.length > limit;
const data = hasMore ? posts.slice(0, -1) : posts;
const nextCursor = hasMore ? data[data.length - 1].id : null;
return NextResponse.json({
posts: data,
nextCursor,
});
}
2. 画像の最適化
// Next.jsのImageコンポーネントを使用
import Image from "next/image";
<Image
src={post.imageUrl}
alt="投稿画像"
width={600}
height={400}
className="rounded-2xl"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..." // ぼかしプレースホルダー
/>
3. キャッシュ戦略
// 静的データのキャッシュ
// app/user/[id]/page.tsx
export const revalidate = 60; // 60秒間キャッシュ
export async function generateStaticParams() {
const users = await prisma.user.findMany({
select: { id: true },
take: 100,
});
return users.map((user) => ({
id: user.id,
}));
}
まとめ
Next.jsを使ったSNSプラットフォームの開発は、App Routerの強力な機能を活用することで、パフォーマンスとSEOに優れたアプリケーションを構築できます。
主要なポイントをまとめると以下の通りです。
- Server Actionsを活用して型安全なデータ操作
- NextAuth.jsによる堅牢な認証システム
- Prismaを使った効率的なデータベース操作
- 楽観的UI更新によるユーザー体験の向上
- キャッシュ戦略によるパフォーマンス最適化
SNS開発は複雑ですが、Next.jsのエコシステムを活用することで、スケーラブルで保守性の高いアプリケーションを効率的に構築できます。
参考文献
- Next.js公式ドキュメント - App Router、Server Actions、データフェッチングの詳細
- NextAuth.js公式ドキュメント - 認証の実装ガイド
- Prisma公式ドキュメント - ORMとデータベース設計
- Tailwind CSS公式ドキュメント - スタイリングリファレンス
- Zod公式ドキュメント - スキーマバリデーション
- Vercel公式ドキュメント - デプロイとホスティング