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.