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.
Pare de configurar. Comece a construir.
Templates SaaS com orquestração de IA.
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.
Pare de configurar. Comece a construir.
Templates SaaS com orquestração de IA.
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 --versionClaude 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-codeAccounts 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 initInstall 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 zodOpen the project in Claude Code:
claudeWriting 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-keyAuth 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/stripeThe 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 buildAsk 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 --prodOne 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.
Pare de configurar. Comece a construir.
Templates SaaS com orquestração de IA.
Posted by @speedy_devv
Pare de configurar. Comece a construir.
Templates SaaS com orquestração de IA.
Test-Driven Development with Claude Code
Make Claude write failing tests from your spec, then implement until green without cheating. How to wire testing into the agent loop so quality is enforced, not hoped for.
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.