Objective
What you're building
The full shell: sign up, log in, log out, a real database schema, and a live Vercel URL — deployed today, with zero AI functionality yet. This is Type D's "ship the shell first" principle from Course 04, now at real-SaaS scale.
Server vs. client components: the mental model before you write a line
Default to server components. Add "use client" only when you need interactivity
Next.js App Router components are server components by default -- they run on the server, can talk to your database directly, and never ship their code to the browser. Add 'use client' at the top of a file only when it needs browser-only things: useState, onClick, useEffect. Getting this backwards (marking everything 'use client' out of habit from pre-App-Router React) is the #1 confusion point for developers new to this architecture -- and it silently bloats your JS bundle with server logic that never needed to ship to the browser.
// app/dashboard/page.tsx -- a SERVER component (no 'use client')
import { createClient } from '@/lib/supabase/server';
export default async function DashboardPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
return <div>Welcome, {user?.email}</div>;
}
Notice this component is async and queries Supabase directly, no useEffect, no loading state, no client-side fetch -- because it runs on the server before the page ever reaches the browser. This is the actual advantage of the App Router model over older client-fetched-everything patterns: less code, less loading-state complexity, for anything that doesn't need real-time interactivity.
Supabase SSR auth: cookies, not localStorage
Supabase auth in Next.js App Router uses server-side cookies, not the client-side pattern you may have seen in tutorials for other frameworks
The @supabase/ssr package's createServerClient reads/writes auth cookies through Next.js's cookie APIs, which is what lets a SERVER component (like the dashboard above) know who's logged in without any client-side JavaScript running first. Using the plain @supabase/supabase-js client (the browser-oriented one) for server-side auth checks is a common mistake that either doesn't work or creates security gaps -- always use the SSR-specific client on the server.
Database schema and row level security
create table usage_limits (
user_id uuid references auth.users(id) primary key,
proposals_used integer default 0,
subscription_status text default 'free' check (subscription_status in ('free', 'active', 'canceled'))
);
alter table usage_limits enable row level security;
create policy "Users can only read their own usage"
on usage_limits for select
using (auth.uid() = user_id);
Row Level Security (RLS) is not optional hardening you add later -- without it, usage_limits is queryable by any authenticated user for ANY user_id, not just their own, from the client. The policy above is the actual security boundary: "you can only see your own row," enforced by Postgres itself, not by application code that could have a bug.
You test your app and a logged-in user can somehow see another user's proposal count in a network request, even though your frontend code only ever queries for the current user's ID. What's the most likely cause?
If the frontend "only ever queries for the current user," but a response contains another user's data anyway, the database isn't actually restricting access -- meaning RLS is disabled or the policy doesn't correctly scope by auth.uid(). This is exactly why RLS is the real security boundary, not application-level query filtering, which a determined user could bypass by calling the API directly with a different ID.
What you're building today
A deployed Next.js App Router project with shadcn/ui, Supabase auth (sign up/log in/log out working end-to-end), a usage_limits table with RLS enabled, and a live Vercel URL — verified by actually signing up, logging out, and logging back in at the real deployed URL, not just localhost.