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