Build This Now
Build This Now
What Is Claude Code?Claude Code InstallationClaude Code Native InstallerYour First Claude Code Project
speedy_devvkoen_salo
Blog/Handbook/Workflow/How to Build a SaaS MVP With Claude Code

How to Build a SaaS MVP With Claude Code

A weekend build log: scaffold a Next.js 16 app, add Supabase auth and Postgres, wire up Stripe billing, and deploy to Vercel, all with Claude Code.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →
speedy_devvWritten by speedy_devvPublished Jul 14, 202614 min readHandbook hubWorkflow index

Last weekend I built a small feedback board app end to end with Claude Code: sign in, create a board, collect feature requests, upvote them, pay $19 a month to unlock more than one board. Nothing about it is complicated, and that's the point. Below is the actual build, in order, with the code that made it into the repo.


Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

Picking a Small, Real SaaS to Build

The example app is called Signal. It's a public feedback board: a founder creates a board, shares the link, users submit feature requests and upvote the ones they want. Free accounts get one board. Paying accounts get unlimited boards.

It's small enough to finish in a weekend and complete enough to touch every layer a real SaaS needs: auth, a relational schema with row-level security, a core feature loop, and recurring billing. If you're building something else, the shape of the work below still applies. Swap boards and posts for whatever your product's core objects are.

Before You Start

Four things need to exist before you open Claude Code.

Node.js 20.9.0 or newer, since Next.js 16 dropped support for Node 18:

node --version

Claude Code installed globally, and a Claude Pro or Max plan. The free tier doesn't work with Claude Code.

npm install -g @anthropic-ai/claude-code

Accounts on Supabase, Stripe, and Vercel, all with generous free tiers for a project this size. Create the Supabase project and Stripe account now, you'll need API keys from both in the next few steps.

A GitHub repo, so the Vercel deploy at the end is a single push instead of a manual upload.

Scaffolding the Project

Start from a clean Next.js 16 project with Turbopack and Tailwind CSS v4 already wired up.

npx create-next-app@latest signal --typescript --tailwind --app --turbopack
cd signal
npx shadcn@latest init

Install the packages the rest of this build depends on: the Supabase SSR client for auth and Postgres, and the Stripe SDK for billing.

npm install @supabase/ssr @supabase/supabase-js stripe zod

Open the project in Claude Code:

claude

Writing CLAUDE.md and AGENTS.md

Claude Code reads AGENTS.md for framework conventions and CLAUDE.md for project-specific rules. On Next.js 16's canary release both get generated for you. If you're on stable, create AGENTS.md yourself with one line pointing at the bundled docs:

node_modules/next/dist/docs/

Then write CLAUDE.md. This is the file that keeps Claude from guessing at your stack, your file layout, and your naming conventions every session.

@AGENTS.md

## Stack

- Next.js 16 with App Router (TypeScript)
- Tailwind CSS v4 with shadcn/ui components
- PostgreSQL via Supabase, with row-level security on every table
- Stripe for subscription billing

## File Conventions

- Server Components by default. "use client" only for interactivity.
- Supabase server client: lib/supabase/server.ts
- Supabase admin client (service role, webhook use only): lib/supabase/admin.ts
- Server Actions live next to the routes that use them, in actions.ts files
- Route handlers for webhooks only, under app/api/

## Commands

- Dev server: npm run dev
- Type check: npx tsc --noEmit
- Build: npm run build

## Proxy

- Auth checks live in proxy.ts, not middleware.ts (Next.js 16)

The row-level security line matters more than it looks. Without it stated explicitly, Claude will sometimes write a table and forget to enable RLS on it, which means every row is world-readable by default in Supabase.

Planning the Database Schema in Plan Mode

Before any code gets written, use plan mode to work out the schema.

claude --plan "design the Postgres schema for Signal: boards owned by a user, posts on a board, and votes on a post. Free accounts get 1 board. Paid accounts get unlimited boards. Include row-level security policies."

Claude comes back with four tables (profiles, boards, posts, votes), the foreign keys between them, and a plan for RLS: boards and posts are publicly readable so a board link works for anonymous visitors, but writes require an authenticated user who owns the resource. Review this before anything gets built. Schema decisions are the most expensive ones to walk back once you have real data in the tables.

Setting Up Supabase and Postgres

Create a new Supabase project from the dashboard, then run the schema through the SQL editor. This is the actual migration that shipped:

-- profiles: one row per user, tracks plan status
create table profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  plan text not null default 'free',
  stripe_customer_id text,
  created_at timestamptz default now()
);

