Back to Blog

How to Build a Social Network Feature Set for Your SaaS

How to Build a Social Network Feature Set for Your SaaS

You are building a SaaS product and someone on the team says, “We should add social features.” A feed, profiles, following, maybe messaging. It sounds simple because you use social platforms every day. It is not simple. Social features are among the most architecturally demanding things you can add to an application.

We learned this firsthand building MindHyv, which has a full social layer — activity feeds, user profiles, a follow system, direct messaging, and real-time notifications — sitting alongside booking, invoicing, and commerce modules. This post covers the architecture patterns we used and the decisions you need to make before writing any code.

Decide What “Social” Means for Your Product

Not every product needs a full social network. Before you build anything, get specific about which social primitives you actually need:

  • Profiles. Public identity pages. Almost every SaaS with user-generated content needs these.
  • Following/connections. Directional (Twitter-style follow) or bidirectional (LinkedIn-style connection). This determines your entire data model.
  • Activity feed. A timeline of events from people you follow. This is the most complex piece by far.
  • Reactions and comments. Engagement on individual pieces of content.
  • Direct messaging. Private 1:1 or group conversations.
  • Notifications. Alerting users about activity that involves them.

For MindHyv, we needed all of them because the social layer is the primary surface of the product. For most SaaS products, you probably need profiles, a feed, and notifications. Messaging and full connection systems are often overkill early on.

The Follow System: Schema and Queries

The follow/connection system is the foundation that everything else builds on. Here is the schema pattern we use:

create table follows (
  id uuid primary key default gen_random_uuid(),
  follower_id uuid references profiles(id) on delete cascade,
  following_id uuid references profiles(id) on delete cascade,
  created_at timestamptz default now(),
  unique(follower_id, following_id)
);

-- Indexes for the two primary query patterns
create index idx_follows_follower on follows(follower_id);
create index idx_follows_following on follows(following_id);

-- Materialized counts for profile display
-- Recalculated via trigger, not computed on every query
alter table profiles add column follower_count integer default 0;
alter table profiles add column following_count integer default 0;

The key design decision: store counts as materialized columns, not as count(*) queries. When a profile page loads, you do not want to count rows in the follows table. At even modest scale (10,000 users), that query becomes a bottleneck.

The trigger to maintain counts:

create or replace function update_follow_counts()
returns trigger as $$
begin
  if TG_OP = 'INSERT' then
    update profiles
    set following_count = following_count + 1
    where id = NEW.follower_id;

    update profiles
    set follower_count = follower_count + 1
    where id = NEW.following_id;
  elsif TG_OP = 'DELETE' then
    update profiles
    set following_count = following_count - 1
    where id = OLD.follower_id;

    update profiles
    set follower_count = follower_count - 1
    where id = OLD.following_id;
  end if;
  return null;
end;
$$ language plpgsql;

create trigger follow_counts_trigger
after insert or delete on follows
for each row execute function update_follow_counts();

For the follow/unfollow API, keep it idempotent. Double-following should not error — it should be a no-op:

async function toggleFollow(targetId: string): Promise<boolean> {
  const { data: existing } = await supabase
    .from('follows')
    .select('id')
    .eq('follower_id', currentUser.id)
    .eq('following_id', targetId)
    .maybeSingle();

  if (existing) {
    await supabase.from('follows').delete().eq('id', existing.id);
    return false; // unfollowed
  }

  await supabase.from('follows').insert({
    follower_id: currentUser.id,
    following_id: targetId,
  });
  return true; // followed
}

Activity Feed Architecture

The feed is where social features get hard. You have two fundamental approaches:

Fan-out on write (push model). When a user creates a post, you write a feed item for every follower. Reads are fast — each user’s feed is pre-computed. Writes scale with follower count.

Fan-out on read (pull model). When a user loads their feed, you query all the people they follow and pull recent posts. Reads are slow and scale with following count. Writes are simple — just insert the post.

For most SaaS products, fan-out on write is the right choice. Your users are reading their feed far more often than they are posting. Optimize for the common case.

Here is the schema:

-- Content table
create table posts (
  id uuid primary key default gen_random_uuid(),
  author_id uuid references profiles(id) on delete cascade,
  content text not null,
  media_urls text[] default '{}',
  post_type text default 'standard', -- standard, update, milestone
  like_count integer default 0,
  comment_count integer default 0,
  created_at timestamptz default now()
);

