🦋

Twitter風自作SNSにチャレンジしよう! - 最新技術で学ぶ実践的Web開発

に公開

はじめに

「自分でSNSを作ってみたい」と思ったことはありませんか?

Twitter(現X)のような本格的なSNSを自作することは、Web開発の主要な要素を一通り実践できる、学習題材として非常に優れたテーマです。

この記事では、

  • なぜSNS制作が学習に向いているのか
  • どんな技術を選べばよいのか
  • どのようなステップで進めればよいのか

を、筆者の実体験を交えながら解説します。

筆者が個人開発で実装した機能は、たとえば以下のようなものです。

  • 投稿、いいね、リプライ、リツイート
  • リアルタイムチャット
  • ユーザープロフィール・フォロー機能
  • トレンド表示
  • 通知システム
  • 画像・動画アップロード
  • Mastodonと連携する分散型SNS機能(ActivityPub対応)

本記事は、「今すぐコピペで動くコード」よりも、「どう考えて設計するか」に重点を置いています。
そのため、設計の考え方や技術選定の軸を知りたい方に特に向いています。


なぜSNSを自作すべきなのか

1. Web開発に必要な要素が一通り揃っている

SNSには、Webアプリ開発でよく登場する技術要素がほぼすべて含まれています。

データベース設計

  • 複雑なリレーション
    ユーザー、投稿、フォロー関係、いいね、コメントなど
  • 多対多の関係
    フォロー・フォロワー、ブロック関係など
  • 自己参照
    返信ツリー(コメントのコメント)
  • パフォーマンス設計
    数万〜数百万件のデータの扱いを意識した設計
User ←→ Post ←→ Like
  ↓       ↓       ↓
Follow  Reply  Bookmark

バックエンド開発

  • RESTful APIの設計
  • 認証・認可
  • ファイルアップロード(画像・動画)
  • リアルタイム通信(WebSocket / SSE)
  • バックグラウンド処理(通知、トレンド集計、キュー処理など)

フロントエンド開発

  • SPA(Single Page Application)
  • 無限スクロール
  • リアルタイム更新
  • レスポンシブデザイン
  • 画像のプレビュー・トリミング、ドラッグ&ドロップ

インフラ・デプロイ

  • データベースのスケーリング
  • CDNの活用
  • キャッシュ戦略(APIレスポンス・画像・HTML)
  • 負荷分散、障害に強い構成

1つのプロジェクトで、ここまで幅広い領域を一気に学べる題材は多くありません。

2. 段階的に成長させやすい

SNSは「小さく始めて大きく育てる」ことがしやすいアプリケーションです。

フェーズ1: MVP(最小機能)

  • ユーザー登録・ログイン
  • 投稿の作成・表示
  • タイムライン表示(最新順)

フェーズ2: ソーシャル機能

  • フォロー・フォロワー
  • いいね・リツイート(リポスト)
  • プロフィール編集

フェーズ3: 高度な機能

  • 通知機能(いいね・フォロー・リプライ・メンション)
  • トレンド表示
  • 検索(ユーザー・投稿)
  • DM(ダイレクトメッセージ)

フェーズ4: スケールアップ

  • パフォーマンス最適化
  • AI機能(レコメンド、モデレーションなど)
  • 分散型SNS対応(ActivityPub / Fediverse)

最初は**「ログインできて投稿できるだけ」**でも十分です。そこから少しずつ機能を増やしていくことで、挫折しにくくなります。

3. ポートフォリオとして強力

「Next.jsでTwitterクローンを作りました」は、強いアピール材料になります。

  • 実務に近い開発経験
    チュートリアルではなく、要件定義〜設計〜実装〜デプロイまでを自分でこなした経験になる
  • 技術スタックの広さ
    フロント・バックエンド・インフラ・DB・認証など、一通り触れる
  • 成果物がわかりやすい
    実際に触れるURLやスクリーンショットを見せやすい

技術スタックの選び方