-- boards: one feedback board per row
create table boards (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid references auth.users(id) on delete cascade not null,
  name text not null,
  slug text unique not null,
  created_at timestamptz default now()
);

-- posts: feature requests on a board
create table posts (
  id uuid primary key default gen_random_uuid(),
  board_id uuid references boards(id) on delete cascade not null,
  title text not null,
  body text,
  vote_count int not null default 0,
  created_at timestamptz default now()
);

-- votes: one vote per user per post
create table votes (
  id uuid primary key default gen_random_uuid(),
  post_id uuid references posts(id) on delete cascade not null,
  voter_id uuid references auth.users(id) on delete cascade not null,
  created_at timestamptz default now(),
  unique (post_id, voter_id)
);

alter table profiles enable row level security;
alter table boards enable row level security;
alter table posts enable row level security;
alter table votes enable row level security;

create policy "Users manage their own profile"
  on profiles for all
  using (auth.uid() = id);

create policy "Boards are publicly readable"
  on boards for select
  using (true);

create policy "Owners manage their own boards"
  on boards for insert, update, delete
  using (auth.uid() = owner_id);

create policy "Posts are publicly readable"
  on posts for select
  using (true);

create policy "Authenticated users create posts"
  on posts for insert
  with check (auth.role() = 'authenticated');

create policy "Voters manage their own votes"
  on votes for all
  using (auth.uid() = voter_id);

-- auto-create a profile row on signup
create or replace function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id) values (new.id);
  return new;
end;
$$ language plpgsql security definer;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function public.handle_new_user();

-- atomic vote increment, called from a Server Action
create or replace function increment_vote(target_post_id uuid)
returns void as $$
begin
  update posts set vote_count = vote_count + 1 where id = target_post_id;
end;
$$ language plpgsql security definer;

Every table has RLS enabled and a policy. Boards and posts are readable by anyone, since the whole point of a feedback board is a public link, but only the owner (or an authenticated voter, for votes) can write to them. Grab the project URL and anon key from Supabase's API settings and put them in .env.local.

NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

Auth With Supabase

Two Supabase clients cover the whole app: one that runs on the server with the visitor's session, and one with the service role key that bypasses RLS for the Stripe webhook later.

// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
          );
        },
      },
    }
  );
}
// lib/supabase/admin.ts
import { createClient as createSupabaseClient } from "@supabase/supabase-js";

export function createAdminClient() {
  return createSupabaseClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    { auth: { persistSession: false } }
  );
}

proxy.ts (not middleware.ts, that name is gone in Next.js 16) protects the dashboard routes by checking for a session before the request reaches the page.

// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { createServerClient } from "@supabase/ssr";

export async function proxy(request: NextRequest) {
  const response = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  return response;
}

export const config = {
  matcher: ["/dashboard/:path*"],
};

The login page itself is a plain email and password form backed by a Server Action. Nothing fancy, magic links would work just as well if you'd rather skip passwords entirely.

// app/login/actions.ts
"use server";

import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";

export async function signIn(formData: FormData) {
  const supabase = await createClient();

  const { error } = await supabase.auth.signInWithPassword({
    email: formData.get("email") as string,
    password: formData.get("password") as string,
  });

  if (error) redirect("/login?error=invalid-credentials");
  redirect("/dashboard");
}

export async function signUp(formData: FormData) {
  const supabase = await createClient();

  const { error } = await supabase.auth.signUp({
    email: formData.get("email") as string,
    password: formData.get("password") as string,
  });

  if (error) redirect("/login?error=signup-failed");
  redirect("/dashboard");
}

Building the Core Feature: Boards and Upvotes

The dashboard lists a user's boards and lets them create a new one. Free accounts are capped at one board, enforced in the Server Action, not just in the UI.

// app/dashboard/actions.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export async function createBoard(formData: FormData) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const { data: profile } = await supabase
    .from("profiles")
    .select("plan")
    .eq("id", user.id)
    .single();

  const { count } = await supabase
    .from("boards")
    .select("id", { count: "exact", head: true })
    .eq("owner_id", user.id);

  if (profile?.plan === "free" && (count ?? 0) >= 1) {
    redirect("/dashboard/billing?limit=reached");
  }

  const name = formData.get("name") as string;
  const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40);

  await supabase.from("boards").insert({ owner_id: user.id, name, slug });
  redirect("/dashboard");
}

The public board page is where async params matter. In Next.js 16, params is a Promise, and await params is required before you can read the slug.

// app/b/[slug]/page.tsx
import { createClient } from "@/lib/supabase/server";
import { VoteButton } from "./vote-button";
import { notFound } from "next/navigation";