-- Fan-out feed table
create table feed_items (
  id uuid primary key default gen_random_uuid(),
  recipient_id uuid references profiles(id) on delete cascade,
  post_id uuid references posts(id) on delete cascade,
  author_id uuid references profiles(id) on delete cascade,
  created_at timestamptz default now()
);

-- Index for feed retrieval — this is the hot path
create index idx_feed_recipient_created
on feed_items(recipient_id, created_at desc);

The fan-out happens via a database function triggered on post creation:

create or replace function fan_out_post()
returns trigger as $$
begin
  -- Insert a feed item for each follower
  insert into feed_items (recipient_id, post_id, author_id, created_at)
  select
    f.follower_id,
    NEW.id,
    NEW.author_id,
    NEW.created_at
  from follows f
  where f.following_id = NEW.author_id;

  -- Also insert for the author (they see their own posts)
  insert into feed_items (recipient_id, post_id, author_id, created_at)
  values (NEW.author_id, NEW.id, NEW.author_id, NEW.created_at);

  return NEW;
end;
$$ language plpgsql;

create trigger post_fan_out
after insert on posts
for each row execute function fan_out_post();

Loading the feed is now a simple query:

async function loadFeed(profileId: string, cursor?: string) {
  let query = supabase
    .from('feed_items')
    .select(`
      id,
      created_at,
      post:posts (
        id, content, media_urls, post_type,
        like_count, comment_count, created_at,
        author:profiles (id, display_name, handle, avatar_url)
      )
    `)
    .eq('recipient_id', profileId)
    .order('created_at', { ascending: false })
    .limit(20);

  if (cursor) {
    query = query.lt('created_at', cursor);
  }

  return query;
}

The cursor parameter enables infinite scroll pagination. The client sends the created_at of the last item it has, and the server returns the next 20 items older than that.

The Celebrity Problem

Fan-out on write has a well-known weakness: what happens when a user with 100,000 followers posts? You are inserting 100,000 rows into the feed table. That is slow and puts pressure on your database.

The industry solution, popularized by Twitter’s architecture, is a hybrid approach: fan-out on write for normal users, fan-out on read for high-follower accounts. Posts from “celebrities” are not pre-distributed. Instead, the feed query merges pre-computed feed items with a real-time pull of recent posts from high-follower accounts the user follows.

For most SaaS products, you will never hit this problem. If your platform has users with more than 10,000 followers, you have a good problem to solve. Until then, pure fan-out on write is fine.

In MindHyv, our largest accounts have a few hundred followers. Pure fan-out on write handles it without any measurable latency impact.

Reactions and Comments

Likes and comments are structurally simple but have subtle performance considerations.

create table post_likes (
  id uuid primary key default gen_random_uuid(),
  post_id uuid references posts(id) on delete cascade,
  profile_id uuid references profiles(id) on delete cascade,
  created_at timestamptz default now(),
  unique(post_id, profile_id)
);

create table comments (
  id uuid primary key default gen_random_uuid(),
  post_id uuid references posts(id) on delete cascade,
  author_id uuid references profiles(id) on delete cascade,
  content text not null,
  created_at timestamptz default now()
);

Like counts and comment counts are maintained as materialized columns on the posts table (same pattern as follower counts), updated via triggers.

The important UX concern: when loading a feed, you need to know whether the current user has liked each post. A naive approach queries the post_likes table for each post individually. A better approach batch-checks:

async function checkUserLikes(
  postIds: string[],
  profileId: string
): Promise<Set<string>> {
  const { data } = await supabase
    .from('post_likes')
    .select('post_id')
    .eq('profile_id', profileId)
    .in('post_id', postIds);

  return new Set(data?.map((d) => d.post_id) ?? []);
}

Call this once after loading the feed, passing all 20 post IDs. One query instead of twenty.

An activity feed timeline displaying user posts and engagement updates

Notifications: The Unifying Layer

Notifications tie the entire social layer together. Every meaningful action — follow, like, comment, mention — should generate a notification for the relevant user.

The schema we use in MindHyv supports notifications across all modules, not just social:

type NotificationType =
  | 'follow'           // Someone followed you
  | 'like'             // Someone liked your post
  | 'comment'          // Someone commented on your post
  | 'mention'          // Someone mentioned you
  | 'booking_new'      // Someone booked an appointment
  | 'booking_cancel'   // A booking was cancelled
  | 'invoice_paid'     // An invoice was paid
  | 'order_new';       // Someone purchased from your store

