Build a Link-in-Bio SaaS
Build and deploy a link-in-bio SaaS with Next.js, PostgreSQL and Stripe: public profile pages, click analytics, subscriptions and a VPS deploy.
Build a production-ready link-in-bio product — the Linktree model — with Next.js, PostgreSQL and Stripe: a public
/[username]page rendered from your database, click analytics you own (no tracking scripts), a free/Pro subscription split, and a one-command Docker deploy on a $4.49 VPS. Verified 2026-08-20.
What we’re building
A two-part app:
- Public side: every user gets a beautiful
https://app.yourapp.com/usernamepage listing their links. Each click fires aPOST /api/links/:id/clickthat increments a counter in PostgreSQL — the data is yours. - Private side: a dashboard where the owner creates, deletes and counts links. Free accounts get 3 links; Pro (Stripe subscription, $6/month) gets unlimited links and click counts in the dashboard.
flowchart LR
Visitor -->|GET /username| Next[Next.js App Router]
Owner -->|session cookie| Next
Next --> PG[(PostgreSQL)]
Next --> Stripe[Stripe checkout + webhook]
Next -->|302 + click counter| Target[Target URL]
What you’ll learn
- How to build public profile pages at
/[username]that render only the owner’s links and stay public even with an auth middleware running - How to hand-roll session auth: HMAC-signed cookie, typed middleware with
node:crypto, and an ownership check on every link query - How to sell a subscription with Stripe checkout + webhook and enforce a per-plan link limit
- How to run click analytics you own — one parameterized
UPDATE ... clicks = clicks + 1per click, no third-party script - How to deploy the whole thing to a VPS with Docker Compose + Caddy (HTTPS included) in about ten commands
Prerequisites
- Node.js 22+
- PostgreSQL 17 (local: any dev install or Docker)
- A Stripe account (test mode keys are fine for the whole tutorial)
- A VPS with 2 GB RAM for the end (Hetzner CX22-class)
Project structure
bio/
├── app/
│ ├── [username]/
│ │ ├── page.tsx # public profile page (server component)
│ │ └── LinkCard.tsx # client card that fires the click counter
│ ├── api/
│ │ ├── auth/
│ │ │ ├── signup/route.ts
│ │ │ ├── login/route.ts
│ │ │ └── logout/route.ts
│ │ ├── billing/checkout/route.ts
│ │ ├── links/
│ │ │ ├── route.ts # GET list, POST create
│ │ │ └── [id]/
│ │ │ ├── route.ts # DELETE (ownership-checked)
│ │ │ └── click/route.ts # public click counter
│ │ ├── webhooks/stripe/route.ts
│ │ └── healthz/route.ts
│ ├── dashboard/
│ │ ├── page.tsx # server component: list links + counts
│ │ └── controls.tsx # client: NewLinkForm, Delete, Upgrade
│ ├── login/page.tsx
│ ├── pricing/page.tsx
│ ├── signup/page.tsx
│ ├── layout.tsx # from the scaffold
│ └── page.tsx # landing page
├── lib/
│ ├── auth.ts # getCurrentUser from the session cookie
│ ├── billing.ts # Stripe customer + checkout session
│ ├── db.ts # pg connection pool (singleton)
│ ├── pg.ts # isUniqueViolation helper
│ └── session.ts # HMAC-signed stateless session
├── middleware.ts # auth guard (nodejs runtime)
├── migrations/0001_init.sql # schema (idempotent)
├── .env.example
├── Caddyfile
├── Dockerfile
├── docker-compose.yml
└── next.config.ts # output: 'standalone'
1. Create the project
npx create-next-app@latest bio --ts --app --tailwind --eslint --no-src-dir --import-alias "@/*"
cd bio
npm i pg bcryptjs zod stripe
npm i -D @types/pg
These are the only runtime dependencies beyond the Next.js scaffold: pg talks to PostgreSQL with real prepared statements, bcryptjs hashes passwords, zod validates every request body, and stripe talks to the Stripe API.
Enable the standalone output the Docker image needs:
next.config.ts:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;
2. Database schema
migrations/0001_init.sql:
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
bio TEXT NOT NULL DEFAULT '',
accent_color TEXT NOT NULL DEFAULT '#2563eb',
plan TEXT NOT NULL DEFAULT 'free',
stripe_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
url TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
clicks INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS links_user_idx ON links (user_id, position);
gen_random_uuid()is built into PostgreSQL as of 13 — no extension needed on PG 17, so this file applies cleanly to a freshpostgres:17container or your local Postgres.links.user_id → users.id ON DELETE CASCADEmeans deleting an account removes its links; you never have orphaned rows.links_user_idxmakes the two queries that matter fast:WHERE user_id = $1 ORDER BY positionon the profile page and dashboard.clicksis a plain counter column. Good enough for a link-in-bio at this scale; flush-to-aggregate alternatives are in “Improvements”.
Create the database and apply it locally:
docker run -d --name bio-pg --rm -p 5432:5432 \
-e POSTGRES_USER=bio -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=bio postgres:17
psql "postgres://bio:dev@localhost:5432/bio" -f migrations/0001_init.sql
Should we turn on RLS?
The app connects to PostgreSQL with a single role via the connection pool, and every query already filters rows by user_id = $1 in SQL. Enabling row-level security would add real value only if a second app or direct database access shares this database later — and then the pool’s single role can’t know who the HTTP user is, so you’d have to set current_setting('app.current_user_id', ...) inside each transaction. For this single-app SaaS, application-level ownership checks are the enforcement layer. (Same recommendation as our SaaS with Next.js, PostgreSQL and Stripe guide.)
3. Environment variables
.env (root of the project — not committed; .gitignore it):
DATABASE_URL=postgres://bio:dev@localhost:5432/bio
SESSION_SECRET=change-me-32-random-characters
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE=price_1...
NEXT_PUBLIC_APP_URL=http://localhost:3000
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string. On the VPS it points at the postgres service instead of localhost. |
SESSION_SECRET |
yes | ≥ 32 random chars: openssl rand -hex 32 |
STRIPE_SECRET_KEY |
yes | sk_test_... from Stripe dashboard |
STRIPE_WEBHOOK_SECRET |
yes | whsec_... printed by stripe listen (section 9) |
STRIPE_PRO_PRICE |
yes | price_1... of the Pro product (section 9 shows where to copy it) |
NEXT_PUBLIC_APP_URL |
no | Absolute app URL; used as fallback when no Origin header is present |
4. Session signing
We hand-roll auth with a stateless, HMAC-signed cookie — no Auth.js, no sessions table, and the guard can verify it in the middleware without touching the database.
lib/session.ts:
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.SESSION_SECRET;
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export function signSession(userId: string): string {
const payload = Buffer.from(
JSON.stringify({ uid: userId, exp: Date.now() + MAX_AGE_MS }),
).toString('base64url');
const sig = createHmac('sha256', SECRET!).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
export function verifySession(token: string | undefined): string | null {
if (!token) return null;
const [payload, sig] = token.split('.');
if (!payload || !sig) return null;
const expected = createHmac('sha256', SECRET!).update(payload).digest('base64url');
if (
sig.length !== expected.length ||
!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
return null;
}
try {
const { uid, exp } = JSON.parse(
Buffer.from(payload, 'base64url').toString('utf8'),
) as { uid: string; exp: number };
if (Date.now() > exp) return null;
return uid;
} catch {
return null; // malformed payload is simply "not logged in"
}
}
timingSafeEqualcompares signatures in constant time — a timing attack can’t learn the secret byte by byte.- Nothing here requires a database lookup, which is what lets the middleware reject anonymous requests cheaply.
The connection pool goes in lib/db.ts — a singleton so hot reload in dev doesn’t open a new pool per request:
import { Pool } from 'pg';
const globalForPool = globalThis as unknown as { pool?: Pool };
export const pool = globalForPool.pool ?? new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
});
if (process.env.NODE_ENV !== 'production') {
globalForPool.pool = pool;
}
And lib/pg.ts — PostgreSQL errors are typed, so we can catch a unique violation (23505) and turn it into a clean 409:
import { DatabaseError } from 'pg';
export function isUniqueViolation(err: unknown): boolean {
return err instanceof DatabaseError && err.code === '23505';
}
5. Auth API
lib/auth.ts — turn the verified cookie into the current user row (used by every protected route):
import { cookies } from 'next/headers';
import { pool } from '@/lib/db';
import { verifySession } from '@/lib/session';
export type SessionUser = {
id: string;
email: string;
username: string;
plan: string;
accent_color: string;
};
export async function getCurrentUser(): Promise<SessionUser | null> {
const token = (await cookies()).get('session')?.value;
const userId = verifySession(token);
if (!userId) return null;
const { rows } = await pool.query<SessionUser>(
`SELECT id, email, username, plan, accent_color
FROM users WHERE id = $1`,
[userId],
);
return rows[0] ?? null;
}
app/api/auth/signup/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { hash } from 'bcryptjs';
import { pool } from '@/lib/db';
import { isUniqueViolation } from '@/lib/pg';
import { signSession } from '@/lib/session';
const bodySchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
username: z
.string()
.regex(/^[a-z0-9]{3,20}$/, '3-20 chars, lowercase letters and numbers only'),
});
export async function POST(req: NextRequest) {
try {
const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { email, password, username } = parsed.data;
const clean = {
email: email.toLowerCase().trim(),
username: username.toLowerCase(),
};
const passwordHash = await hash(password, 12);
const result = await pool.query<{ id: string }>(
`INSERT INTO users (email, username, password_hash)
VALUES ($1, $2, $3)
RETURNING id`,
[clean.email, clean.username, passwordHash],
);
const res = NextResponse.json({ ok: true }, { status: 201 });
res.cookies.set('session', signSession(result.rows[0].id), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
});
return res;
} catch (err) {
if (isUniqueViolation(err)) {
return NextResponse.json({ error: 'email or username already taken' }, { status: 409 });
}
console.error('signup failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}
- Validation is one
zod.safeParse— no request body can reach the database unvalidated. - The username regex is the same rule applied again in the signup form, so the page-level
patternattribute and the API can’t disagree. It’s also exactly what the profile route will trust when looking up/[username]. - Duplicate email or username surfaces as the Postgres
23505error and becomes a409— no pre-check race, the database is the authority.
app/api/auth/login/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { compare } from 'bcryptjs';
import { pool } from '@/lib/db';
import { signSession } from '@/lib/session';
const bodySchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export async function POST(req: NextRequest) {
try {
const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { rows } = await pool.query<{ id: string; password_hash: string }>(
'SELECT id, password_hash FROM users WHERE email = $1',
[parsed.data.email.toLowerCase().trim()],
);
const user = rows[0];
// Same response whether the email exists or not — no user enumeration.
if (!user || !(await compare(parsed.data.password, user.password_hash))) {
return NextResponse.json({ error: 'invalid credentials' }, { status: 401 });
}
const res = NextResponse.json({ ok: true });
res.cookies.set('session', signSession(user.id), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
});
return res;
} catch (err) {
console.error('login failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}
app/api/auth/logout/route.ts:
import { NextResponse } from 'next/server';
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set('session', '', { maxAge: 0, path: '/' });
return res;
}
6. Middleware guard
middleware.ts — protects only the private area; profile pages and webhooks stay public:
import { NextRequest, NextResponse } from 'next/server';
import { verifySession } from '@/lib/session';
const PRIVATE_PREFIXES = ['/dashboard', '/api/links', '/api/billing'];
// Node.js runtime so node:crypto works in session verification (Next.js >= 15.2).
export const runtime = 'nodejs';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isPrivate = PRIVATE_PREFIXES.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
if (!isPrivate) return NextResponse.next();
const token = request.cookies.get('session')?.value;
if (!verifySession(token)) {
const login = new URL('/login', request.url);
login.searchParams.set('next', pathname);
return NextResponse.redirect(login);
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};
Why this shape, specifically:
- Public by default, private by prefix.
/[username]pages,/login,/signup,/pricing,/api/auth/*,/api/webhooks/*and the public click counter never hit the auth check. Only/dashboard*,/api/links*and/api/billing*require a session. This is the inverse of a big denylist, and it’s what keeps a new public page from accidentally becoming private later. verifySessionis pure HMAC + timestamp — no database call in the hot path of a redirect.- The
matcherexcludes static assets, favicon and any path containing a dot.
7. Link API + click analytics
app/api/links/route.ts — list and create, with the free-plan limit enforced in code:
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { pool } from '@/lib/db';
import { getCurrentUser } from '@/lib/auth';
const createSchema = z.object({
title: z.string().trim().min(1).max(80),
url: z
.string()
.url()
.refine((u) => u.startsWith('https://') || u.startsWith('http://'), {
message: 'URL must start with http:// or https://',
}),
});
const FREE_LINK_LIMIT = 3;
export async function GET() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
const { rows } = await pool.query(
`SELECT id, title, url, position, clicks, created_at
FROM links WHERE user_id = $1 ORDER BY position ASC, created_at ASC`,
[user.id],
);
return NextResponse.json({ links: rows });
}
export async function POST(req: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
const parsed = createSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
// Free accounts: hard limit, checked atomically with the insert path below.
if (user.plan !== 'pro') {
const { rows } = await pool.query<{ n: number }>(
'SELECT count(*)::int AS n FROM links WHERE user_id = $1',
[user.id],
);
if (rows[0].n >= FREE_LINK_LIMIT) {
return NextResponse.json(
{ error: `free plan allows ${FREE_LINK_LIMIT} links — upgrade to Pro` },
{ status: 403 },
);
}
}
const { rows } = await pool.query(
`INSERT INTO links (user_id, title, url, position)
VALUES ($1, $2, $3,
(SELECT coalesce(max(position), 0) + 1 FROM links WHERE user_id = $1))
RETURNING id, title, url, position, clicks`,
[user.id, parsed.data.title, parsed.data.url],
);
return NextResponse.json({ link: rows[0] }, { status: 201 });
} catch (err) {
console.error('create link failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}
count(*)::intmatters: without the cast,pgreturnscount(*)as a bigint string, and>= 3would compare weirdly. The cast keeps it a JS number.- The
positionsubquery appends the new link after the current max — no separate reorder API needed for the MVP. - Every value is a
$nplaceholder; user input can never be interpolated into SQL.
app/api/links/[id]/route.ts — delete, ownership-checked. Note params is a Promise in Next.js 15:
import { NextRequest, NextResponse } from 'next/server';
import { pool } from '@/lib/db';
import { getCurrentUser } from '@/lib/auth';
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
const { id } = await params;
const result = await pool.query(
'DELETE FROM links WHERE id = $1 AND user_id = $2 RETURNING id',
[id, user.id],
);
if (result.rowCount === 0) {
return NextResponse.json({ error: 'link not found' }, { status: 404 });
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error('delete link failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}
The AND user_id = $2 is the security boundary: an attacker who guesses another user’s link UUID gets a 404, never a deletion.
app/api/links/[id]/click/route.ts — the public click counter. This is the entire analytics pipeline:
import { NextRequest, NextResponse } from 'next/server';
import { pool } from '@/lib/db';
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
// Unknown ids are a no-op (0 rows updated) yet still answer 200:
// a click that fails must never break the redirect in the browser.
await pool.query('UPDATE links SET clicks = clicks + 1 WHERE id = $1', [id]);
return NextResponse.json({ ok: true });
} catch (err) {
console.error('click tracking failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}
One parameterized UPDATE per click, landed server-side before the target page opens. No Google Analytics, no pixel, no consent banner, and the counter lives in your own Postgres row.
app/api/healthz/route.ts — the probe your uptime monitor will hit later:
import { NextResponse } from 'next/server';
export function GET() {
return NextResponse.json({ ok: true });
}
8. Public profile page
app/[username]/page.tsx — a server component that renders only the owner’s links:
import { notFound } from 'next/navigation';
import { pool } from '@/lib/db';
import { LinkCard } from './LinkCard';
export const dynamic = 'force-dynamic'; // links change on every edit — no cache
type ProfileProps = { params: Promise<{ username: string }> };
export default async function ProfilePage({ params }: ProfileProps) {
const { username } = await params;
const userRes = await pool.query(
`SELECT id, username, display_name, bio
FROM users WHERE username = $1`,
[username.toLowerCase()],
);
const user = userRes.rows[0];
if (!user) notFound();
const linksRes = await pool.query(
`SELECT id, title, url, position
FROM links WHERE user_id = $1 ORDER BY position ASC, created_at ASC`,
[user.id],
);
return (
<main style={{ maxWidth: 480, margin: 'auto', padding: '2rem 1rem' }}>
<header style={{ textAlign: 'center' }}>
<h1 style={{ fontSize: '1.5rem', margin: '0 0 0.25rem' }}>
{user.display_name || `@${user.username}`}
</h1>
{user.bio ? <p style={{ color: '#6b7280' }}>{user.bio}</p> : null}
</header>
<nav>
{linksRes.rows.map(
(link: { id: string; title: string; url: string }) => (
<LinkCard key={link.id} link={link} />
),
)}
</nav>
</main>
);
}
app/[username]/LinkCard.tsx — a tiny client component so the click can fire without blocking the navigation:
'use client';
type LinkRow = { id: string; title: string; url: string };
export function LinkCard({ link }: { link: LinkRow }) {
async function onOpen() {
try {
await fetch(`/api/links/${link.id}/click`, { method: 'POST' });
} catch {
// analytics must never block the redirect — swallow and move on
}
}
return (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
onClick={onOpen}
style={{
display: 'block',
padding: '0.9rem 1rem',
margin: '0.6rem 0',
border: '1px solid #e5e7eb',
borderRadius: 8,
textDecoration: 'none',
color: '#111827',
textAlign: 'center',
background: '#fff',
}}
>
{link.title}
</a>
);
}
- The profile route lowercases the URL segment and looks up by
username— the same normalized value the signup route stored, soGET /scoobyandGET /Scoobyhit the same user, and the middleware already lets single-segment pages through. - React escapes
display_nameandbiowhen rendering, so a user pasting<script>into their bio outputs it as text — no XSS from user content. target="_blank"always pairs withrel="noopener noreferrer"so the opened site can’twindow.openeryour profile page.
9. Stripe billing
First, the step most guides skip — where to get the price ID:
- Stripe Dashboard → Test mode → Products → Add product → name it “Pro”, price $6/month, recurring → Save product.
- Open the product → copy the Price ID (
price_1...). - Set
STRIPE_PRO_PRICE=price_1...in.env(and later on the VPS). - For the webhook:
stripe listen --forward-to localhost:3000/api/webhooks/stripeprints thewhsec_...value and forwards Stripe events to your local server.
lib/billing.ts:
import { NextRequest } from 'next/server';
import Stripe from 'stripe';
import { pool } from '@/lib/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function ensureCustomer(userId: string, email: string): Promise<string> {
const { rows } = await pool.query<{ stripe_id: string | null }>(
'SELECT stripe_id FROM users WHERE id = $1',
[userId],
);
if (rows[0]?.stripe_id) return rows[0].stripe_id;
const customer = await stripe.customers.create({ email, metadata: { userId } });
await pool.query('UPDATE users SET stripe_id = $1 WHERE id = $2', [customer.id, userId]);
return customer.id;
}
export async function createCheckoutUrl(
req: NextRequest,
userId: string,
email: string,
): Promise<string | null> {
const customer = await ensureCustomer(userId, email);
const origin = req.headers.get('origin') ?? process.env.NEXT_PUBLIC_APP_URL!;
const session = await stripe.checkout.sessions.create({
customer,
mode: 'subscription',
line_items: [{ price: process.env.STRIPE_PRO_PRICE!, quantity: 1 }],
metadata: { userId },
success_url: `${origin}/dashboard?upgraded=1`,
cancel_url: `${origin}/dashboard`,
allow_promotion_codes: true,
});
return session.url;
}
- The
emailpassed in is the real row from your database — no hardcoded address. The customer’s Stripe identity is remembered (stripe_idcolumn) so repeat checkouts don’t create duplicates. metadata.userIdis how the webhook maps the Stripe event back to your user. Do not rely onsession.customer_email— for a returning customer it can benull.success_url/cancel_urlare built from the requestOriginheader (env fallback), so checkout works onlocalhostand production with zero edits.
app/api/billing/checkout/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import { createCheckoutUrl } from '@/lib/billing';
export async function POST(req: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
const url = await createCheckoutUrl(req, user.id, user.email);
if (!url) {
return NextResponse.json({ error: 'checkout could not be created' }, { status: 500 });
}
return NextResponse.json({ url });
} catch (err) {
console.error('checkout failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}
app/api/webhooks/stripe/route.ts — this is the file that actually changes the plan:
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { pool } from '@/lib/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const sig = req.headers.get('stripe-signature');
if (!sig) {
return NextResponse.json({ error: 'missing stripe-signature header' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(await req.text(), sig, endpointSecret);
} catch (err) {
console.error('webhook signature verification failed', err);
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (!session.metadata?.userId) break;
await pool.query(
`UPDATE users
SET plan = 'pro', stripe_id = $2
WHERE id = $1`,
[
session.metadata.userId,
typeof session.customer === 'string'
? session.customer
: String(session.customer),
],
);
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await pool.query(
'UPDATE users SET plan = \'free\' WHERE stripe_id = $1',
[String(sub.customer)],
);
break;
}
}
} catch (err) {
console.error('webhook handler failed', err);
return NextResponse.json({ error: 'handler failed' }, { status: 500 });
}
return NextResponse.json({ received: true });
}
Return a non-2xx on failure so Stripe retries the event — this is how a transient DB blip during a successful checkout still ends with the user on Pro.
10. Dashboard UI
app/dashboard/page.tsx — server component listing the owner’s links with their click counts:
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth';
import { pool } from '@/lib/db';
import { NewLinkForm, DeleteLinkButton, UpgradeBanner } from './controls';
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { rows } = await pool.query(
`SELECT id, title, url, position, clicks
FROM links WHERE user_id = $1 ORDER BY position ASC, created_at ASC`,
[user.id],
);
return (
<main style={{ maxWidth: 640, margin: 'auto', padding: '2rem 1rem' }}>
<h1>Dashboard</h1>
<p>
Your page: <a href={`/${user.username}`}>/{user.username}</a> · Plan:{' '}
{user.plan}
</p>
{user.plan !== 'pro' ? <UpgradeBanner /> : null}
<NewLinkForm />
<ul style={{ listStyle: 'none', padding: 0 }}>
{rows.map(
(link: {
id: string;
title: string;
url: string;
clicks: number;
}) => (
<li
key={link.id}
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 8,
padding: '0.5rem 0',
borderBottom: '1px solid #e5e7eb',
}}
>
<span>
{link.title} <small>({link.clicks} clicks)</small>
</span>
<DeleteLinkButton id={link.id} />
</li>
),
)}
</ul>
</main>
);
}
app/dashboard/controls.tsx — the three client pieces in one file:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function UpgradeBanner() {
const [busy, setBusy] = useState(false);
async function upgrade() {
if (busy) return;
setBusy(true);
try {
const res = await fetch('/api/billing/checkout', { method: 'POST' });
const data = await res.json();
if (res.ok && data.url) {
window.location.assign(data.url);
} else {
alert(data.error ?? 'something went wrong');
}
} finally {
setBusy(false);
}
}
return (
<button
type="button"
onClick={upgrade}
disabled={busy}
style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}
>
Upgrade to Pro — unlimited links & analytics
</button>
);
}
export function NewLinkForm() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const form = new FormData(e.currentTarget);
const res = await fetch('/api/links', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: form.get('title'),
url: form.get('url'),
}),
});
const data = await res.json();
if (!res.ok) {
setError(String(data.error ?? 'something went wrong'));
return;
}
e.currentTarget.reset();
router.refresh(); // re-render the server component with the new link
}
return (
<form
onSubmit={onSubmit}
style={{ display: 'grid', gap: '0.5rem', margin: '1rem 0' }}
>
<input name="title" type="text" placeholder="Title (e.g. My Blog)" required />
<input
name="url"
type="url"
placeholder="https://..."
pattern="https?://.+"
required
/>
<button type="submit" style={{ justifySelf: 'start', cursor: 'pointer' }}>
Add link
</button>
{error ? (
<p role="alert" style={{ color: '#b91c1c', margin: 0 }}>
{typeof error === 'string' ? error : 'something went wrong'}
</p>
) : null}
</form>
);
}
export function DeleteLinkButton({ id }: { id: string }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function onDelete() {
if (busy) return;
setBusy(true);
try {
const res = await fetch(`/api/links/${id}`, { method: 'DELETE' });
if (res.ok) router.refresh();
} finally {
setBusy(false);
}
}
return (
<button
type="button"
onClick={onDelete}
disabled={busy}
style={{ cursor: 'pointer' }}
>
Delete
</button>
);
}
Views for the remaining pages are intentionally simple. app/signup/page.tsx + app/signup/SignupForm.tsx (client, posts to /api/auth/signup, redirects to /dashboard on success), app/login/page.tsx + LoginForm, app/pricing/page.tsx (two static cards: Free — 3 links/USD 0, Pro — unlimited links + click analytics/USD 6), and app/page.tsx (a landing hero linking to /signup and /pricing). All forms validate with the same guards the API enforces (email type, username [a-z0-9]{3,20}, password min 8).
Running locally
export DATABASE_URL=postgres://bio:dev@localhost:5432/bio
export SESSION_SECRET=$(openssl rand -hex 32)
export STRIPE_SECRET_KEY=sk_test_...
export STRIPE_WEBHOOK_SECRET=whsec_...
export STRIPE_PRO_PRICE=price_1...
export NEXT_PUBLIC_APP_URL=http://localhost:3000
npm run dev
Then, while stripe listen is running in another terminal:
- Open
http://localhost:3000/signup, createscooby→ you land in the dashboard. - Add 3 links → the 4th POST returns
403with the upgrade message. http://localhost:3000/scoobyshows your page; opening a link in a new tab bumps its count.- Click “Upgrade to Pro” → Stripe test checkout → check
http://localhost:3000/dashboard?upgraded=1— plan ispro, and the 4th link now saves.
Testing
Beyond the manual happy path, verify the security edges:
# anonymous dashboard → 301 to /login
curl -i http://localhost:3000/dashboard | grep '^Location' # /login
# create a link without a session → 401
curl -s -X POST http://localhost:3000/api/links \
-H 'content-type: application/json' \
-d '{"title":"x","url":"https://example.com"}'
# {"error":"unauthorized"}
# javascript: URLs are rejected by zod
curl -s -X POST http://localhost:3000/api/links \
-H 'content-type: application/json' \
-d '{"title":"x","url":"javascript:alert(1)"}' # 400
# unknown profile → 404
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/nonexistent-user
# health probe
curl -s http://localhost:3000/api/healthz # {"ok":true}
The result of the upload-style test: create a second account and confirm it cannot see or delete the first account’s links (the AND user_id = $2 check returning 404).
Dockerize
Dockerfile:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
Multi-stage: the heavy toolchain compiles, then only the standalone server is copied in — a small, fast image.
docker-compose.yml — PostgreSQL, the app and Caddy in one file. The migrations mount applies the schema automatically the first time the volume is created:
services:
postgres:
image: postgres:17
environment:
POSTGRES_USER: bio
POSTGRES_PASSWORD: dev
POSTGRES_DB: bio
volumes:
- ./migrations:/docker-entrypoint-initdb.d:ro
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bio"]
interval: 5s
retries: 10
app:
build: .
environment:
DATABASE_URL: postgres://bio:dev@postgres:5432/bio
SESSION_SECRET: ${SESSION_SECRET}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRO_PRICE: ${STRIPE_PRO_PRICE}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
depends_on:
postgres: { condition: service_healthy }
restart: unless-stopped
caddy:
image: caddy:2
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on: [app]
restart: unless-stopped
volumes:
pgdata:
caddy_data:
- The compose file reads its secrets from the
.envfile at the project root (the same one git ignores) — no secrets in the repo, none on the command line. healthcheck+depends_on.conditionmeans the app waits until PostgreSQL actually answers before booting — without this you getconnection refusedraces on every cold start.
Deploy to a VPS
On a fresh Ubuntu VPS with Docker installed:
git clone <your-repo-url> && cd bio # or scp the folder
cp .env.example .env # fill in the real values
docker compose up -d
Caddyfile:
app.YOUR-DOMAIN.com {
reverse_proxy app:3000
}
Replace app.YOUR-DOMAIN.com with your real domain, point an A record at the VPS IP, and Caddy obtains + renews the HTTPS certificate automatically — no certbot.
Check the whole stack:
curl -s https://app.YOUR-DOMAIN.com/api/healthz # {"ok":true}
curl -s https://app.YOUR-DOMAIN.com/scooby # your profile page HTML
Finally, register the real webhook in the Stripe dashboard instead of stripe listen: Developers → Webhooks → Add endpoint → https://app.YOUR-DOMAIN.com/api/webhooks/stripe, subscribe to checkout.session.completed and customer.subscription.deleted, and copy the signing secret into STRIPE_WEBHOOK_SECRET.
Security considerations
- Secrets: every credential is an env var;
SESSION_SECRETshould be ≥ 32 random chars and rotated on any breach. - Passwords: bcrypt with cost 12; login returns the same
401whether the email exists or not (no user enumeration). - SQL injection: every query in this guide is a parameterized
$nstatement — there is no string interpolation of user input anywhere. - Ownership: all link mutations filter by
user_id = $2; cross-tenant access returns404. - Input validation: zod on every API; the
urlfield is restricted tohttp(s)://, blockingjavascript:links. - Auth cookies:
httpOnly,sameSite: lax,securein production; the signature is verified withtimingSafeEqual. - XSS: React escapes all user-rendered text (
display_name,bio,title). - Open redirect risk:
/[username]only ever 302s to a URL that the owner stored — and that URL was validated ashttp(s)://at creation time. - Rate limiting: add per-IP limits on
/api/auth/*and the click endpoint before launch (see checklist). - Dependency updates:
npm audit+ patched base images in CI keep the image current.
Backups and reliability
Daily PostgreSQL dump on the VPS, from cron:
0 2 * * * pg_dump "postgres://bio:$PGPASS@localhost:5432/bio" | gzip > /backups/bio-$(date +\%F).sql.gz
Test the restore path at least once — restore into a scratch database and confirm your links come back. A backup that has never been restored is not a backup.
Monitoring
Point a free uptime checker (Uptime Kuma on a spare $3 VPS, or any paid probe) at:
https://app.YOUR-DOMAIN.com/api/healthz— tells you the app respondshttps://app.YOUR-DOMAIN.com/scooby— tells you the content path renders
Alert on first failure; page this on two consecutive failures. Also watch Stripe Dashboard for webhook delivery failures (Developers → Webhooks shows attempts and retries).
Cost
Assumptions (recompute for your scale): 300 registered users, 40 paying Pro at $6/month, ~100 GB/month bandwidth.
| Service | Monthly cost |
|---|---|
| VPS 2 GB / 40 GB (Hetzner CX22) | $4.49 |
| PostgreSQL (on the same VPS) | $0 |
| Domain | ~$1.00 |
| Stripe fees (40 × $6, ~2% mixed cards + $0.30) | ~$17 |
| Total | ≈ $23/month |
Stripe’s published fee is 2.9% + $0.30 (US cards) or 1.5% + $0.30 (cards issued in Europe, at the time of writing) — we used ~2% as a mixed average. Revenue at that point is $240/month, so infrastructure is ~10% of MRR. At 1,000 paid users the VPS stays the same while Stripe fees grow with revenue — the platform cost per user only goes down.
Production checklist
-
SESSION_SECRET≥ 32 chars, only in env vars - Rate limit
/api/auth/*and/api/links/[id]/click(per IP) before launch - Stripe test → live key swap, both
STRIPE_SECRET_KEYand webhook secret - Webhook endpoint registered in the Stripe dashboard (not
stripe listen) - Daily
pg_dump+ a restore drill that actually ran -
invoice.payment_failedhandler → set apast_dueflag (Stripe retries the card; you show a “payment failed” banner) - Firewall: SSH + 80/443 only
- Uptime monitoring on
/api/healthzand a profile page
Common problems
- Profile page 404s for logged-in users: the middleware must leave single-segment paths public. If you switched to a denylist matcher later,
/[username]starts failing — keep the public-by-default shape in “Middleware guard”. count(*)compares wrong: without::int,pgreturns bigint counts as strings and>= 3may pass unexpectedly. The cast in the link API prevents it.paramsis a Promise: on Next.js 15 you mustawait params— forgetting it gives a confusing runtime error on dynamic routes.- Connection pool explosion in dev: omitting the
globalForPoolguard inlib/db.tsopens a new pool on every hot reload and you exhaust Postgres connections. - Webhook 400s: missing
STRIPE_WEBHOOK_SECRETor an old secret — re-runstripe listenand copy the freshwhsec_.... - Stripe does not fire
customer.subscription.deletedon failed renewals: it only fires on cancellation. Addinvoice.payment_failedfor dunning (checklist). - Upgrade never applied: double-check
metadata: { userId }inlib/billing.ts— the webhook can only map the event to a user via that field.
Improvements
- Custom accents & avatars: connect
accent_color(validate with^#[0-9a-fA-F]{6}$) and an uploaded avatar with server-side validation. - Link reordering: a
PATCH /api/links/:id/positionor a drag-and-drop client that batch-sends positions. - Click analytics over time: log each click into a
clicksevent table (orpgcrypto-free lightweight column-per-day) so the dashboard can chart trends — counts column + aggregate table keeps the hot path cheap. - Custom domains for Pro: per-user
CNAME+ a host-header lookup table, the classic link-in-bio upsell. - Email verification: Resend on signup before the first login (see Email Newsletter SaaS for the transactional-email pattern).
- Team / business accounts: shared pages via
org_idon links — this is the moment the RLS discussion above becomes “yes”.
Conclusion
A link-in-bio SaaS is one of the smallest real subscription products you can ship: two tables, a signed cookie, one Stripe webhook and a public page. This build costs about $5/month in infrastructure at 300 users, keeps the analytics in your own database, and every file needed to run it — from schema to Caddyfile — is above.