interface PageProps {
  params: Promise<{ slug: string }>;
}

export default async function BoardPage({ params }: PageProps) {
  const { slug } = await params;
  const supabase = await createClient();

  const { data: board } = await supabase
    .from("boards")
    .select("id, name")
    .eq("slug", slug)
    .single();

  if (!board) notFound();

  const { data: posts } = await supabase
    .from("posts")
    .select("id, title, body, vote_count")
    .eq("board_id", board.id)
    .order("vote_count", { ascending: false });

  return (
    <main className="max-w-2xl mx-auto py-12 px-4">
      <h1 className="text-2xl font-bold mb-6">{board.name}</h1>
      <ul className="space-y-3">
        {posts?.map((post) => (
          <li key={post.id} className="flex gap-4 border rounded-lg p-4">
            <VoteButton postId={post.id} initialCount={post.vote_count} />
            <div>
              <p className="font-medium">{post.title}</p>
              {post.body && (
                <p className="text-sm text-muted-foreground">{post.body}</p>
              )}
            </div>
          </li>
        ))}
      </ul>
    </main>
  );
}

Voting needs a small Client Component, since it responds to a click. The vote itself runs through a Server Action so the RLS check happens on the server, not in the browser.

// app/b/[slug]/vote-button.tsx
"use client";

import { useState, useTransition } from "react";
import { castVote } from "./actions";

export function VoteButton({
  postId,
  initialCount,
}: {
  postId: string;
  initialCount: number;
}) {
  const [count, setCount] = useState(initialCount);
  const [isPending, startTransition] = useTransition();

  return (
    <button
      disabled={isPending}
      onClick={() =>
        startTransition(async () => {
          setCount((c) => c + 1);
          await castVote(postId);
        })
      }
      className="flex flex-col items-center justify-center w-12 h-12 rounded-md border hover:bg-accent"
    >
      <span className="text-sm font-semibold">{count}</span>
    </button>
  );
}
// app/b/[slug]/actions.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export async function castVote(postId: string) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const { error } = await supabase
    .from("votes")
    .insert({ post_id: postId, voter_id: user.id });

  if (!error) {
    await supabase.rpc("increment_vote", { target_post_id: postId });
  }
}

The unique (post_id, voter_id) constraint from the schema does the real work here. If a user votes twice, the insert fails, the count doesn't increment, and there's no need for extra application logic to prevent double voting.

Adding Stripe Checkout for the Pro Plan

Create a product and a recurring price in the Stripe dashboard first, then wire the checkout flow. Starting checkout is a Server Action that redirects straight to Stripe.

// app/dashboard/billing/actions.ts
"use server";

import Stripe from "stripe";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function startCheckout() {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: process.env.STRIPE_PRO_PRICE_ID!, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
    client_reference_id: user.id,
    metadata: { supabase_user_id: user.id },
  });

  redirect(session.url!);
}

The plan only actually changes once Stripe confirms the subscription, through a webhook, not on the success redirect. Redirects can be spoofed or interrupted, webhooks are the source of truth.

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
import { createAdminClient } from "@/lib/supabase/admin";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: NextRequest) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 400 });
  }

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  const supabase = createAdminClient();

  if (event.type === "checkout.session.completed") {
    const session = event.data.object as Stripe.Checkout.Session;
    const userId = session.metadata?.supabase_user_id;

    if (userId) {
      await supabase
        .from("profiles")
        .update({
          plan: "pro",
          stripe_customer_id: session.customer as string,
        })
        .eq("id", userId);
    }
  }

  if (event.type === "customer.subscription.deleted") {
    const subscription = event.data.object as Stripe.Subscription;

    await supabase
      .from("profiles")
      .update({ plan: "free" })
      .eq("stripe_customer_id", subscription.customer as string);
  }

  return NextResponse.json({ received: true });
}

Forward events to your local server while testing with the Stripe CLI, and copy the signing secret it prints into STRIPE_WEBHOOK_SECRET.

stripe listen --forward-to localhost:3000/api/webhooks/stripe

The billing page itself barely changes, so it's a reasonable place to reach for the "use cache" directive that replaced experimental.dynamicIO in Next.js 16.

// app/pricing/page.tsx
"use cache";

