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/username page listing their links. Each click fires a POST /api/links/:id/click that 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 + 1 per 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 fresh postgres:17 container or your local Postgres.
  • links.user_id → users.id ON DELETE CASCADE means deleting an account removes its links; you never have orphaned rows.
  • links_user_idx makes the two queries that matter fast: WHERE user_id = $1 ORDER BY position on the profile page and dashboard.
  • clicks is 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"
  }
}
  • timingSafeEqual compares 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 pattern attribute 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 23505 error and becomes a 409 — 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.
  • verifySession is pure HMAC + timestamp — no database call in the hot path of a redirect.
  • The matcher excludes static assets, favicon and any path containing a dot.

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(*)::int matters: without the cast, pg returns count(*) as a bigint string, and >= 3 would compare weirdly. The cast keeps it a JS number.
  • The position subquery appends the new link after the current max — no separate reorder API needed for the MVP.
  • Every value is a $n placeholder; 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, so GET /scooby and GET /Scooby hit the same user, and the middleware already lets single-segment pages through.
  • React escapes display_name and bio when rendering, so a user pasting <script> into their bio outputs it as text — no XSS from user content.
  • target="_blank" always pairs with rel="noopener noreferrer" so the opened site can’t window.opener your profile page.

9. Stripe billing

First, the step most guides skip — where to get the price ID:

  1. Stripe Dashboard → Test modeProductsAdd product → name it “Pro”, price $6/month, recurring → Save product.
  2. Open the product → copy the Price ID (price_1...).
  3. Set STRIPE_PRO_PRICE=price_1... in .env (and later on the VPS).
  4. For the webhook: stripe listen --forward-to localhost:3000/api/webhooks/stripe prints the whsec_... 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 email passed in is the real row from your database — no hardcoded address. The customer’s Stripe identity is remembered (stripe_id column) so repeat checkouts don’t create duplicates.
  • metadata.userId is how the webhook maps the Stripe event back to your user. Do not rely on session.customer_email — for a returning customer it can be null.
  • success_url/cancel_url are built from the request Origin header (env fallback), so checkout works on localhost and 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 &amp; 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:

  1. Open http://localhost:3000/signup, create scooby → you land in the dashboard.
  2. Add 3 links → the 4th POST returns 403 with the upgrade message.
  3. http://localhost:3000/scooby shows your page; opening a link in a new tab bumps its count.
  4. Click “Upgrade to Pro” → Stripe test checkout → check http://localhost:3000/dashboard?upgraded=1 — plan is pro, 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 .env file at the project root (the same one git ignores) — no secrets in the repo, none on the command line.
  • healthcheck + depends_on.condition means the app waits until PostgreSQL actually answers before booting — without this you get connection refused races 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 endpointhttps://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_SECRET should be ≥ 32 random chars and rotated on any breach.
  • Passwords: bcrypt with cost 12; login returns the same 401 whether the email exists or not (no user enumeration).
  • SQL injection: every query in this guide is a parameterized $n statement — there is no string interpolation of user input anywhere.
  • Ownership: all link mutations filter by user_id = $2; cross-tenant access returns 404.
  • Input validation: zod on every API; the url field is restricted to http(s)://, blocking javascript: links.
  • Auth cookies: httpOnly, sameSite: lax, secure in production; the signature is verified with timingSafeEqual.
  • 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 as http(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 responds
  • https://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_KEY and webhook secret
  • Webhook endpoint registered in the Stripe dashboard (not stripe listen)
  • Daily pg_dump + a restore drill that actually ran
  • invoice.payment_failed handler → set a past_due flag (Stripe retries the card; you show a “payment failed” banner)
  • Firewall: SSH + 80/443 only
  • Uptime monitoring on /api/healthz and 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, pg returns bigint counts as strings and >= 3 may pass unexpectedly. The cast in the link API prevents it.
  • params is a Promise: on Next.js 15 you must await params — forgetting it gives a confusing runtime error on dynamic routes.
  • Connection pool explosion in dev: omitting the globalForPool guard in lib/db.ts opens a new pool on every hot reload and you exhaust Postgres connections.
  • Webhook 400s: missing STRIPE_WEBHOOK_SECRET or an old secret — re-run stripe listen and copy the fresh whsec_....
  • Stripe does not fire customer.subscription.deleted on failed renewals: it only fires on cancellation. Add invoice.payment_failed for dunning (checklist).
  • Upgrade never applied: double-check metadata: { userId } in lib/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/position or a drag-and-drop client that batch-sends positions.
  • Click analytics over time: log each click into a clicks event table (or pgcrypto-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_id on 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.

Related guides