The notification is created by the same database triggers that handle the social actions. When a like is inserted, the trigger creates both the materialized count update and the notification in a single transaction.

For real-time delivery, we use Supabase Realtime subscriptions:

function subscribeNotifications(
  profileId: string,
  onNotification: (n: Notification) => void
) {
  return supabase
    .channel(`notifications:${profileId}`)
    .on(
      'postgres_changes',
      {
        event: 'INSERT',
        schema: 'public',
        table: 'notifications',
        filter: `recipient_id=eq.${profileId}`,
      },
      (payload) => onNotification(payload.new as Notification)
    )
    .subscribe();
}

We wrote more about our notification architecture in our post on building a notification system.

Messaging: Build or Buy

Direct messaging is the one social feature where we genuinely recommend evaluating third-party services before building your own. Real-time messaging requires:

  • WebSocket connections for live delivery
  • Offline message queuing and delivery on reconnect
  • Read receipts and typing indicators
  • Media attachments with upload and thumbnailing
  • End-to-end encryption if your users expect privacy

For MindHyv, we built messaging on Supabase Realtime because we were already deeply invested in the Supabase ecosystem and needed tight integration with our contact and notification systems. But if messaging is not core to your product, services like Stream, SendBird, or even a simple integration with an existing chat platform will save you months.

If you do build it yourself, the core schema is:

create table conversations (
  id uuid primary key default gen_random_uuid(),
  created_at timestamptz default now()
);

create table conversation_members (
  conversation_id uuid references conversations(id) on delete cascade,
  profile_id uuid references profiles(id) on delete cascade,
  last_read_at timestamptz default now(),
  primary key (conversation_id, profile_id)
);

create table messages (
  id uuid primary key default gen_random_uuid(),
  conversation_id uuid references conversations(id) on delete cascade,
  sender_id uuid references profiles(id) on delete cascade,
  content text not null,
  created_at timestamptz default now()
);

The last_read_at column on conversation_members is how you calculate unread counts without scanning the entire messages table.

A messaging chat interface with conversation threads and direct messages

Row-Level Security for Social Data

Social features require careful access control. Not all posts are public. Not all profiles are visible to everyone. Blocked users should not see your content.

With Supabase, we enforce this at the database level using row-level security:

-- Posts are visible if:
-- 1. The viewer follows the author, OR
-- 2. The viewer is the author
create policy "Posts are visible to followers and author"
on posts for select using (
  author_id = auth.uid()
  or exists (
    select 1 from follows
    where follower_id = auth.uid()
    and following_id = posts.author_id
  )
);

We have a detailed post on Supabase row-level security patterns that covers more complex policies including blocked users and private accounts.

A social media interface showing notifications and interaction elements

Performance Considerations at Scale

Social features are read-heavy and latency-sensitive. Users expect feeds to load in under 200 milliseconds. Here are the patterns that keep things fast:

  1. Cursor-based pagination, not offset. OFFSET 1000 scans and discards 1,000 rows. WHERE created_at < $cursor uses the index directly.
  2. Materialized counts everywhere. Never COUNT(*) in a hot path. Maintain counters via triggers.
  3. Batch queries for related data. Load 20 posts, then batch-load likes and comments for those 20 posts. Two queries total, not forty.
  4. Cache profile data aggressively. Profile cards appear in every feed item. Cache them on the client after first load.
  5. Limit real-time subscriptions. Subscribe to notifications and the current conversation. Do not subscribe to the entire feed — pull-to-refresh is fine for feeds.

When to Add Social Features

Our recommendation: add social features when they serve your product’s core value proposition, not because they drive engagement metrics. Social features that do not connect to the product’s purpose feel hollow and users ignore them.

In MindHyv, the social layer works because it is the connective tissue between business tools. An entrepreneur’s followers are their potential clients. The feed is where they showcase their expertise. The profile is their booking page.

If your SaaS product has users who would genuinely benefit from seeing each other’s activity — think project management tools, learning platforms, or community-driven marketplaces — social features are worth the investment. If you are adding a feed to an analytics dashboard because a PM read an article about engagement, reconsider.

We covered the broader MindHyv architecture in our post on building an all-in-one business platform. If you are planning to add social features to your SaaS product and want to talk through the architecture before you commit, reach out at [email protected].