Documentation Next.js

はじめに

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ユーザー認証・セッション管理
ORMPrismaデータベース操作
データベース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に優れたアプリケーションを構築できます。

主要なポイントをまとめると以下の通りです。

  1. Server Actionsを活用して型安全なデータ操作
  2. NextAuth.jsによる堅牢な認証システム
  3. Prismaを使った効率的なデータベース操作
  4. 楽観的UI更新によるユーザー体験の向上
  5. キャッシュ戦略によるパフォーマンス最適化

SNS開発は複雑ですが、Next.jsのエコシステムを活用することで、スケーラブルで保守性の高いアプリケーションを効率的に構築できます。

参考文献

円