ここからは、2025年時点での「個人でSNSを作るならこれ」というおすすめスタックを紹介します。
もちろんこれが唯一の正解ではありませんが、**「迷ったらこれにしておけば無難」**というラインナップです。

フロントエンド

初学者〜中級者向け: Next.js + TypeScript

{
  "framework": "Next.js 15+",
  "language": "TypeScript",
  "styling": "Tailwind CSS",
  "state": "useState / useContext",
  "data-fetching": "Next.js App Router"
}

おすすめ理由

  • Next.jsはReactベースのフレームワークで、ルーティングやSSR、APIルートなどが最初から揃っている
  • TypeScriptで型安全に開発できるので、規模が大きくなっても破綻しにくい
  • Tailwind CSSでUIを素早く組み立てられる(デザインが苦手でも形にしやすい)

学習コスト

  • Reactの基礎があれば、1〜2週間で「とりあえず作れる」レベルになれる

中級者向け: 状態管理とフォーム周り

{
  "state": "Jotai / Zustand",
  "data-fetching": "TanStack Query (React Query)",
  "forms": "React Hook Form",
  "validation": "Zod"
}

ポイント

  • Jotai / Zustand: シンプルなAPIでグローバル状態管理ができる
  • React Query: サーバーデータのフェッチ・キャッシュ・再取得をよしなにやってくれる
  • React Hook Form + Zod: バリデーションとフォーム管理の相性がよい

最初から全部入れる必要はなく、必要になったときに導入するで十分です。

バックエンド

選択肢1: Next.js API Routes(いちばん手軽)

フロントエンドと同じコードベースでAPIを実装するパターンです。

// app/api/posts/route.ts
export async function GET(request: Request) {
  const posts = await db.post.findMany();
  return Response.json(posts);
}

export async function POST(request: Request) {
  const body = await request.json();
  const post = await db.post.create({ data: body });
  return Response.json(post);
}

メリット

  • フロントとバックエンドが同じリポジトリで完結
  • デプロイが簡単(Vercelなど)
  • TypeScriptの型を共有しやすい

デメリット

  • 大規模・複雑なバックエンドになると管理が難しくなりがち
  • バッチ処理やキューなどを多用する構成にはやや不向き

選択肢2: Node.js + Express(定番スタイル)

const express = require('express');
const app = express();

app.get('/api/posts', async (req, res) => {
  const posts = await db.post.findMany();
  res.json(posts);
});

app.post('/api/posts', async (req, res) => {
  const post = await db.post.create({ data: req.body });
  res.json(post);
});

メリット

  • 歴史が長く、情報やライブラリが非常に豊富
  • フレームワークに縛られず、自由度が高い

デメリット

  • Next.jsと別リポジトリ・別デプロイになることが多く、初心者には少しハードルが高い
  • セットアップ・環境構築の手間が増える

選択肢3: エッジランタイム(上級者向け)

Cloudflare Workers、Deno Deploy、Vercel Edge Functionsなど。

メリット

  • 世界中どこからでも低レイテンシ
  • サーバーレスでスケールしやすく、従量課金で低コストになりやすい

デメリット

  • Node.js APIがそのまま使えない場合がある
  • 学習コストが高く、最初の一歩にはやや不向き

データベース

初心者向け: PostgreSQL + Prisma

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  posts     Post[]
  following Follow[] @relation("follower")
  followers Follow[] @relation("following")
}

model Post {
  id        String   @id @default(cuid())
  content   String
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  likes     Like[]
  createdAt DateTime @default(now())
}

model Follow {
  id          String @id @default(cuid())
  followerId  String
  followingId String
  follower    User   @relation("follower", fields: [followerId], references: [id])
  following   User   @relation("following", fields: [followingId], references: [id])

  @@unique([followerId, followingId])
}

おすすめ理由

  • Prismaの型安全なORMにより、SQLを直接書かずにDB操作ができる
  • スキーマファイルでDB定義を管理でき、マイグレーションも簡単
  • PostgreSQLは機能が豊富で、個人開発〜中規模サービスにかなり向いている