export default function PricingPage() {
  return (
    <main className="max-w-2xl mx-auto py-16 px-4">
      <h1 className="text-3xl font-bold mb-8">Pricing</h1>
      <div className="grid grid-cols-2 gap-6">
        <div className="border rounded-lg p-6">
          <h2 className="font-semibold">Free</h2>
          <p className="text-sm text-muted-foreground">1 board, unlimited posts</p>
        </div>
        <div className="border rounded-lg p-6">
          <h2 className="font-semibold">Pro ($19/mo)</h2>
          <p className="text-sm text-muted-foreground">Unlimited boards</p>
        </div>
      </div>
    </main>
  );
}

Quality Gates Before You Ship

Two checks run before every commit, no exceptions.

npx tsc --noEmit
npm run build

Ask Claude Code to run both after wiring up the webhook and the billing flow, since Stripe's types are strict and easy to get slightly wrong on the first pass.

claude "run tsc --noEmit and fix any type errors, then confirm the build passes"

This is also where you actually test the flows by hand: sign up, create a board, hit the free plan limit, upgrade through Stripe test mode, confirm the webhook flips the plan to pro. None of that is caught by a type checker. It has to be clicked through.

Deploying to Vercel

Push the repo to GitHub, then import it in Vercel. Set every environment variable from .env.local in the Vercel dashboard before the first deploy, including the Stripe keys and the Supabase service role key.

npx vercel env add SUPABASE_SERVICE_ROLE_KEY production
npx vercel env add STRIPE_SECRET_KEY production
npx vercel env add STRIPE_WEBHOOK_SECRET production
npx vercel --prod

One thing people miss: the Stripe webhook secret from your local stripe listen session is different from the one you get when you register a real endpoint in the Stripe dashboard pointed at your production URL. Create that endpoint after the first deploy, then update STRIPE_WEBHOOK_SECRET in Vercel with the new secret and redeploy.

What a Coordinated Pipeline Looks Like

A single Claude Code session, like the one above, handles a weekend project fine. The bottleneck is you: reviewing the plan, reading the RLS policies, clicking through the checkout flow by hand. That's still true no matter how good the agent is, and it's a reasonable amount of review for four features.

It stops being reasonable once you have twenty features instead of four. That's the gap the $29 Code Kit is built for: a harness on top of Claude Code that runs plan, build, evaluate, and test for every feature automatically, and enforces a quality gate (zero type errors, zero lint errors, a clean build) before anything ships. The npx tsc --noEmit step above is the same gate, run by hand once. In a pipeline, it runs after every single feature without you asking.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

Posted by @speedy_devv

Continue in Workflow

  • Agentic Commerce: How to Build an App AI Agents Can Pay For
    A plain-English guide to agentic commerce in 2026: what x402, ACP, and the Machine Payments Protocol do, plus a weekend walkthrough for shipping a paid API that AI agents can buy from.
  • Claude Code Best Practices
    Five habits separate engineers who ship with Claude Code: PRDs, modular CLAUDE.md rules, custom slash commands, /clear resets, and a system-evolution mindset.
  • Claude Code Auto Mode
    A second Sonnet model reviews every Claude Code tool call before it fires. What auto mode blocks, what it allows, and the allow rules it drops in your settings.
  • Channels, Routines, Teleport, Dispatch
    The four Claude Code features Anthropic shipped in March and April 2026 that turn the CLI into an event-driven coordination layer across phone, web, and desktop.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • Adding Authentication With Claude Code (Supabase Auth)
    Add email/password signup, Google OAuth, magic links, protected routes, and session handling to a Next.js 16 app using Claude Code and Supabase Auth.

More from Handbook

  • Agent Fundamentals
    Five ways to build specialist agents in Claude Code: Task sub-agents, .claude/agents YAML, custom slash commands, CLAUDE.md personas, and perspective prompts.
  • Agent Harness Engineering
    The harness is every layer around your AI agent except the model itself. Learn the five control levers, the constraint paradox, and why harness design determines agent performance more than the model does.
  • Agent Patterns
    Orchestrator, fan-out, validation chain, specialist routing, progressive refinement, and watchdog. Six orchestration shapes to wire Claude Code sub-agents with.
  • Agent Teams Best Practices
    Battle-tested patterns for Claude Code Agent Teams. Context-rich spawn prompts, right-sized tasks, file ownership, delegate mode, and v2.1.33-v2.1.45 fixes.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →

On this page

Picking a Small, Real SaaS to Build
Before You Start
Scaffolding the Project
Writing CLAUDE.md and AGENTS.md
Planning the Database Schema in Plan Mode
Setting Up Supabase and Postgres
Auth With Supabase
Building the Core Feature: Boards and Upvotes
Adding Stripe Checkout for the Pro Plan
Quality Gates Before You Ship
Deploying to Vercel
What a Coordinated Pipeline Looks Like

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

See what we build for companies →