Build and Ship an AI SaaS in 48 Hours · 50 min · Anthropic Claude API · Next.js App Router
Objective
What you're building
The actual product feature — but everything it needs (who's allowed to use it, how many times, what happens after) already exists from Lessons 2-3. Today is genuinely the "easy part" this course's ordering was designed to make true.
Check the gate before you generate, not after
// app/api/generate/route.ts
export async function POST(req: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response('Unauthorized', { status: 401 });
const { data: usage } = await supabase
.from('usage_limits')
.select('proposals_used, subscription_status')
.eq('user_id', user.id)
.single();
const isFreeAndOverLimit = usage?.subscription_status === 'free' && (usage.proposals_used ?? 0) >= 3;
if (isFreeAndOverLimit) {
return Response.json({ error: 'Free tier limit reached. Upgrade to continue.' }, { status: 402 });
}
const { clientName, projectDescription, serviceDetails } = await req.json();
// ... generation happens below, only after this check passes
}
Notice this check happens BEFORE any call to Claude -- checking the limit after generating would mean you've already paid the API cost for a request you're about to reject, and it opens a race-condition window where a user could fire several requests before the first one's usage update lands. Gate first, generate second, always.
Streaming in the app router
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic();
const stream = await anthropic.messages.stream({
model: 'claude-sonnet-5',
max_tokens: 2000,
messages: [{ role: 'user', content: buildProposalPrompt(clientName, projectDescription, serviceDetails) }],
});
const encoder = new TextEncoder();
const readableStream = new ReadableStream({
async start(controller) {
for await (const event of stream) {
if (event.type === 'content_block_delta' && 'text' in event.delta) {
controller.enqueue(encoder.encode(event.delta.text));
}
}
controller.close();
},
});
return new Response(readableStream);
App Router route handlers can return a raw ReadableStream directly as the Response body -- this is the correct current pattern, distinct from the older Pages Router API routes' response model. The Anthropic SDK's .stream() helper handles the SSE parsing for you server-side (unlike Course 04's Chrome extension, which had to parse raw SSE manually in the browser) -- here it's Node.js on the server doing that work, then re-streaming plain text to the client.
Increment usage AFTER a successful generation, not before
If you increment proposals_used before calling Claude and the API call then fails (rate limit, network error), the user loses a free-tier use for a proposal they never actually received. Increment only after the stream completes successfully -- this is a small ordering detail with a real fairness impact on a paying (or about-to-pay) user.
A user on the free tier with 2/3 proposals used opens two browser tabs and clicks "Generate" in both at nearly the same instant. Both requests pass the "under 3" check before either has incremented the counter. What happens?
This is a genuine race condition: both requests read proposals_used = 2 before either writes back an updated value, so both see "under the limit" and both proceed. A production-grade fix uses an atomic increment-and-check (a single database operation, or a Postgres function) rather than separate read-then-write steps. For this 48-hour MVP, it's a known, acceptable limitation to note rather than solve — but it's worth knowing it exists rather than being surprised by it later.
What you're building today
/api/generate: checks subscription/usage state, streams a real proposal from Claude, saves the completed proposal to Supabase, and increments usage only after success — with a UI that displays the streaming text as it arrives, tested with a real client name and project description, not lorem ipsum.