無料で使いやすいホスティング例

  • Supabase(PostgreSQL + 認証・ストレージ)
  • Neon(サーバーレスPostgreSQL)
  • Railway

中級者向け: MySQL + PlanetScale

MySQLをベースにした分散型DBで、ブランチ機能が強力です。
スキーマ変更の検証やロールバックをしやすいのが特徴です。

上級者向け: 分散DB

  • TiDB Cloud(MySQL互換の分散DB)
  • CockroachDB(PostgreSQL互換)

大規模トラフィックを想定する場合や、分散システムの学習にも向いています。

認証

NextAuth.js / Auth.js

import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";

export const { handlers, auth } = NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_ID,
      clientSecret: process.env.GOOGLE_SECRET,
    }),
    CredentialsProvider({
      credentials: {
        email: {},
        password: {},
      },
      authorize: async (credentials) => {
        const user = await db.user.findUnique({
          where: { email: credentials.email }
        });
        if (user && await bcrypt.compare(credentials.password, user.passwordHash)) {
          return user;
        }
        return null;
      },
    }),
  ],
});

メリット

  • Google、Twitter、GitHubなど主要なOAuthに簡単対応
  • セッション管理やCSRF対策がライブラリ側で面倒を見てくれる
  • 公式ドキュメントや記事が豊富で、情報を見つけやすい

よりモダンな選択肢: better-auth(おすすめ) / Lucia など

APIがシンプルで、よりミニマルに構成したい場合に向いています。
特にbetter-authがおすすめです。

ファイルストレージ

初心者向け: Cloudflare R2(S3互換ストレージ)

// アップロード例(フロント)
const uploadFile = async (file: File) => {
  const formData = new FormData();
  formData.append('file', file);

  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData,
  });

  const { url } = await response.json();
  return url;
};

主な選択肢と無料枠のイメージ

  • Cloudflare R2: 無料枠が大きく、転送料も安価
  • AWS S3: 実務でもよく使われる標準的なストレージ
  • Vercel Blob: Next.jsとの相性が良い

段階的な実装ガイド

ここからは、実際にどのようなステップでSNSを作っていくか、フェーズごとに見ていきます。

Phase 1: MVP(最小機能版)を作る

目標

  • ユーザー登録・ログイン
  • 投稿の作成・表示
  • タイムラインの表示(全ユーザー共通でもOK)

データベース設計(最小版)

model User {
  id           String   @id @default(cuid())
  email        String   @unique
  username     String   @unique
  passwordHash String
  name         String
  avatarUrl    String?
  createdAt    DateTime @default(now())
  posts        Post[]
}

model Post {
  id        String   @id @default(cuid())
  content   String   @db.Text
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([authorId])
  @@index([createdAt])
}

API設計(例)

POST   /api/auth/register  - ユーザー登録
POST   /api/auth/login     - ログイン
GET    /api/posts          - 投稿一覧取得
POST   /api/posts          - 投稿作成
GET    /api/posts/[id]     - 投稿詳細
DELETE /api/posts/[id]     - 投稿削除

実装のポイント

1. パスワードのハッシュ化は必須

import bcrypt from 'bcryptjs';

// 登録時
const passwordHash = await bcrypt.hash(password, 10);

// ログイン時
const isValid = await bcrypt.compare(password, user.passwordHash);

2. 投稿作成API

// app/api/posts/route.ts
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';

export async function POST(request: Request) {
  const session = await auth();
  if (!session) {
    return new Response('Unauthorized', { status: 401 });
  }

  const { content } = await request.json();

  if (!content || content.length > 280) {
    return new Response('Invalid content', { status: 400 });
  }

  const post = await prisma.post.create({
    data: {
      content,
      authorId: session.user.id,
    },
    include: {
      author: {
        select: {
          id: true,
          name: true,
          username: true,
          avatarUrl: true,
        }
      }
    }
  });

  return Response.json(post);
}

3. タイムライン取得API(カーソルページネーション)

