Build a Tiny Social App: Follows, Mentions, and Email Notifications

A deliberately small social app — posts, follows, and mentions — with Notify powering high-signal transactional email (welcome, new follower, mention), not digests-as-newsletters.

Most “build a social network” tutorials skip email until the end — then paste SMTP into a firehose of notifications. This one flips it: Notify is the email infrastructure from day one, and we only email high-signal events.

Scope (intentionally tiny):

  • Users, posts, follows
  • @mentions in post body
  • Email for: welcome, new follower, mention
  • Optional: user-opted “daily summary” framed as a transactional digest they enabled in settings — not a newsletter product

Stack sketch: Next.js App Router + Postgres (Supabase or any host) + Notify.

What we will not build

  • Newsletters, growth loops, or drip campaigns
  • Real-time chat
  • A template studio
  • MCP wrappers around fetch

Data model

create table profiles (
  id uuid primary key references auth.users(id), -- or your users table
  username text unique not null,
  email text not null,
  notify_follows boolean default true,
  notify_mentions boolean default true,
  notify_daily_summary boolean default false,
  welcome_email_sent_at timestamptz,
  created_at timestamptz default now()
);

create table posts (
  id uuid primary key default gen_random_uuid(),
  author_id uuid not null references profiles(id),
  body text not null,
  created_at timestamptz default now()
);

create table follows (
  follower_id uuid references profiles(id),
  following_id uuid references profiles(id),
  created_at timestamptz default now(),
  primary key (follower_id, following_id)
);

create table email_outbox (
  id uuid primary key default gen_random_uuid(),
  dedupe_key text unique not null,
  created_at timestamptz default now()
);

email_outbox.dedupe_key stops double sends on retries (mention:{post_id}:{user_id}, etc.).

Notify helper

// lib/notify.ts
export async function sendEmail(opts: {
  to: string;
  subject: string;
  message: string;
}) {
  const res = await fetch('https://notify.cx/api/email/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': process.env.NOTIFY_API_KEY!
    },
    body: JSON.stringify({
      from: 'notify@your-verified-domain.com',
      ...opts
    })
  });
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

export async function sendOnce(dedupeKey: string, send: () => Promise<unknown>) {
  const inserted = await tryInsertOutbox(dedupeKey);
  if (!inserted) return;
  await send();
}

1. Welcome email on profile create

export async function sendWelcome(profile: {
  id: string;
  email: string;
  username: string;
}) {
  if (profile /* already has welcome_email_sent_at */) return;

  await sendOnce(`welcome:${profile.id}`, () =>
    sendEmail({
      to: profile.email,
      subject: `Welcome to TinySocial, @${profile.username}`,
      message: `
        <h1>You’re in</h1>
        <p>Follow people, post updates, and we’ll only email you for high-signal events.</p>
        <p><a href="${process.env.NEXT_PUBLIC_APP_URL}/home">Open the app</a></p>
      `
    })
  );

  await markWelcomeSent(profile.id);
}

Call from your signup Server Action after the profile row exists.

2. New follower email

export async function onFollow(followerId: string, followingId: string) {
  if (followerId === followingId) return;

  await db.follows.insert({ followerId, followingId });

  const [follower, following] = await Promise.all([
    getProfile(followerId),
    getProfile(followingId)
  ]);

  if (!following.notify_follows) return;

  await sendOnce(`follow:${followerId}:${followingId}`, () =>
    sendEmail({
      to: following.email,
      subject: `@${follower.username} followed you`,
      message: `
        <p><strong>@${follower.username}</strong> followed you on TinySocial.</p>
        <p><a href="${process.env.NEXT_PUBLIC_APP_URL}/u/${follower.username}">View profile</a></p>
      `
    })
  );
}

3. Mention email

Parse @username tokens when a post is created:

const MENTION_RE = /@([a-zA-Z0-9_]{2,32})/g;

export async function onCreatePost(authorId: string, body: string) {
  const post = await db.posts.insert({ authorId, body });
  const usernames = [...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase());
  const unique = [...new Set(usernames)];

  const author = await getProfile(authorId);

  for (const username of unique) {
    const mentioned = await getProfileByUsername(username);
    if (!mentioned || mentioned.id === authorId) continue;
    if (!mentioned.notify_mentions) continue;

    await sendOnce(`mention:${post.id}:${mentioned.id}`, () =>
      sendEmail({
        to: mentioned.email,
        subject: `@${author.username} mentioned you`,
        message: `
          <p><strong>@${author.username}</strong> mentioned you:</p>
          <blockquote>${escapeHtml(body.slice(0, 280))}</blockquote>
          <p><a href="${process.env.NEXT_PUBLIC_APP_URL}/posts/${post.id}">View post</a></p>
        `
      })
    );
  }

  return post;
}

Escape HTML in user content before interpolating into email.

4. Optional daily summary (careful wording)

Only if notify_daily_summary is opt-in in settings. Cron selects users who enabled it and sends a short “here’s what you missed” list of follows/mentions already stored in-app. That is still a user-requested transactional summary, not a marketing blast to cold contacts.

Do not pitch this as a newsletter platform. If product wants campaigns, use a different vendor and domain.

// app/api/cron/daily-summary/route.ts — protect with CRON_SECRET
export async function GET(request: Request) {
  if (request.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }

  const today = new Date().toISOString().slice(0, 10);
  const users = await usersWithDailySummaryEnabled();

  for (const user of users) {
    const items = await unseenHighSignalSince(user.id, /* last 24h */ );
    if (items.length === 0) continue;

    await sendOnce(`daily:${user.id}:${today}`, () =>
      sendEmail({
        to: user.email,
        subject: 'Your TinySocial summary',
        message: renderSummaryHtml(items)
      })
    );
  }

  return Response.json({ ok: true, users: users.length });
}

Preference center (required)

Every notification email should link to /settings/notifications where users can disable follows, mentions, and summaries independently. Honor those flags before every send.

Production checklist

  • Verified sending domain
  • Dedupe keys for every email kind
  • Preference flags checked server-side
  • HTML-escaped user content
  • Rate limits on follow/post APIs
  • Bounce webhooks when volume justifies (webhooks)

Why Notify fits

Social notification volume is bursty and transactional. You need a small send API, logs when someone says “I didn’t get the mention email,” and no marketing suite gravity. Free 1k / Pro $10 for 10k covers early communities.

Quick start · Sending emails · Pricing

Frequently asked questions

How should social apps send notification email?

Trigger on real events (comments, likes, follows), batch bursty activity when needed, and offer preferences for non-essential mail.

Can Notify power social notification email?

Yes — same send API for each notification type. Use webhooks for bounces and keep HTML in your app. Quick start.

Should every like send an email?

Usually no. Batch or digest high-frequency events. Empty digests should not send at all.