Construire un MVP SaaS avec Claude Code
Un journal de build sur un week-end : monter une app Next.js 16, ajouter l'auth Supabase et Postgres, brancher la facturation Stripe, et déployer sur Vercel, le tout avec Claude Code.
Vous voulez le framework derrière ces projets ?
Obtenez le système Claude Code que nous utilisons pour planifier, construire, tester et livrer des logiciels en production.
Le week-end dernier, j'ai construit une petite app de feedback board de bout en bout avec Claude Code : connexion, création d'un board, collecte de demandes de fonctionnalités, upvotes, paiement de $19 par mois pour débloquer plus d'un board. Rien de compliqué là-dedans, et c'est bien le but. Voici le build réel, dans l'ordre, avec le code qui a fini dans le repo.
Choisir un petit SaaS réel à construire
L'app d'exemple s'appelle Signal. C'est un feedback board public : un fondateur crée un board, partage le lien, les utilisateurs soumettent des demandes de fonctionnalités et upvotent celles qu'ils veulent. Les comptes gratuits ont un seul board. Les comptes payants en ont un nombre illimité.
C'est assez petit pour finir en un week-end et assez complet pour toucher chaque couche dont un vrai SaaS a besoin : auth, un schéma relationnel avec row-level security, une boucle de fonctionnalité centrale, et de la facturation récurrente. Si tu construis autre chose, la forme du travail ci-dessous reste valable. Remplace boards et posts par les objets centraux de ton produit.
Avant de commencer
Quatre choses doivent exister avant d'ouvrir Claude Code.
Node.js 20.9.0 ou plus récent, puisque Next.js 16 a abandonné le support de Node 18 :
node --versionClaude Code installé globalement, et un plan Claude Pro ou Max. Le tier gratuit ne marche pas avec Claude Code.
npm install -g @anthropic-ai/claude-codeDes comptes sur Supabase, Stripe et Vercel, tous avec des tiers gratuits généreux pour un projet de cette taille. Crée le projet Supabase et le compte Stripe maintenant, tu auras besoin des clés API des deux dans les étapes qui suivent.
Un repo GitHub, pour que le déploiement Vercel à la fin ne soit qu'un simple push au lieu d'un upload manuel.
Monter le projet
Pars d'un projet Next.js 16 propre avec Turbopack et Tailwind CSS v4 déjà branchés.
npx create-next-app@latest signal --typescript --tailwind --app --turbopack
cd signal
npx shadcn@latest initInstalle les packages dont dépend le reste du build : le client SSR Supabase pour l'auth et Postgres, et le SDK Stripe pour la facturation.
npm install @supabase/ssr @supabase/supabase-js stripe zodOuvre le projet dans Claude Code :
claudeÉcrire CLAUDE.md et AGENTS.md
Claude Code lit AGENTS.md pour les conventions du framework et CLAUDE.md pour les règles propres au projet. Sur la release canary de Next.js 16, les deux sont générés pour toi. Si tu es sur la version stable, crée AGENTS.md toi-même avec une ligne qui pointe vers la doc embarquée :
node_modules/next/dist/docs/Écris ensuite CLAUDE.md. C'est le fichier qui empêche Claude de deviner ta stack, l'organisation de tes fichiers et tes conventions de nommage à chaque 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)La ligne sur la row-level security compte plus qu'elle n'en a l'air. Sans elle explicitée, Claude va parfois écrire une table et oublier d'activer la RLS dessus, ce qui veut dire que chaque ligne est lisible par le monde entier par défaut dans Supabase.
Planifier le schéma de base de données en Plan mode
Avant qu'aucun code ne soit écrit, utilise le plan mode pour poser le schéma.
claude --permission-mode 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 revient avec quatre tables (profiles, boards, posts, votes), les clés étrangères entre elles, et un plan pour la RLS : boards et posts sont lisibles publiquement pour qu'un lien de board fonctionne pour les visiteurs anonymes, mais les écritures exigent un utilisateur authentifié qui possède la ressource. Relis ça avant que quoi que ce soit ne soit construit. Les décisions de schéma sont les plus coûteuses à défaire une fois que tu as de vraies données dans les tables.
Mettre en place Supabase et Postgres
Crée un nouveau projet Supabase depuis le dashboard, puis passe le schéma dans l'éditeur SQL. Voici la migration réelle qui est partie :
-- 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;Chaque table a la RLS activée et une policy. Boards et posts sont lisibles par n'importe qui, puisque tout l'intérêt d'un feedback board est un lien public, mais seul le propriétaire (ou un votant authentifié, pour les votes) peut écrire dedans. Récupère l'URL du projet et la clé anon dans les réglages API de Supabase et mets-les dans .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-keyL'auth avec Supabase
Deux clients Supabase couvrent toute l'app : un qui tourne côté serveur avec la session du visiteur, et un avec la clé service role qui contourne la RLS pour le webhook Stripe plus tard.
// 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 (et non middleware.ts, ce nom a disparu dans Next.js 16) protège les routes du dashboard en vérifiant qu'une session existe avant que la requête n'atteigne la 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*"],
};La page de login elle-même est un simple formulaire email et mot de passe adossé à une Server Action. Rien de sophistiqué, des magic links feraient aussi bien l'affaire si tu préfères te passer complètement de mots de passe.
// 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");
}Construire la fonctionnalité centrale : boards et upvotes
Le dashboard liste les boards d'un utilisateur et lui permet d'en créer un nouveau. Les comptes gratuits sont plafonnés à un board, imposé dans la Server Action, pas seulement dans l'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");
}La page publique du board est l'endroit où les params async comptent. Dans Next.js 16, params est une Promise, et await params est obligatoire avant de pouvoir lire le 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>
);
}Le vote a besoin d'un petit Client Component, puisqu'il réagit à un clic. Le vote lui-même passe par une Server Action pour que la vérification RLS se fasse sur le serveur, pas dans le navigateur.
// 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 });
}
}La contrainte unique (post_id, voter_id) du schéma fait le vrai travail ici. Si un utilisateur vote deux fois, l'insert échoue, le compteur ne s'incrémente pas, et il n'y a besoin d'aucune logique applicative supplémentaire pour empêcher le double vote.
Ajouter Stripe Checkout pour le plan Pro
Crée d'abord un produit et un prix récurrent dans le dashboard Stripe, puis branche le flux de paiement. Démarrer le checkout est une Server Action qui redirige directement vers 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!);
}Le plan ne change vraiment qu'une fois que Stripe confirme l'abonnement, via un webhook, et pas sur la redirection de succès. Les redirections peuvent être falsifiées ou interrompues, les webhooks sont la source de vérité.
// 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 });
}Redirige les événements vers ton serveur local pendant les tests avec la CLI Stripe, et copie le signing secret qu'elle affiche dans STRIPE_WEBHOOK_SECRET.
stripe listen --forward-to localhost:3000/api/webhooks/stripeLa page de facturation elle-même bouge à peine, c'est donc un endroit raisonnable pour utiliser la directive "use cache" qui a remplacé experimental.dynamicIO dans 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>
);
}Les contrôles qualité avant d'expédier
Deux vérifications tournent avant chaque commit, sans exception.
npx tsc --noEmit
npm run buildDemande à Claude Code de lancer les deux après avoir branché le webhook et le flux de facturation, puisque les types de Stripe sont stricts et faciles à se tromper d'un poil au premier passage.
claude "run tsc --noEmit and fix any type errors, then confirm the build passes"C'est aussi ici que tu testes vraiment les flux à la main : inscription, création d'un board, atteinte de la limite du plan gratuit, upgrade via le mode test de Stripe, confirmation que le webhook bascule le plan sur pro. Rien de tout ça n'est attrapé par un vérificateur de types. Il faut cliquer dedans.
Déployer sur Vercel
Push le repo sur GitHub, puis importe-le dans Vercel. Renseigne chaque variable d'environnement de .env.local dans le dashboard Vercel avant le premier déploiement, y compris les clés Stripe et la clé service role Supabase.
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 --prodUn truc que les gens ratent : le webhook secret de ta session stripe listen locale est différent de celui que tu obtiens quand tu enregistres un vrai endpoint dans le dashboard Stripe pointé vers ton URL de prod. Crée cet endpoint après le premier déploiement, puis mets à jour STRIPE_WEBHOOK_SECRET dans Vercel avec le nouveau secret et redéploie.
À quoi ressemble un pipeline orchestré
Une seule session Claude Code, comme celle ci-dessus, gère très bien un projet de week-end. Le goulot d'étranglement, c'est toi : relire le plan, lire les policies RLS, cliquer à la main dans le flux de checkout. Ça reste vrai peu importe la qualité de l'agent, et c'est une quantité de relecture raisonnable pour quatre fonctionnalités.
Ça cesse d'être raisonnable dès que tu as vingt fonctionnalités au lieu de quatre. C'est le trou que comble le Code Kit à $29 : un moteur posé sur Claude Code qui lance plan, build, évaluation et test pour chaque fonctionnalité automatiquement, et impose un contrôle qualité (zéro erreur de type, zéro erreur de lint, un build propre) avant que quoi que ce soit ne parte. L'étape npx tsc --noEmit ci-dessus, c'est le même contrôle, lancé une fois à la main. Dans un pipeline, il tourne après chaque fonctionnalité sans que tu aies à le demander.
Posté par @speedy_devv
Vous voulez le framework derrière ces projets ?
Obtenez le système Claude Code que nous utilisons pour planifier, construire, tester et livrer des logiciels en production.
Test-Driven Development
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.
Ajouter l'authentification avec Claude Code (Supabase Auth)
Ajoute l'inscription email/mot de passe, l'OAuth Google, les magic links, les routes protégées et la gestion des sessions à une app Next.js 16 avec Claude Code et Supabase Auth.