// app/api/posts/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const cursor = searchParams.get('cursor');

  const posts = await prisma.post.findMany({
    take: 20,
    ...(cursor && {
      skip: 1,
      cursor: { id: cursor },
    }),
    orderBy: {
      createdAt: 'desc',
    },
    include: {
      author: {
        select: {
          id: true,
          name: true,
          username: true,
          avatarUrl: true,
        }
      }
    }
  });

  return Response.json({
    posts,
    nextCursor: posts.length === 20 ? posts[posts.length - 1].id : null,
  });
}

4. フロントエンドの投稿フォーム

'use client';

import { useState } from 'react';

export function PostComposer() {
  const [content, setContent] = useState('');
  const [isPosting, setIsPosting] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!content) return;

    setIsPosting(true);

    try {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content }),
      });

      if (response.ok) {
        setContent('');
        // MVP段階ではリロードでもOK。慣れたらReact Queryなどに変更。
        window.location.reload();
      } else {
        alert('投稿に失敗しました');
      }
    } catch (error) {
      alert('エラーが発生しました');
    } finally {
      setIsPosting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="border rounded-lg p-4">
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="いまどうしてる?"
        maxLength={280}
        className="w-full resize-none outline-none"
        rows={3}
      />
      <div className="flex justify-between items-center mt-2">
        <span className="text-sm text-gray-500">
          {content.length}/280
        </span>
        <button
          type="submit"
          disabled={!content || isPosting}
          className="bg-blue-500 text-white px-4 py-2 rounded-full disabled:opacity-50"
        >
          投稿
        </button>
      </div>
    </form>
  );
}

ここまでで身につくこと

  • PrismaでのDB操作の基本
  • Next.js App RouterでのAPI実装
  • 認証とセッションの扱い
  • フロントエンドでのフォーム処理とバリデーション

Phase 2: ソーシャル機能を追加する

目標

  • フォロー・フォロワー機能
  • いいね機能
  • リプライ機能
  • プロフィールページ

データベース拡張

model Follow {
  id          String   @id @default(cuid())
  followerId  String
  followingId String
  follower    User     @relation("follower", fields: [followerId], references: [id], onDelete: Cascade)
  following   User     @relation("following", fields: [followingId], references: [id], onDelete: Cascade)
  createdAt   DateTime @default(now())

  @@unique([followerId, followingId])
  @@index([followerId])
  @@index([followingId])
}

model Like {
  id        String   @id @default(cuid())
  userId    String
  postId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())

  @@unique([userId, postId])
  @@index([postId])
}

model Post {
  id        String   @id @default(cuid())
  content   String   @db.Text
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  parentId  String?  // 返信先の投稿ID
  parent    Post?    @relation("replies", fields: [parentId], references: [id])
  replies   Post[]   @relation("replies")
  likes     Like[]

  @@index([authorId])
  @@index([createdAt])
}

フォロー機能

// app/api/users/[userId]/follow/route.ts
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';

export async function POST(
  request: Request,
  { params }: { params: { userId: string } }
) {
  const session = await auth();
  if (!session) {
    return new Response('Unauthorized', { status: 401 });
  }

  const targetUserId = params.userId;

  if (targetUserId === session.user.id) {
    return new Response('Cannot follow yourself', { status: 400 });
  }

  const existing = await prisma.follow.findUnique({
    where: {
      followerId_followingId: {
        followerId: session.user.id,
        followingId: targetUserId,
      }
    }
  });

  if (existing) {
    return new Response('Already following', { status: 400 });
  }

  await prisma.follow.create({
    data: {
      followerId: session.user.id,
      followingId: targetUserId,
    }
  });

  return Response.json({ success: true });
}

export async function DELETE(
  request: Request,
  { params }: { params: { userId: string } }
) {
  const session = await auth();
  if (!session) {
    return new Response('Unauthorized', { status: 401 });
  }

  await prisma.follow.delete({
    where: {
      followerId_followingId: {
        followerId: session.user.id,
        followingId: params.userId,
      }
    }
  });

  return Response.json({ success: true });
}

いいね機能

// app/api/posts/[postId]/like/route.ts
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';

