Como Construir um MVP de SaaS Com o Claude Code
Um diário de construção de fim de semana: montar uma app Next.js 16, adicionar autenticação e Postgres com Supabase, ligar a faturação Stripe e fazer deploy na Vercel, tudo com o Claude Code.
Quer o framework por trás destes projetos?
Obtenha o sistema Claude Code que usamos para planejar, construir, testar e lançar software em produção.
No fim de semana passado construí uma pequena app de quadro de feedback de ponta a ponta com o Claude Code: iniciar sessão, criar um quadro, recolher pedidos de funcionalidades, votar neles, pagar $19 por mês para desbloquear mais do que um quadro. Nada nisto é complicado, e é esse o ponto. Em baixo está a construção real, por ordem, com o código que acabou por entrar no repo.
Escolher um SaaS Pequeno e Real Para Construir
A app de exemplo chama-se Signal. É um quadro de feedback público: um fundador cria um quadro, partilha o link, os utilizadores submetem pedidos de funcionalidades e votam nos que querem. Contas gratuitas ficam com um quadro. Contas pagas ficam com quadros ilimitados.
É pequeno o suficiente para acabar num fim de semana e completo o suficiente para tocar em todas as camadas que um SaaS real precisa: autenticação, um esquema relacional com row-level security, um ciclo de funcionalidade central e faturação recorrente. Se estás a construir outra coisa, o formato do trabalho em baixo continua a aplicar-se. Troca quadros e posts pelo que quer que sejam os objetos centrais do teu produto.
Antes de Começar
Quatro coisas têm de existir antes de abrires o Claude Code.
Node.js 20.9.0 ou mais recente, já que o Next.js 16 deixou cair o suporte ao Node 18:
node --versionClaude Code instalado globalmente, e um plano Claude Pro ou Max. O escalão gratuito não funciona com o Claude Code.
npm install -g @anthropic-ai/claude-codeContas na Supabase, Stripe e Vercel, todas com escalões gratuitos generosos para um projeto deste tamanho. Cria já o projeto Supabase e a conta Stripe, vais precisar das chaves de API de ambos nos próximos passos.
Um repo no GitHub, para que o deploy na Vercel no fim seja um único push em vez de um upload manual.
Montar o Projeto
Arranca a partir de um projeto Next.js 16 limpo, com Turbopack e Tailwind CSS v4 já ligados.
npx create-next-app@latest signal --typescript --tailwind --app --turbopack
cd signal
npx shadcn@latest initInstala os pacotes de que o resto desta construção depende: o cliente SSR do Supabase para autenticação e Postgres, e o SDK do Stripe para faturação.
npm install @supabase/ssr @supabase/supabase-js stripe zodAbre o projeto no Claude Code:
claudeEscrever o CLAUDE.md e o AGENTS.md
O Claude Code lê o AGENTS.md para as convenções da framework e o CLAUDE.md para as regras específicas do projeto. Na versão canary do Next.js 16 ambos são gerados por ti. Se estás no stable, cria o AGENTS.md tu mesmo com uma linha a apontar para os docs incluídos:
node_modules/next/dist/docs/Depois escreve o CLAUDE.md. Este é o ficheiro que impede o Claude de adivinhar a tua stack, a tua organização de ficheiros e as tuas convenções de nomes em cada sessão.
@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)A linha do row-level security importa mais do que parece. Sem ela dita de forma explícita, o Claude às vezes escreve uma tabela e esquece-se de ativar o RLS nela, o que significa que cada linha fica legível para toda a gente por omissão na Supabase.
Planear o Esquema da Base de Dados no Plan Mode
Antes de qualquer código ser escrito, usa o Plan mode para trabalhar o esquema.
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."O Claude volta com quatro tabelas (profiles, boards, posts, votes), as chaves estrangeiras entre elas e um plano para o RLS: os quadros e os posts são publicamente legíveis para que um link de quadro funcione para visitantes anónimos, mas as escritas exigem um utilizador autenticado que seja dono do recurso. Revê isto antes de seja o que for ser construído. As decisões de esquema são as mais caras de reverter depois de teres dados reais nas tabelas.
Configurar o Supabase e o Postgres
Cria um novo projeto Supabase a partir do dashboard, depois corre o esquema pelo editor de SQL. Esta é a migração que efetivamente foi para produção:
-- 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;Todas as tabelas têm o RLS ativado e uma política. Os quadros e os posts são legíveis por qualquer pessoa, já que o objetivo de um quadro de feedback é um link público, mas só o dono (ou um votante autenticado, no caso dos votos) pode escrever neles. Agarra o URL do projeto e a anon key nas definições de API da Supabase e mete-os no .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-keyAutenticação Com o Supabase
Dois clientes Supabase cobrem a app inteira: um que corre no servidor com a sessão do visitante, e outro com a service role key que salta o RLS para o webhook do Stripe mais à frente.
// 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 } }
);
}O proxy.ts (não middleware.ts, esse nome desapareceu no Next.js 16) protege as rotas do dashboard ao verificar se existe uma sessão antes de o pedido chegar à página.
// 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*"],
};A página de login é apenas um formulário de email e password suportado por uma Server Action. Nada de especial, magic links funcionariam igualmente bem se preferires saltar as passwords por completo.
// 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");
}Construir a Funcionalidade Central: Quadros e Votos
O dashboard lista os quadros de um utilizador e deixa-o criar um novo. Contas gratuitas ficam limitadas a um quadro, imposto na Server Action, não só na 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");
}A página pública do quadro é onde os params assíncronos importam. No Next.js 16, params é uma Promise, e await params é obrigatório antes de conseguires ler o 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>
);
}Votar precisa de um pequeno Client Component, já que responde a um clique. O voto em si corre por uma Server Action para que a verificação do RLS aconteça no servidor, não no 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 });
}
}A constraint unique (post_id, voter_id) do esquema é que faz o trabalho de verdade aqui. Se um utilizador votar duas vezes, o insert falha, a contagem não incrementa, e não é preciso lógica de aplicação extra para impedir o voto duplo.
Adicionar o Stripe Checkout Para o Plano Pro
Cria primeiro um produto e um preço recorrente no dashboard do Stripe, depois liga o fluxo de checkout. Iniciar o checkout é uma Server Action que redireciona diretamente para o 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!);
}O plano só muda de verdade depois de o Stripe confirmar a subscrição, através de um webhook, não no redirect de sucesso. Os redirects podem ser falsificados ou interrompidos, os webhooks é que são a fonte da verdade.
// 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 });
}Reencaminha os eventos para o teu servidor local enquanto testas com a CLI do Stripe, e copia o signing secret que ela imprime para o STRIPE_WEBHOOK_SECRET.
stripe listen --forward-to localhost:3000/api/webhooks/stripeA própria página de faturação quase não muda, por isso é um sítio razoável para recorrer à diretiva "use cache" que substituiu o experimental.dynamicIO no 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>
);
}Barreiras de Qualidade Antes de Pores em Produção
Duas verificações correm antes de cada commit, sem exceções.
npx tsc --noEmit
npm run buildPede ao Claude Code para correr as duas depois de ligares o webhook e o fluxo de faturação, já que os tipos do Stripe são estritos e fáceis de acertar só por pouco à primeira.
claude "run tsc --noEmit and fix any type errors, then confirm the build passes"Este é também o sítio onde efetivamente testas os fluxos à mão: regista-te, cria um quadro, atinge o limite do plano gratuito, faz upgrade pelo modo de teste do Stripe, confirma que o webhook vira o plano para pro. Nada disto é apanhado por um verificador de tipos. Tem de ser clicado à mão.
Fazer Deploy na Vercel
Faz push do repo para o GitHub, depois importa-o na Vercel. Define todas as variáveis de ambiente do .env.local no dashboard da Vercel antes do primeiro deploy, incluindo as chaves do Stripe e a service role key do 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 --prodUma coisa que as pessoas deixam passar: o webhook secret da tua sessão local de stripe listen é diferente do que recebes quando registas um endpoint real no dashboard do Stripe a apontar para o teu URL de produção. Cria esse endpoint depois do primeiro deploy, depois atualiza o STRIPE_WEBHOOK_SECRET na Vercel com o novo secret e volta a fazer deploy.
Como É Um Pipeline Coordenado
Uma única sessão do Claude Code, como a de cima, aguenta bem um projeto de fim de semana. O estrangulamento és tu: rever o plano, ler as políticas de RLS, clicar pelo fluxo de checkout à mão. Isso continua a ser verdade por muito bom que o agente seja, e é uma quantidade razoável de revisão para quatro funcionalidades.
Deixa de ser razoável assim que tens vinte funcionalidades em vez de quatro. É essa a lacuna para que o Code Kit de $29 foi feito: uma estrutura por cima do Claude Code que corre plano, construção, avaliação e teste em cada funcionalidade automaticamente, e impõe uma barreira de qualidade (zero erros de tipos, zero erros de lint, uma build limpa) antes de seja o que for ir para produção. O passo npx tsc --noEmit de cima é a mesma barreira, corrida à mão uma vez. Num pipeline, corre depois de cada funcionalidade sem tu pedires.
Posted by @speedy_devv
Quer o framework por trás destes projetos?
Obtenha o sistema Claude Code que usamos para planejar, construir, testar e lançar software em produção.
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.
Adicionar Autenticação Com o Claude Code (Supabase Auth)
Adiciona registo por email/password, Google OAuth, magic links, rotas protegidas e gestão de sessões a uma app Next.js 16 usando o Claude Code e o Supabase Auth.

