個人開発で壁紙系・癒し系・引き寄せ系といった、いわゆる「静かな」ユーティリティアプリを長く育ててきました。どれも一つのことを丁寧にこなす単機能ツールでしたが、ここ数年は同じユーザーが「他の人の投稿を見たい」「お気に入りを誰かと共有したい」と言ってくれる場面が増えてきました。ソーシャル機能を後から載せるべきかどうか、実際の運用で何度も迷ってきたテーマです。
ソーシャル機能は、最初の数千 DAU までは「あるとちょっと嬉しい付加機能」で済みます。けれど、DAU が万を超え、リテンションを 7 日・30 日のスケールで見るようになった瞬間、データベースの形・タイムラインの並べ方・通知の出し方が、そのままユーザーの定着率に直結し始めます。私自身、ある壁紙アプリで「フォロー外の人気投稿をタイムラインに混ぜる」設定にしてしまい、Day7 リテンションを 41% から 28% まで落としたことがあります。そこから半年かけて元に戻した過程で気づいたことを、このガイドには織り込みました。
このガイドは、Rork Max と Supabase で「壊れにくい」ソーシャルアプリ機能を組み上げるための実装手順書です。フォロー/フォロワー、タイムラインフィード、いいね・コメント、リアルタイム更新、プッシュ通知までを一気通貫で扱います。読み終わったときに、自分のアプリのどこから着手すれば良いかが具体的に見える状態を目指しました。
取り組みの背景
ソーシャル機能は「ユーザー同士の接続をどう保つか」と「タイムラインで何を見せるか」の二点に集約されます。Twitter が爆発的に伸びたのもフォローという接続モデル、Instagram が Facebook を抜いたのもフォトを中心にした純粋なフォローフィードがあったからです。
ただし、これらの巨大サービスを真似しようとすると、ほぼ確実に「全部入りで動きはするが、誰のフィードも汚い」という状態に陥ります。私が壁紙アプリで実際に経験したのも同じパターンでした。データベース設計・タイムラインの並び・通知の出し方、それぞれで「やらない判断」を最初に決めるのが、結果として一番ユーザーに親切な作りになります。私はこの「全部入りの誘惑」を、実装に入る前の設計段階で意識的に断ち切るようにしています。一度コードに落ちてしまうと、機能を足すより外すほうが何倍も難しくなるからです。
以降の章では、データベース・フォロー・タイムライン・楽観的更新・リアルタイム・無限スクロール・通知の順に、Supabase をバックエンドにした実装パターンを 1 つずつ作っていきます。
データベース設計 — Supabaseスキーマの全体像
ソーシャル機能の土台となるデータベーススキーマ設計が最も重要です。不適切な設計は、後々のパフォーマンス低下につながります。
テーブル構成
profiles
├── id (UUID, PK)
├── user_id (UUID, FK → auth.users)
├── username (TEXT, UNIQUE)
├── display_name (TEXT)
├── avatar_url (TEXT)
├── bio (TEXT)
├── followers_count (INT)
├── following_count (INT)
├── created_at (TIMESTAMP)
└── updated_at (TIMESTAMP)
follows
├── id (UUID, PK)
├── follower_id (UUID, FK → profiles)
├── following_id (UUID, FK → profiles)
├── created_at (TIMESTAMP)
└── UNIQUE(follower_id, following_id)
posts
├── id (UUID, PK)
├── author_id (UUID, FK → profiles)
├── content (TEXT)
├── image_urls (TEXT[])
├── likes_count (INT)
├── comments_count (INT)
├── created_at (TIMESTAMP)
├── updated_at (TIMESTAMP)
└── deleted_at (TIMESTAMP, soft delete)
likes
├── id (UUID, PK)
├── post_id (UUID, FK → posts)
├── user_id (UUID, FK → profiles)
├── created_at (TIMESTAMP)
└── UNIQUE(post_id, user_id)
comments
├── id (UUID, PK)
├── post_id (UUID, FK → posts)
├── author_id (UUID, FK → profiles)
├── parent_comment_id (UUID, FK → comments, nullable)
├── content (TEXT)
├── likes_count (INT)
├── created_at (TIMESTAMP)
├── updated_at (TIMESTAMP)
└── deleted_at (TIMESTAMP)
notifications
├── id (UUID, PK)
├── user_id (UUID, FK → profiles)
├── type (TEXT: 'like', 'comment', 'follow')
├── triggered_by_id (UUID, FK → profiles)
├── post_id (UUID, FK → posts, nullable)
├── comment_id (UUID, FK → comments, nullable)
├── is_read (BOOLEAN)
├── created_at (TIMESTAMP)
└── INDEX(user_id, is_read)
Supabase での実装
Supabase SQL エディタで以下を実行します:
-- profiles テーブル
CREATE TABLE profiles (
id UUID PRIMARY KEY DEFAULT auth . uid (),
user_id UUID REFERENCES auth . users NOT NULL ,
username TEXT UNIQUE NOT NULL ,
display_name TEXT ,
avatar_url TEXT ,
bio TEXT ,
followers_count INT DEFAULT 0 ,
following_count INT DEFAULT 0 ,
created_at TIMESTAMP DEFAULT now (),
updated_at TIMESTAMP DEFAULT now ()
);
-- RLS ポリシー
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY ;
CREATE POLICY "Public profiles are viewable by all"
ON profiles FOR SELECT USING (true);
CREATE POLICY "Users can update their own profile"
ON profiles FOR UPDATE USING ( auth . uid () = id);
-- follows テーブル
CREATE TABLE follows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
follower_id UUID REFERENCES profiles NOT NULL ,
following_id UUID REFERENCES profiles NOT NULL ,
created_at TIMESTAMP DEFAULT now (),
UNIQUE (follower_id, following_id),
CHECK (follower_id != following_id)
);
ALTER TABLE follows ENABLE ROW LEVEL SECURITY ;
CREATE POLICY "Public follows are viewable"
ON follows FOR SELECT USING (true);
CREATE POLICY "Users can manage their follows"
ON follows FOR INSERT WITH CHECK ( auth . uid () = follower_id);
CREATE POLICY "Users can delete their follows"
ON follows FOR DELETE USING ( auth . uid () = follower_id);
-- posts テーブル
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id UUID REFERENCES profiles NOT NULL ,
content TEXT NOT NULL ,
image_urls TEXT [],
likes_count INT DEFAULT 0 ,
comments_count INT DEFAULT 0 ,
created_at TIMESTAMP DEFAULT now (),
updated_at TIMESTAMP DEFAULT now (),
deleted_at TIMESTAMP
);
ALTER TABLE posts ENABLE ROW LEVEL SECURITY ;
CREATE POLICY "Public posts are viewable" ON posts
FOR SELECT USING (deleted_at IS NULL );
CREATE POLICY "Users can create posts" ON posts
FOR INSERT WITH CHECK ( auth . uid () = author_id);
CREATE POLICY "Users can update own posts" ON posts
FOR UPDATE USING ( auth . uid () = author_id);
-- likes テーブル
CREATE TABLE likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID REFERENCES posts NOT NULL ,
user_id UUID REFERENCES profiles NOT NULL ,
created_at TIMESTAMP DEFAULT now (),
UNIQUE (post_id, user_id)
);
ALTER TABLE likes ENABLE ROW LEVEL SECURITY ;
CREATE POLICY "Likes are viewable" ON likes FOR SELECT USING (true);
CREATE POLICY "Users can like posts" ON likes
FOR INSERT WITH CHECK ( auth . uid () = user_id);
CREATE POLICY "Users can unlike" ON likes
FOR DELETE USING ( auth . uid () = user_id);
-- comments テーブル
CREATE TABLE comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID REFERENCES posts NOT NULL ,
author_id UUID REFERENCES profiles NOT NULL ,
parent_comment_id UUID REFERENCES comments,
content TEXT NOT NULL ,
likes_count INT DEFAULT 0 ,
created_at TIMESTAMP DEFAULT now (),
updated_at TIMESTAMP DEFAULT now (),
deleted_at TIMESTAMP
);
ALTER TABLE comments ENABLE ROW LEVEL SECURITY ;
CREATE POLICY "Comments are viewable" ON comments
FOR SELECT USING (deleted_at IS NULL );
CREATE POLICY "Users can comment" ON comments
FOR INSERT WITH CHECK ( auth . uid () = author_id);
CREATE POLICY "Users can update own comments" ON comments
FOR UPDATE USING ( auth . uid () = author_id);
-- notifications テーブル
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles NOT NULL ,
type TEXT CHECK ( type IN ( 'like' , 'comment' , 'follow' )),
triggered_by_id UUID REFERENCES profiles NOT NULL ,
post_id UUID REFERENCES posts,
comment_id UUID REFERENCES comments,
is_read BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT now ()
);
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY ;
CREATE POLICY "Users can view own notifications" ON notifications
FOR SELECT USING ( auth . uid () = user_id);
CREATE POLICY "Users can mark notifications as read" ON notifications
FOR UPDATE USING ( auth . uid () = user_id);
-- インデックス作成
CREATE INDEX idx_profiles_username ON profiles(username);
CREATE INDEX idx_follows_follower_id ON follows(follower_id);
CREATE INDEX idx_follows_following_id ON follows(following_id);
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_created_at ON posts(created_at DESC );
CREATE INDEX idx_likes_post_id ON likes(post_id);
CREATE INDEX idx_likes_user_id ON likes(user_id);
CREATE INDEX idx_comments_post_id ON comments(post_id);
CREATE INDEX idx_comments_author_id ON comments(author_id);
CREATE INDEX idx_notifications_user_id ON notifications(user_id, is_read);
フォロー/フォロワーシステムの実装
RorkのサービスクラスでFollowロジックを実装
// services/followService.ts
import { createClient } from '@supabase/supabase-js' ;
class FollowService {
private supabase = createClient (
process.env. SUPABASE_URL ! ,
process.env. SUPABASE_ANON_KEY !
);
// フォローしたユーザーのリストを取得
async getFollowing ( userId : string , limit = 20 , offset = 0 ) {
const { data , error } = await this .supabase
. from ( 'follows' )
. select ( `
following_id,
profiles:following_id (
id, username, display_name, avatar_url, followers_count
)
` )
. eq ( 'follower_id' , userId)
. order ( 'created_at' , { ascending: false })
. range (offset, offset + limit - 1 );
if (error) throw error;
return data;
}
// フォロワーのリストを取得
async getFollowers ( userId : string , limit = 20 , offset = 0 ) {
const { data , error } = await this .supabase
. from ( 'follows' )
. select ( `
follower_id,
profiles:follower_id (
id, username, display_name, avatar_url, followers_count
)
` )
. eq ( 'following_id' , userId)
. order ( 'created_at' , { ascending: false })
. range (offset, offset + limit - 1 );
if (error) throw error;
return data;
}
// ユーザーをフォロー
async followUser ( followerId : string , followingId : string ) {
// 既にフォローしているかチェック
const { data : existing } = await this .supabase
. from ( 'follows' )
. select ( 'id' )
. eq ( 'follower_id' , followerId)
. eq ( 'following_id' , followingId)
. single ();
if (existing) throw new Error ( 'Already following' );
// フォロー関係を作成
const { error } = await this .supabase
. from ( 'follows' )
. insert ([
{ follower_id: followerId, following_id: followingId }
]);
if (error) throw error;
// フォロワーカウントを増やす
await this .supabase
. from ( 'profiles' )
. update ({ followers_count: this . incrementCounter () })
. eq ( 'id' , followingId);
// フォロー中カウントを増やす
await this .supabase
. from ( 'profiles' )
. update ({ following_count: this . incrementCounter () })
. eq ( 'id' , followerId);
// 通知を作成
await this . createNotification (
followingId,
'follow' ,
followerId,
null ,
null
);
}
// フォローを解除
async unfollowUser ( followerId : string , followingId : string ) {
const { error } = await this .supabase
. from ( 'follows' )
. delete ()
. eq ( 'follower_id' , followerId)
. eq ( 'following_id' , followingId);
if (error) throw error;
// カウンターを更新
await Promise . all ([
this .supabase
. from ( 'profiles' )
. update ({ followers_count: this . decrementCounter () })
. eq ( 'id' , followingId),
this .supabase
. from ( 'profiles' )
. update ({ following_count: this . decrementCounter () })
. eq ( 'id' , followerId)
]);
}
// フォロー提案を取得(相互フォローの可能性が高いユーザー)
async getSuggestedFollows ( userId : string , limit = 10 ) {
const { data : suggested , error } = await this .supabase
. rpc ( 'get_suggested_follows' , {
current_user_id: userId,
limit_count: limit
});
if (error) throw error;
return suggested;
}
private incrementCounter () {
return this .supabase. raw `followers_count + 1` ;
}
private decrementCounter () {
return this .supabase. raw `followers_count - 1` ;
}
private async createNotification (
userId : string ,
type : 'like' | 'comment' | 'follow' ,
triggeredById : string ,
postId : string | null ,
commentId : string | null
) {
await this .supabase. from ( 'notifications' ). insert ([
{
user_id: userId,
type,
triggered_by_id: triggeredById,
post_id: postId,
comment_id: commentId
}
]);
}
}
export const followService = new FollowService ();
Rorkアプリでのフォローボタンコンポーネント
// components/FollowButton.tsx
import React, { useState } from 'react' ;
import { Text, TouchableOpacity, ActivityIndicator } from 'react-native' ;
import { useAuth } from '@/context/AuthContext' ;
import { followService } from '@/services/followService' ;
interface FollowButtonProps {
targetUserId : string ;
isFollowing : boolean ;
onFollowChange : ( newState : boolean ) => void ;
}
export function FollowButton ({
targetUserId ,
isFollowing ,
onFollowChange
} : FollowButtonProps ) {
const { user } = useAuth ();
const [ loading , setLoading ] = useState ( false );
if ( ! user) return null ;
const handlePress = async () => {
setLoading ( true );
try {
if (isFollowing) {
await followService. unfollowUser (user.id, targetUserId);
onFollowChange ( false );
} else {
await followService. followUser (user.id, targetUserId);
onFollowChange ( true );
}
} catch (error) {
console. error ( 'Follow error:' , error);
} finally {
setLoading ( false );
}
};
return (
< TouchableOpacity
onPress = { handlePress }
disabled = { loading }
style = { {
paddingHorizontal: 16 ,
paddingVertical: 8 ,
backgroundColor: isFollowing ? '#f0f0f0' : '#0066cc' ,
borderRadius: 20 ,
borderWidth: isFollowing ? 1 : 0 ,
borderColor: '#ccc'
} }
>
{ loading ? (
< ActivityIndicator color = { isFollowing ? '#000' : '#fff' } />
) : (
< Text
style = { {
color: isFollowing ? '#000' : '#fff' ,
fontWeight: '600' ,
fontSize: 14
} }
>
{ isFollowing ? 'フォロー中' : 'フォロー' }
</ Text >
) }
</ TouchableOpacity >
);
}
タイムラインフィードの設計と実装
タイムラインフィード生成は、ソーシャルアプリの「心臓」です。正しいアルゴリズムがなければ、ユーザーは退屈するか、無関係な投稿で埋まります。
フィード生成アルゴリズム
// services/feedService.ts
class FeedService {
private supabase = createClient (
process.env. SUPABASE_URL ! ,
process.env. SUPABASE_ANON_KEY !
);
/**
* ユーザーのホームフィードを取得
* フォローしているユーザーの投稿 + 新規・人気順でソート
*/
async getHomeFeed ( userId : string , limit = 20 , offset = 0 ) {
const { data : following , error : followError } = await this .supabase
. from ( 'follows' )
. select ( 'following_id' )
. eq ( 'follower_id' , userId);
if (followError) throw followError;
const followingIds = following?. map ( f => f.following_id) || [];
// 自分の投稿 + フォローしているユーザーの投稿を結合
const { data : posts , error : postError } = await this .supabase
. from ( 'posts' )
. select ( `
id,
content,
image_urls,
author_id,
profiles:author_id (
id, username, display_name, avatar_url
),
likes_count,
comments_count,
created_at,
likes!inner (user_id)
` )
. in ( 'author_id' , [userId, ... followingIds])
. is ( 'deleted_at' , null )
. order ( 'created_at' , { ascending: false })
. range (offset, offset + limit - 1 );
if (postError) throw postError;
// 現在のユーザーが各投稿にいいねしているかを確認
return posts?. map ( post => ({
... post,
isLikedByCurrentUser: post.likes?. some (
like => like.user_id === userId
) || false
})) || [];
}
/**
* 探索フィード(発見用フィード)
* トレンド投稿・人気投稿・新規投稿をブレンド
*/
async getDiscoverFeed ( userId : string , limit = 20 , offset = 0 ) {
// 過去24時間でいいね数が多い投稿を優先
const { data : posts , error } = await this .supabase
. from ( 'posts' )
. select ( `
id,
content,
image_urls,
author_id,
profiles:author_id (
id, username, display_name, avatar_url
),
likes_count,
comments_count,
created_at,
likes!inner (user_id)
` )
. is ( 'deleted_at' , null )
. gt ( 'created_at' , new Date (Date. now () - 24 * 60 * 60 * 1000 ). toISOString ())
. order ( 'likes_count' , { ascending: false })
. range (offset, offset + limit - 1 );
if (error) throw error;
return posts?. map ( post => ({
... post,
isLikedByCurrentUser: post.likes?. some (
like => like.user_id === userId
) || false
})) || [];
}
/**
* ユーザープロフィールの投稿一覧を取得
*/
async getUserPosts ( userId : string , limit = 20 , offset = 0 ) {
const { data : posts , error } = await this .supabase
. from ( 'posts' )
. select ( '*' )
. eq ( 'author_id' , userId)
. is ( 'deleted_at' , null )
. order ( 'created_at' , { ascending: false })
. range (offset, offset + limit - 1 );
if (error) throw error;
return posts || [];
}
/**
* 無限スクロール対応のページネーション
* カーソルベースのページネーション(offset より効率的)
*/
async getFeedWithCursor (
userId : string ,
cursor : string | null = null ,
limit = 20
) {
let query = this .supabase
. from ( 'posts' )
. select ( '*' )
. eq ( 'author_id' , userId)
. is ( 'deleted_at' , null )
. order ( 'created_at' , { ascending: false });
if (cursor) {
query = query. lt ( 'created_at' , cursor);
}
const { data : posts , error } = await query. limit (limit + 1 );
if (error) throw error;
const hasMore = (posts || []). length > limit;
const feedPosts = (posts || []). slice ( 0 , limit);
const nextCursor = feedPosts[feedPosts. length - 1 ]?.created_at || null ;
return { posts: feedPosts, nextCursor, hasMore };
}
}
export const feedService = new FeedService ();
いいね・コメント機能と楽観的更新
ユーザーがいいね・コメントボタンをタップした時、サーバーのレスポンスを待たずに、すぐにUIを更新する (楽観的更新) ことが、優れたモバイルUX の特徴です。
いいね機能の実装
// services/likeService.ts
class LikeService {
private supabase = createClient (
process.env. SUPABASE_URL ! ,
process.env. SUPABASE_ANON_KEY !
);
async likePost ( postId : string , userId : string ) {
try {
// 既にいいねしているかチェック
const { data : existing } = await this .supabase
. from ( 'likes' )
. select ( 'id' )
. eq ( 'post_id' , postId)
. eq ( 'user_id' , userId)
. single ();
if (existing) return ; // 既にいいね済み
// いいねを追加
await this .supabase
. from ( 'likes' )
. insert ([{ post_id: postId, user_id: userId }]);
// post の likes_count を増やす
await this .supabase
. from ( 'posts' )
. update ({ likes_count: this . incrementCounter () })
. eq ( 'id' , postId);
// 通知を作成
const { data : post } = await this .supabase
. from ( 'posts' )
. select ( 'author_id' )
. eq ( 'id' , postId)
. single ();
if (post?.author_id !== userId) {
await this .supabase
. from ( 'notifications' )
. insert ([{
user_id: post.author_id,
type: 'like' ,
triggered_by_id: userId,
post_id: postId
}]);
}
} catch (error) {
console. error ( 'Like error:' , error);
throw error;
}
}
async unlikePost ( postId : string , userId : string ) {
await this .supabase
. from ( 'likes' )
. delete ()
. eq ( 'post_id' , postId)
. eq ( 'user_id' , userId);
// likes_count を減らす
await this .supabase
. from ( 'posts' )
. update ({ likes_count: this . decrementCounter () })
. eq ( 'id' , postId);
}
private incrementCounter () {
return this .supabase. raw `likes_count + 1` ;
}
private decrementCounter () {
return this .supabase. raw `likes_count - 1` ;
}
}
export const likeService = new LikeService ();
Rorkコンポーネント — 楽観的更新の実装
// components/LikeButton.tsx
import React, { useState } from 'react' ;
import { TouchableOpacity, Text } from 'react-native' ;
import { likeService } from '@/services/likeService' ;
interface LikeButtonProps {
postId : string ;
userId : string ;
initialIsLiked : boolean ;
initialCount : number ;
onCountChange ?: ( newCount : number ) => void ;
}
export function LikeButton ({
postId ,
userId ,
initialIsLiked ,
initialCount ,
onCountChange
} : LikeButtonProps ) {
const [ isLiked , setIsLiked ] = useState (initialIsLiked);
const [ count , setCount ] = useState (initialCount);
const [ loading , setLoading ] = useState ( false );
const handleLike = async () => {
// 楽観的更新:サーバーレスポンス前にUIを更新
const previousLiked = isLiked;
const previousCount = count;
setIsLiked ( ! isLiked);
setCount (isLiked ? count - 1 : count + 1 );
setLoading ( true );
try {
if (isLiked) {
await likeService. unlikePost (postId, userId);
} else {
await likeService. likePost (postId, userId);
}
onCountChange ?.(isLiked ? count - 1 : count + 1 );
} catch (error) {
// エラー時はロールバック
console. error ( 'Like failed, rolling back:' , error);
setIsLiked (previousLiked);
setCount (previousCount);
} finally {
setLoading ( false );
}
};
return (
< TouchableOpacity
onPress = { handleLike }
disabled = { loading }
style = { { flexDirection: 'row' , alignItems: 'center' } }
>
< Text style = { { fontSize: 18 , marginRight: 8 } } >
{ isLiked ? '❤️' : '🤍' }
</ Text >
< Text style = { { color: isLiked ? '#e74c3c' : '#999' } } >
{ count }
</ Text >
</ TouchableOpacity >
);
}
コメント機能の実装
// services/commentService.ts
class CommentService {
private supabase = createClient (
process.env. SUPABASE_URL ! ,
process.env. SUPABASE_ANON_KEY !
);
/**
* 投稿に対するコメントを取得
* ネスト返信(親コメント以下のスレッド)にも対応
*/
async getComments ( postId : string , limit = 20 , offset = 0 ) {
const { data : comments , error } = await this .supabase
. from ( 'comments' )
. select ( `
id,
content,
author_id,
profiles:author_id (id, username, avatar_url),
likes_count,
parent_comment_id,
created_at,
updated_at
` )
. eq ( 'post_id' , postId)
. is ( 'parent_comment_id' , null ) // トップレベルのコメントのみ
. is ( 'deleted_at' , null )
. order ( 'created_at' , { ascending: false })
. range (offset, offset + limit - 1 );
if (error) throw error;
// 各トップレベルコメントに対する返信を取得
const commentsWithReplies = await Promise . all (
(comments || []). map ( async ( comment ) => {
const { data : replies } = await this .supabase
. from ( 'comments' )
. select ( `
id, content, author_id,
profiles:author_id (id, username, avatar_url),
likes_count, created_at
` )
. eq ( 'parent_comment_id' , comment.id)
. is ( 'deleted_at' , null )
. order ( 'created_at' , { ascending: true })
. limit ( 3 ); // 返信は最新3件のみ最初に表示
return { ... comment, replies: replies || [] };
})
);
return commentsWithReplies;
}
async addComment ( postId : string , userId : string , content : string , parentCommentId ?: string ) {
const { data : newComment , error } = await this .supabase
. from ( 'comments' )
. insert ([{
post_id: postId,
author_id: userId,
content,
parent_comment_id: parentCommentId || null
}])
. select ()
. single ();
if (error) throw error;
// post の comments_count を増やす(トップレベルコメントのみ)
if ( ! parentCommentId) {
await this .supabase
. from ( 'posts' )
. update ({ comments_count: this . incrementCounter () })
. eq ( 'id' , postId);
}
// 通知を作成
const { data : post } = await this .supabase
. from ( 'posts' )
. select ( 'author_id' )
. eq ( 'id' , postId)
. single ();
if (post?.author_id !== userId) {
await this .supabase
. from ( 'notifications' )
. insert ([{
user_id: post.author_id,
type: 'comment' ,
triggered_by_id: userId,
post_id: postId,
comment_id: newComment.id
}]);
}
return newComment;
}
private incrementCounter () {
return this .supabase. raw `comments_count + 1` ;
}
private decrementCounter () {
return this .supabase. raw `comments_count - 1` ;
}
}
export const commentService = new CommentService ();
リアルタイム更新 — Supabase Realtime の統合
リアルタイムリスナーの設定
// hooks/useRealtimePost.ts
import { useEffect, useState } from 'react' ;
import { createClient } from '@supabase/supabase-js' ;
interface Post {
id : string ;
content : string ;
likes_count : number ;
comments_count : number ;
// ... その他のフィールド
}
export function useRealtimePost ( postId : string ) {
const [ post , setPost ] = useState < Post | null >( null );
const supabase = createClient (
process.env. SUPABASE_URL ! ,
process.env. SUPABASE_ANON_KEY !
);
useEffect (() => {
// 初期データを取得
const fetchPost = async () => {
const { data } = await supabase
. from ( 'posts' )
. select ( '*' )
. eq ( 'id' , postId)
. single ();
setPost (data);
};
fetchPost ();
// リアルタイムリスナーを設定
const subscription = supabase
. channel ( `post:${ postId }` )
. on (
'postgres_changes' ,
{
event: '*' ,
schema: 'public' ,
table: 'posts' ,
filter: `id=eq.${ postId }`
},
( payload ) => {
console. log ( 'Post updated:' , payload);
setPost (payload.new as Post );
}
)
. subscribe ();
return () => {
subscription. unsubscribe ();
};
}, [postId]);
return post;
}
リアルタイム新規投稿通知
// hooks/useRealtimeFeed.ts
export function useRealtimeFeed ( userId : string ) {
const [ newPosts , setNewPosts ] = useState < Post []>([]);
const supabase = createClient (
process.env. SUPABASE_URL ! ,
process.env. SUPABASE_ANON_KEY !
);
useEffect (() => {
const subscription = supabase
. channel ( 'feed:updates' )
. on (
'postgres_changes' ,
{
event: 'INSERT' ,
schema: 'public' ,
table: 'posts'
},
( payload ) => {
const newPost = payload.new as Post ;
// フォローしているユーザーの投稿のみを追加
setNewPosts ( prev => [newPost, ... prev]);
}
)
. subscribe ();
return () => {
subscription. unsubscribe ();
};
}, [userId]);
return newPosts;
}
無限スクロールとパフォーマンス最適化
FlatList での実装
// screens/FeedScreen.tsx
import React, { useState, useCallback, useRef } from 'react' ;
import {
FlatList,
ActivityIndicator,
RefreshControl,
View,
Text
} from 'react-native' ;
import { feedService } from '@/services/feedService' ;
import { PostCard } from '@/components/PostCard' ;
export function FeedScreen () {
const [ posts , setPosts ] = useState < Post []>([]);
const [ loading , setLoading ] = useState ( true );
const [ refreshing , setRefreshing ] = useState ( false );
const [ hasMore , setHasMore ] = useState ( true );
const [ cursor , setCursor ] = useState < string | null >( null );
const { user } = useAuth ();
// 初期ロード
const loadInitialFeed = useCallback ( async () => {
if ( ! user) return ;
setLoading ( true );
try {
const { posts : initialPosts , nextCursor , hasMore } =
await feedService. getFeedWithCursor (user.id, null , 20 );
setPosts (initialPosts);
setCursor (nextCursor);
setHasMore (hasMore);
} catch (error) {
console. error ( 'Load feed error:' , error);
} finally {
setLoading ( false );
}
}, [user]);
// プルリフレッシュ
const onRefresh = useCallback ( async () => {
if ( ! user) return ;
setRefreshing ( true );
try {
const { posts : freshPosts , nextCursor } =
await feedService. getFeedWithCursor (user.id, null , 20 );
setPosts (freshPosts);
setCursor (nextCursor);
setHasMore ( true );
} catch (error) {
console. error ( 'Refresh error:' , error);
} finally {
setRefreshing ( false );
}
}, [user]);
// 末尾に達した時に追加データをロード
const onEndReached = useCallback ( async () => {
if ( ! user || ! hasMore || ! cursor) return ;
try {
const { posts : morePosts , nextCursor , hasMore : moreExists } =
await feedService. getFeedWithCursor (user.id, cursor, 20 );
setPosts ( prev => [ ... prev, ... morePosts]);
setCursor (nextCursor);
setHasMore (moreExists);
} catch (error) {
console. error ( 'Load more error:' , error);
}
}, [user, cursor, hasMore]);
if (loading) {
return (
< View style = { { flex: 1 , justifyContent: 'center' , alignItems: 'center' } } >
< ActivityIndicator size = "large" />
</ View >
);
}
return (
< FlatList
data = { posts }
renderItem = { ({ item }) => < PostCard post = { item } /> }
keyExtractor = { item => item.id }
onEndReached = { onEndReached }
onEndReachedThreshold = { 0.5 } // スクロール位置50%で追加ロード
refreshControl = {
< RefreshControl
refreshing = { refreshing }
onRefresh = { onRefresh }
tintColor = "#0066cc"
/>
}
ListFooterComponent = {
hasMore && posts. length > 0 ? (
< View style = { { paddingVertical: 16 } } >
< ActivityIndicator />
</ View >
) : posts. length === 0 ? (
< Text style = { { textAlign: 'center' , marginTop: 32 , color: '#999' } } >
投稿がありません
</ Text >
) : null
}
/>
);
}
画像の遅延読み込みとキャッシュ
// components/PostImage.tsx
import { Image } from 'react-native' ;
import FastImage from 'react-native-fast-image' ;
interface PostImageProps {
url : string ;
width : number ;
height : number ;
}
export function PostImage ({ url , width , height } : PostImageProps ) {
return (
< FastImage
source = { {
uri: url,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable
} }
style = { { width, height } }
resizeMode = { FastImage.resizeMode.cover }
/>
);
}
プッシュ通知の統合
Firebase Cloud Messaging (FCM) の設定
// services/notificationService.ts
import messaging from '@react-native-firebase/messaging' ;
class NotificationService {
private supabase = createClient (
process.env. SUPABASE_URL ! ,
process.env. SUPABASE_ANON_KEY !
);
async initializeNotifications ( userId : string ) {
// FCM トークンを取得
const fcmToken = await messaging (). getToken ();
// Supabase に FCM トークンを保存
await this .supabase
. from ( 'user_devices' )
. upsert ({
user_id: userId,
fcm_token: fcmToken,
platform: 'ios' , // または 'android'
updated_at: new Date ()
});
// フォアグラウンド通知リスナー
messaging (). onMessage ( async ( remoteMessage ) => {
console. log ( 'Foreground notification:' , remoteMessage);
this . handleNotification (remoteMessage);
});
// バックグラウンド通知リスナー
messaging (). onNotificationOpenedApp (( remoteMessage ) => {
console. log ( 'App opened from notification:' , remoteMessage);
this . navigateToNotificationContent (remoteMessage);
});
}
private handleNotification ( remoteMessage : any ) {
// 通知をローカル表示
// または通知センターを更新
}
private navigateToNotificationContent ( remoteMessage : any ) {
const { notificationType , targetId } = remoteMessage.data;
switch (notificationType) {
case 'like' :
case 'comment' :
// 投稿詳細画面へ移動
break ;
case 'follow' :
// ユーザープロフィール画面へ移動
break ;
}
}
}
export const notificationService = new NotificationService ();
通知イベントのトリガー(Cloud Functions)
// Firebase Cloud Function
import * as functions from 'firebase-functions' ;
import * as admin from 'firebase-admin' ;
admin. initializeApp ();
export const sendLikeNotification = functions.firestore
. document ( 'likes/{likeId}' )
. onCreate ( async ( snap , context ) => {
const like = snap. data ();
const postDoc = await admin
. firestore ()
. collection ( 'posts' )
. doc (like.postId)
. get ();
const postData = postDoc. data ();
const authorId = postData?.authorId;
if (authorId === like.userId) return ; // 自分の投稿へのいいねは通知しない
const userDevices = await admin
. firestore ()
. collection ( 'user_devices' )
. where ( 'userId' , '==' , authorId)
. get ();
const tokens = userDevices.docs
. map ( doc => doc. data ().fcmToken)
. filter (Boolean);
if (tokens. length === 0 ) return ;
await admin. messaging (). sendMulticast ({
tokens,
notification: {
title: 'いいね' ,
body: `誰かがあなたの投稿にいいねしました`
},
data: {
notificationType: 'like' ,
targetId: like.postId
}
});
});
公式ドキュメントには書かれていない、本番運用で気づいたこと
ここまでで「機能としてのソーシャルアプリ」は揃いました。ここからは、Supabase / FCM の公式ドキュメントには書かれていない、私自身が個人開発のアプリ運用を通じて拾った6つの判断材料を共有します。すべて、過去に失敗してから引き返した経験ベースの内容です。
1. タイムラインに「フォロー外の人気投稿」を混ぜると Day7 が落ちる
最初の数千 DAU 規模では、フォロー外の人気投稿をタイムラインに 20% ほど混ぜると DAU の積み上げに寄与する、という分析記事をよく見かけます。私もこれを信じて、ある壁紙アプリで WHERE user_id IN (followees) UNION ALL WHERE popularity_score > X LIMIT 5 的な実装を入れたところ、Day7 リテンションが 41% → 28% に落ちました。
理由は単純で、ユーザーは「自分がフォローした人の投稿を見に来ている」のに、フォロー外のノイズが混じると「自分のフィードが汚れた」と感じるからです。インスタの初期も同じ判断(純粋なフォロー由来フィード)でした。
私の運用ルールは「フォロー由来のフィードは絶対に汚さない 」です。フォロー外の発見はホームではなく、別タブの「みつける」に隔離します:
// services/timelineService.ts
// ❌ やってはいけない: ホームフィードに発見要素を混ぜる
async function getMixedHomeFeed ( userId : string , cursor ?: string ) {
const { data : followFeed } = await supabase
. from ( 'posts' )
. select ( '*' )
. in ( 'user_id' , await getFollowees (userId))
. order ( 'created_at' , { ascending: false })
. limit ( 15 );
// この5件が Day7 を落とす
const { data : discovery } = await supabase
. from ( 'posts' )
. select ( '*' )
. gt ( 'popularity_score' , 50 )
. order ( 'popularity_score' , { ascending: false })
. limit ( 5 );
return [ ... (followFeed ?? []), ... (discovery ?? [])];
}
// ✅ 推奨: ホームはフォロー純粋・発見は別画面
async function getHomeFeed ( userId : string , cursor ?: string ) {
const followees = await getFollowees (userId);
if (followees. length === 0 ) {
// 新規ユーザー向けのオンボーディング用 follow 提案を返す(フィードを混ぜない)
return { posts: [], suggestions: await getFollowSuggestions (userId) };
}
const query = supabase
. from ( 'posts' )
. select ( '*, profiles!inner(*)' )
. in ( 'user_id' , followees)
. order ( 'created_at' , { ascending: false })
. limit ( 20 );
if (cursor) query. lt ( 'created_at' , cursor);
const { data } = await query;
return { posts: data ?? [], suggestions: null };
}
「発見」を見せたいなら別タブ、もしくは「いま、おすすめの人」セクションを独立コンポーネントとして配置するのが安全です。
2. follower_count を非正規化したくなる気持ちはわかるが、最初の一年は集計クエリでよい
フォロワー数を profiles.follower_count に非正規化したくなる場面は、想像以上に早く訪れます。プロフィール画面の表示で SELECT COUNT(*) FROM follows WHERE followee_id = ? が遅いと感じるからです。
けれど私の経験では、DAU 5 万・フォロー総数 200 万くらいまでは、follows テーブルに (followee_id, created_at DESC) の B-tree インデックスを張れば 10ms 以内で返ってきます。むしろ非正規化したことで、フォロー作成・削除のたびに UPDATE profiles SET follower_count = follower_count ± 1 を走らせる必要が出て、トランザクション競合・キャッシュ不整合・retention 計算のずれ、といった三重苦に陥った経験があります。
私の現在の運用は「最初の一年は COUNT で行く 」です。DAU が伸びてから、必要なら以下のように pg_cron で 5 分おきに集計するマテリアライズドビューに切り替えます:
-- migrations/202605_followers_view.sql
CREATE MATERIALIZED VIEW user_follower_stats AS
SELECT
followee_id AS user_id,
COUNT ( * ) AS follower_count,
COUNT ( * ) FILTER ( WHERE created_at > NOW () - INTERVAL '7 days' ) AS new_followers_7d
FROM follows
GROUP BY followee_id;
CREATE UNIQUE INDEX ON user_follower_stats (user_id);
-- 5分おきに refresh
SELECT cron . schedule (
'refresh_user_follower_stats' ,
'*/5 * * * *' ,
$$REFRESH MATERIALIZED VIEW CONCURRENTLY user_follower_stats$$
);
REFRESH MATERIALIZED VIEW CONCURRENTLY を使うと既存読み取りをブロックせずに更新できます。ユニークインデックスが必須なので、(user_id) を必ず張ること。
3. いいねは CRDT ではなく LWW(最終書き込み勝ち)で十分
ソーシャル機能を設計していると「いいねは可換な操作だから CRDT を使うべきでは?」と考える時期があります。私も Yjs ベースで実装した時期がありました。
しかし、いいね/いいね解除は 冪等性が保証されていれば LWW で十分 です。理由は3つあります:
1人のユーザーが同一投稿に同時刻ピンポイントで複数デバイスから lock/unlock することは現実にはほぼない
万一発生しても、ユーザーは「最後に押したボタンの状態」を期待している(LWW の定義そのもの)
CRDT を入れると、いいね数の集計コストが爆発する(CRDT セットのカウント = サーバー側で全要素を見る必要がある)
私が壁紙アプリで採用しているのは、Supabase のユニーク制約 + サーバータイムスタンプで LWW を実装する方法です:
-- migrations/202605_likes_table.sql
CREATE TABLE likes (
id BIGSERIAL PRIMARY KEY ,
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE ,
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now (),
UNIQUE (user_id, post_id)
);
CREATE INDEX likes_post_id_created_idx ON likes (post_id, created_at DESC );
// services/likeService.ts
export async function toggleLike ( userId : string , postId : string ) {
// upsert で「いいね」を冪等に作成、conflict 時は何もしない
const { error : insertError } = await supabase
. from ( 'likes' )
. insert ({ user_id: userId, post_id: postId })
. select ()
. single ();
if (insertError && insertError.code === '23505' ) {
// unique violation → 既に「いいね」済み → 解除する
await supabase
. from ( 'likes' )
. delete ()
. eq ( 'user_id' , userId)
. eq ( 'post_id' , postId);
return { liked: false };
}
return { liked: true };
}
これで複数デバイスから同時に押されても、サーバー側の UNIQUE (user_id, post_id) がそのまま「LWW + 冪等性」を担保してくれます。CRDT は要りません。同じ判断はコメントの編集にも適用できます(編集履歴を残したいなら別テーブルで comment_revisions を作る方が結果的に安い)。
4. Supabase Realtime は約1万接続で詰まる前に段階的フォールバックを仕込む
Supabase Realtime は便利ですが、Free / Pro プランの 1 万同時接続(チャンネル数 × クライアント数)の壁は、思っているより早く来ます。私が運用している癒し系アプリのひとつで、夜 23 時のピーク時に Realtime のラグが 8 秒〜30 秒になる現象が出始めたのは、月間 MAU が 25 万を超えた頃でした。
完全に WebSocket をやめる必要はなく、「接続数で段階的にポーリングへ落とす 」のが現実的です。以下は私が使っている実装です:
// services/realtimeFallback.ts
import { createClient, RealtimeChannel } from '@supabase/supabase-js' ;
type FeedMode = 'realtime' | 'polling-30s' | 'polling-2m' | 'manual' ;
export class FeedSubscriber {
private channel : RealtimeChannel | null = null ;
private pollTimer : NodeJS . Timeout | null = null ;
private mode : FeedMode = 'realtime' ;
constructor (
private supabase : ReturnType < typeof createClient>,
private onUpdate : () => void
) {}
async start ( userId : string ) {
// Cloudflare KV や Edge Config から現在の運用モードを取得
this .mode = await this . fetchCurrentMode ();
switch ( this .mode) {
case 'realtime' :
return this . startRealtime (userId);
case 'polling-30s' :
return this . startPolling ( 30_000 );
case 'polling-2m' :
return this . startPolling ( 120_000 );
case 'manual' :
// 何もしない(プルトゥリフレッシュのみ)
return ;
}
}
private startRealtime ( userId : string ) {
this .channel = this .supabase
. channel ( `feed:${ userId }` )
. on ( 'postgres_changes' ,
{ event: 'INSERT' , schema: 'public' , table: 'posts' },
() => this . onUpdate ()
)
. subscribe (( status , err ) => {
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' ) {
// Realtime 側で詰まったら 30 秒ポーリングへ自動降格
this .mode = 'polling-30s' ;
this . startPolling ( 30_000 );
}
});
}
private startPolling ( intervalMs : number ) {
this .pollTimer = setInterval (() => this . onUpdate (), intervalMs);
}
async stop () {
if ( this .channel) await this .supabase. removeChannel ( this .channel);
if ( this .pollTimer) clearInterval ( this .pollTimer);
}
private async fetchCurrentMode () : Promise < FeedMode > {
// 運用側で `realtime → polling-30s → polling-2m → manual` を切り替える
const res = await fetch ( 'https://your-edge-config.example/feed-mode' );
return ( await res. json ()).mode ?? 'realtime' ;
}
}
要点は、Realtime の失敗を例外として握りつぶさず、運用モードを格下げする経路を最初から書く ことです。実運用では、polling-30s まで落としても 9 割のユーザーは違和感を持ちません。polling-2m まで落とすと「フィードが古い」というレビューが増えるので、Push 通知側で新規投稿を伝える運用に切り替えます。
5. プッシュ通知は「効く通知」を OPEN 率で決める
ソーシャル機能ができると、つい「フォロー」「いいね」「コメント」「リプライ」「メンション」と通知種別を増やしたくなります。私も最初に組んだときは 7 種類を全部 ON のデフォルトにしていました。
結果、通知 OPEN 率は 4% を切り、3 ヶ月で「通知許可をオフ」にしたユーザーが 38% に達しました。
私の現在の運用方針は「OPEN 率が 8% を下回る通知は黙って止める 」です。種別ごとに OPEN 率を計測し、しきい値を割ったものはサーバー側で送信をスキップします:
// supabase/functions/send-social-notifications/index.ts
const OPEN_RATE_THRESHOLD = 0.08 ; // 8%
const NOTIFICATION_TYPES = [ 'follow' , 'like' , 'comment' , 'reply' , 'mention' , 'tag' , 'group_invite' ] as const ;
async function shouldSendNotification (
type : typeof NOTIFICATION_TYPES [number],
recipientUserId : string
) : Promise < boolean > {
// 直近30日のグローバル OPEN 率
const { data } = await supabase. rpc ( 'get_notification_open_rate' , {
notification_type: type,
since_days: 30 ,
});
if ((data?.open_rate ?? 1 ) < OPEN_RATE_THRESHOLD ) {
return false ;
}
// ユーザー側のオプトアウトもチェック
const { data : prefs } = await supabase
. from ( 'notification_preferences' )
. select ( 'disabled_types' )
. eq ( 'user_id' , recipientUserId)
. single ();
if (prefs?.disabled_types?. includes (type)) return false ;
// 直近 30 分で同種別の通知を 3 件以上送っていたら一旦止める(バースト抑制)
const { count } = await supabase
. from ( 'notification_log' )
. select ( '*' , { count: 'exact' , head: true })
. eq ( 'user_id' , recipientUserId)
. eq ( 'type' , type)
. gt ( 'sent_at' , new Date (Date. now () - 30 * 60_000 ). toISOString ());
return (count ?? 0 ) < 3 ;
}
私の運用では、follow と mention だけが安定して OPEN 率 12% 以上を維持しており、tag と group_invite は 5 ヶ月ほどで自然に停止されました。「種別を増やせばエンゲージメントが伸びる」は幻想で、増やすほど通知許可率が下がり、効く通知も届かなくなる のが本当のところです。
6. 誤送信からの収束手順を最初に書いておく
ソーシャル機能で一番怖いのは「全ユーザーに同じ通知を誤って送ってしまった」「タイムラインが全員空になった」といったインシデントです。私自身、一度だけ Cron で WHERE user_id IS NOT NULL を書き忘れた SQL を本番に流し、25 万人に「あなたの投稿に新しいコメントがあります」を送ってしまったことがあります。
そのときに学んだのは、復旧手順をシステム要件に最初から含める ということです:
全通知送信前に「予定送信数」を Slack に webhook で投げる(10,000 件を超えたら手動承認を要求する)
Edge Function に EMERGENCY_NOTIFICATION_KILL_SWITCH 環境変数を仕込む。true なら一切送らない
KV に notification_circuit_breaker を置き、直近 5 分で送信エラー率 > 5% なら自動で開く
誤送信時の謝罪 in-app メッセージのテンプレを app/incidents/templates/ に書き起こしておく(再発時に書く時間はない)
派手なインシデント対応は不要です。「事前にスイッチを用意した自分」を信頼できる状態を作っておく ことが、ソーシャル機能を運用する個人開発者にとって最大の保険になります。
次に踏むべき一歩
ソーシャルアプリ機能を本番品質で組み上げるには、データベース・タイムライン・リアルタイム・通知のそれぞれで「過剰に作らない判断 」が必要です。このガイドの実装パターンは、実際のアプリ運用で「やってよかったこと」と「やらなくてよかったこと」の両方を反映した、最小構成です。
明日からの一歩としておすすめなのは、「自分のアプリでフォロー由来フィードと発見フィードが同じ画面に混ざっていないか」を確認すること です。混ざっていれば、まずタブを分けるだけで Day7 リテンションが数 % は変わる可能性があります。私自身、ここを直したときに最も大きなリテンション改善を見ました。
私もまだ、自分の壁紙アプリにソーシャル機能をどう載せるかを少しずつ試している最中です。同じ課題に取り組んでいる方の参考になれば嬉しく、お読みいただきありがとうございました。
参考書籍