export async function POST(
  request: Request,
  { params }: { params: { postId: string } }
) {
  const session = await auth();
  if (!session) {
    return new Response('Unauthorized', { status: 401 });
  }

  const like = await prisma.like.create({
    data: {
      userId: session.user.id,
      postId: params.postId,
    }
  });

  return Response.json(like);
}

export async function DELETE(
  request: Request,
  { params }: { params: { postId: string } }
) {
  const session = await auth();
  if (!session) {
    return new Response('Unauthorized', { status: 401 });
  }

  await prisma.like.delete({
    where: {
      userId_postId: {
        userId: session.user.id,
        postId: params.postId,
      }
    }
  });

  return Response.json({ success: true });
}

フォローユーザー専用タイムライン

// app/api/posts/timeline/route.ts
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';

export async function GET(request: Request) {
  const session = await auth();
  if (!session) {
    return new Response('Unauthorized', { status: 401 });
  }

  const following = await prisma.follow.findMany({
    where: { followerId: session.user.id },
    select: { followingId: true }
  });

  const userIds = [
    session.user.id,
    ...following.map(f => f.followingId)
  ];

  const posts = await prisma.post.findMany({
    where: {
      authorId: { in: userIds }
    },
    take: 20,
    orderBy: { createdAt: 'desc' },
    include: {
      author: true,
      likes: {
        where: { userId: session.user.id },
        select: { id: true }
      },
      _count: {
        select: {
          likes: true,
          replies: true,
        }
      }
    }
  });

  return Response.json(posts);
}

Phase 3: リアルタイム機能を追加する

通知やDMなどをリアルタイムに届けたい場合は、WebSocketやSSEを使います。

WebSocketでの通知配信(概念図)

// lib/websocket.ts
import { Server } from 'socket.io';

export function initWebSocket(server: any) {
  const io = new Server(server);

  io.on('connection', (socket) => {
    const userId = socket.handshake.auth.userId;

    // ユーザーごとのルームに参加させる
    socket.join(`user:${userId}`);

    socket.on('disconnect', () => {
      console.log('User disconnected:', userId);
    });
  });

  return io;
}

export function sendNotification(io: Server, userId: string, notification: any) {
  io.to(`user:${userId}`).emit('notification', notification);
}

フロントエンド側(カスタムフック例):

'use client';

import { useEffect, useState } from 'react';
import { io } from 'socket.io-client';

export function useNotifications(userId: string) {
  const [notifications, setNotifications] = useState<any[]>([]);

  useEffect(() => {
    const socket = io({
      auth: { userId }
    });

    socket.on('notification', (notification) => {
      setNotifications(prev => [notification, ...prev]);
    });

    return () => socket.disconnect();
  }, [userId]);

  return notifications;
}

VercelなどWebSocketが使いづらい環境では、Pusher、Ably、Supabase Realtimeなどのマネージドサービスを使うのも手です。


Phase 4: 画像アップロード

画像付き投稿を実装すると、見た目が一気に「SNSっぽく」なります。

// app/api/upload/route.ts
import { put } from '@vercel/blob';

export async function POST(request: Request) {
  const formData = await request.formData();
  const file = formData.get('file') as File | null;

  if (!file) {
    return new Response('No file', { status: 400 });
  }

  // ファイルサイズ制限(例: 5MB)
  if (file.size > 5 * 1024 * 1024) {
    return new Response('File too large', { status: 400 });
  }

  // MIMEタイプチェック
  if (!file.type.startsWith('image/')) {
    return new Response('Not an image', { status: 400 });
  }

  const blob = await put(file.name, file, {
    access: 'public',
  });

  return Response.json({ url: blob.url });
}

画像と投稿を紐づけるスキーマ例:

model Asset {
  id        String   @id @default(cuid())
  url       String
  type      String   // 'image' | 'video'
  postId    String
  post      Post     @relation(fields: [postId], references: [id])
  createdAt DateTime @default(now())
}

model Post {
  // 既存フィールド...
  assets    Asset[]
}

よくあるハマりポイントと対処法

1. N+1問題

悪い例(クエリが多すぎる)

const posts = await prisma.post.findMany({ take: 20 });

for (const post of posts) {
  post.author = await prisma.user.findUnique({ where: { id: post.authorId } });
  post.likeCount = await prisma.like.count({ where: { postId: post.id } });
}

20件の投稿に対して 1 + 20 + 20 = 41回クエリが飛びます。

良い例(まとめて取得)

const posts = await prisma.post.findMany({
  take: 20,
  include: {
    author: true,
    _count: {
      select: { likes: true }
    }
  }
});

2. 無限スクロールのパフォーマンス

オフセット(skip, take)よりも、カーソルベースページネーションが無難です。

const posts = await prisma.post.findMany({
  take: 20,
  cursor: lastPostId ? { id: lastPostId } : undefined,
  skip: lastPostId ? 1 : 0,
  orderBy: { createdAt: 'desc' }
});

3. セキュリティ

XSS対策

ユーザー入力をHTMLとして描画するのは危険です。

// ❌ 危険
<div dangerouslySetInnerHTML={{ __html: post.content }} />

// ✅ 安全(Reactが自動でエスケープ)
<div>{post.content}</div>

リンクだけ有効にしたい場合は、linkify-html などを使いつつ、必ずエスケープを挟みます。

CSRF対策

  • Next.js App RouterのServer Actionsは基本的にCSRF対策込み
  • それ以外の構成では、SameSite属性付きCookieやCSRFトークンを検討

レート制限

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '1 m'),
});

export async function POST(request: Request) {
  const ip = request.headers.get('x-forwarded-for') || 'unknown';
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return new Response('Too many requests', { status: 429 });
  }

  // 通常の処理
}

応用編: さらに高度な機能

1. トレンド機能

// 例: 30分ごとのCronジョブで実行
export async function updateTrends() {
  const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);

  const posts = await prisma.post.findMany({
    where: { createdAt: { gte: oneDayAgo } },
    select: { content: true }
  });

  const hashtagCounts = new Map<string, number>();

  for (const post of posts) {
    const hashtags = post.content.match(/#[\w]+/g) || [];
    for (const tag of hashtags) {
      hashtagCounts.set(tag, (hashtagCounts.get(tag) || 0) + 1);
    }
  }

  const trends = Array.from(hashtagCounts.entries())
    .sort((a, b) => b[1] - a[1])
    .slice(0, 20);

  await prisma.trend.deleteMany();
  await prisma.trend.createMany({
    data: trends.map(([tag, count]) => ({ tag, count }))
  });
}

2. メンション機能

function extractMentions(content: string): string[] {
  const mentions = content.match(/@(\w+)/g) || [];
  return mentions.map(m => m.slice(1));
}

export async function POST(request: Request) {
  const { content } = await request.json();
  const session = await auth();

  const post = await prisma.post.create({
    data: {
      content,
      authorId: session.user.id,
    }
  });

  const mentionedUsernames = extractMentions(content);
  const mentionedUsers = await prisma.user.findMany({
    where: { username: { in: mentionedUsernames } }
  });

  for (const user of mentionedUsers) {
    await prisma.notification.create({
      data: {
        type: 'MENTION',
        userId: user.id,
        postId: post.id,
        fromUserId: session.user.id,
      }
    });
  }

  return Response.json(post);
}

3. ブロック機能

model Block {
  id        String   @id @default(cuid())
  blockerId String
  blockedId String
  blocker   User     @relation("blocker", fields: [blockerId], references: [id])
  blocked   User     @relation("blocked", fields: [blockedId], references: [id])
  createdAt DateTime @default(now())

  @@unique([blockerId, blockedId])
}

タイムラインからブロック対象を除外:

const blockedUserIds = await prisma.block.findMany({
  where: { blockerId: session.user.id },
  select: { blockedId: true }
});

const posts = await prisma.post.findMany({
  where: {
    authorId: {
      notIn: blockedUserIds.map(b => b.blockedId)
    }
  }
});

4. AI機能(おまけ)

スレッドの要約などにOpenAI APIを使う例:

import OpenAI from 'openai';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function summarizeThread(postIds: string[]) {
  const posts = await prisma.post.findMany({
    where: { id: { in: postIds } },
    include: { author: true }
  });

  const thread = posts
    .map(p => `${p.author.name}: ${p.content}`)
    .join('\n');

  const response = await openai.chat.completions.create({
    model: 'gpt-5',
    messages: [
      {
        role: 'system',
        content: 'あなたはSNSの投稿スレッドを要約するアシスタントです。'
      },
      {
        role: 'user',
        content: `以下のスレッドを3行で要約してください:\n\n${thread}`
      }
    ]
  });

  return response.choices[0].message.content;
}

デプロイ先の選び方

初心者向け: Vercel

npm install -g vercel
vercel

メリット

  • Next.jsの開発元であり、相性が非常に良い
  • GitHub連携でPushするだけで自動デプロイ
  • 無料枠が充実

注意点

  • 素のWebSocketは使いづらい(別サービスを併用することが多い)

中級者向け: Railway

npm install -g @railway/cli
railway login
railway init
railway up

メリット

  • アプリとDB(PostgreSQL、Redisなど)をまとめて管理
  • WebSocket対応
  • 無料枠あり

上級者向け: AWS / GCP / Azure

より細かい制御や大規模運用が可能ですが、学習コストもそれなりにかかります。

エッジ特化: Cloudflare

npm install -g wrangler
wrangler deploy

メリット

  • 全世界に分散したエッジロケーションで超低レイテンシ
  • 無料枠がかなり大きい
  • R2、D1、Vectorizeなど付帯サービスが豊富

まとめ: SNS制作で身につく力

自作SNSを通じて、次のようなスキルを実践的に鍛えられます。

技術スキル

フロントエンド

  • React / Next.jsの実践的な使い方
  • 状態管理(useState, Context, Jotai/Zustandなど)
  • API連携・データフェッチング
  • リアルタイム更新・UI/UX設計

バックエンド

  • RESTful API設計
  • データベース設計とリレーション
  • 認証・認可
  • セキュリティ対策(XSS・CSRF・レート制限など)

インフラ

  • デプロイとCI/CD
  • スケーリングとパフォーマンスチューニング
  • キャッシュ戦略

その他

  • Git/GitHubによるバージョン管理
  • エラー調査・デバッグスキル
  • ドキュメント・公式リファレンスの読み方

ソフトスキル

  • 大きめの個人プロジェクトを設計・完走する力
  • 段階的に機能を追加していく計画力
  • ユーザー目線でのUXを考える力
  • 問題にぶつかったときに自力で調べて解決する力

次のステップ

  1. まずは最小構成で作る

    • ユーザー登録・ログイン
    • 投稿作成・表示
    • ここまでを 1〜2週間で完成させてみる
  2. 少しずつ機能を追加する

    • いいね
    • フォロー
    • プロフィールページ
    • 余裕が出たら通知や検索にも挑戦
  3. 実際に公開してみる

    • 友人に触ってもらう
    • X(Twitter)でURLを公開してフィードバックを集める
  4. 興味に応じて高度化する

    • AI要約やレコメンド
    • リアルタイムチャット
    • モバイルアプリ化(React Native / Expoなど)
    • 分散型SNS(ActivityPub対応)への発展

完成度は最初から高くなくて構いません。
まずは「ログインして投稿できる最低限のSNS」を動かし、そこから少しずつ育てていきましょう。

実際に手を動かし、詰まったら調べて、また進める。
その積み重ねが、確実なスキルアップにつながります。

もしこの記事を読んで実際にSNSを作ってみた、あるいは途中で詰まってしまった、という方は、ぜひコメントで教えてください。
一緒に試行錯誤していきましょう。

少し宣伝

筆者は Subnect.com というSNSを制作・運営しています。ぜひフラっと見に来てください👍
https://subnect.com

Discussion