You built it β now ship it
In Part 1 you got an app running on your machine. This part is the second foundation: the platforms that put it online, and the AWS services that do the same jobs. The backend parts (3β5) just say "now deploy it" β because the how lives here. Read it once; the others link back.
Everything below is something you can hand to Claude Code β "deploy this to Vercel," "provision a Supabase project," "put it behind CloudFront." This article is so you know what to ask for.
Every web app needs two things
A place to serve the frontend
Takes your built site and delivers it to browsers over HTTPS, fast, worldwide. "Hosting" / "deploy".
A backend
Stores data, signs users in, runs logic the browser can't be trusted with. Your API + database.
Vercel answers the first. Supabase answers the second. AWS does both β more pieces, more control.
Vercel β your deploy platform
Vercel hosts frontends (from the team behind Next.js). Connect a GitHub repo; every push builds and deploys, with a preview URL per branch and a global CDN.
npm i -g vercel
vercel # preview deploy
vercel --prod # production deploy
Why people reach for it: zero-config for Next.js, instant preview links, git-push deploys. Live in minutes.
Static files vs Next.js pages on Vercel
"Deploying to Vercel" hides a distinction worth understanding, because it decides what your site costs to run and what can break at 3am. Three different things can answer a request, and only one of them runs code:
| What it is | When it's produced | Who serves it |
|---|---|---|
Static file β anything in public/ | Never. You wrote it; it's copied as-is. | The CDN. No compute, nothing to fail. |
| Prerendered page β a Next.js route with no per-request data | At next build, once. | The CDN, same as a static file. |
| Dynamic page or route handler | On every request. | A Vercel Function β real Node.js, per-request cost. |
The build tells you which one you got. Run next build and
read the route table β β is prerendered, and
Ζ means a function runs on every hit:
$ next build
Route (app)
β β / β prerendered at build, served from the CDN
β β /_not-found
β (Static) prerendered as static content
Ζ (Dynamic) server-rendered on demand
A page goes dynamic the moment it reads something that only exists per request β
cookies(), headers(),
searchParams, or an uncached fetch. That's not a
failure; it's the correct answer for a logged-in dashboard. It's only a problem
when it happens by accident to a page that could have been free.
This page is the example. The workshop you're reading is a plain HTML file in public/. It's never rendered, never touches a function, and can't break at runtime β the CDN just hands it over. The site's homepage next door is a Next.js route that happens to prerender. Same deploy, three different economics.
Static with a shelf life. Between the two extremes sits revalidation (ISR): prerender the page, then let it refresh every N seconds or on demand. Readers still get a CDN response; the data just isn't frozen at build time. Next 16 also has an opt-in Cache Components model that makes this per-component rather than per-page.
Don't reach for the Edge runtime. It's a common reflex and usually the wrong one β it's a restricted runtime without full Node.js. Vercel's default (Fluid Compute) runs in the same regions at the same price, and streaming, SSE and AI token streaming all work there with no config. Stay on Node.js unless you have a specific reason not to.
Supabase β your backend platform
Supabase wraps a managed Postgres database with the services an app needs, so the database itself becomes your backend β no server to run.
Postgres + auto APIs
A real SQL database, reachable via REST, GraphQL, and RPC.
Auth
Email, magic links, and social sign-in, tied to the database.
Realtime
Subscribe to row changes over WebSockets β live updates in one line.
Row Level Security
Rules enforced per row, so it's safe to talk to from the browser.
The key idea: the database is your API (RPC)
With Supabase you often don't write API endpoints at all. You put logic in a Postgres function and call it by name β an RPC. The function is the endpoint:
// no URL, no route file β just call the function by name
const { data, error } = await supabase.rpc('submit_answer', {
game_id: gameId, player_id: playerId, option_id: optionId,
});
Pair that with Row Level Security and the database can safely be your entire backend β the rules travel with the data instead of living in an API layer you have to remember to write.
Handle SECURITY DEFINER with care. It's often described as "runs trusted, server-side", which undersells it: the function runs as its creator and bypasses RLS entirely. Worse, Postgres grants EXECUTE to everyone by default, so a SECURITY DEFINER function sitting in public is a public endpoint any anonymous visitor can call. Default to SECURITY INVOKER; when you genuinely need to bypass RLS, keep the function out of the exposed schema and check auth.uid() inside the function body yourself. Never reach for it just to make a permission error go away.
How Supabase Auth actually works
Auth is the part people most often get wrong when they move to Supabase, because it doesn't work like the auth service you're used to. Most auth providers hand you a token and leave enforcement to you. Supabase hands you a token the database itself understands β and that one fact explains everything else about it.
The token is a database credential
Sign a user in and Supabase issues a short-lived access token (a JWT) plus a long-lived refresh token. The interesting part is what's inside the access token:
// the claims that matter
{
"iss": "https://abcd.supabase.co/auth/v1",
"sub": "9f8cβ¦", // the user id β this is auth.uid() in SQL
"role": "authenticated", // an actual Postgres role
"exp": 1893456000
}
role is not a label your code checks β it is the
Postgres role the query runs as. So when the browser talks to the database, your
Row Level Security policies are the thing standing in the way, and
they're enforced by Postgres rather than by an API layer you had to remember to
write:
create policy "own rows only" on notes
for select to authenticated
using ( (select auth.uid()) = user_id );
That's why it's safe to query Supabase straight from the browser, and why "where do I put my API?" often has the answer "you don't need one".
browser ββtokenβββΆ Supabase βββΆ Postgres
β
βΌ RLS reads auth.uid() from the token
only that user's rows come back
The trap that bites everyone. A user can edit their own user_metadata, and it shows up in the token. Never make an authorization decision from it β no "is_admin" in user_metadata. Put anything that grants power in app_metadata, which users can't touch. Related: deleting a user does not invalidate tokens already issued to them β sign them out or revoke the session too.
On the frontend (Next.js)
Install @supabase/supabase-js and
@supabase/ssr. The browser gets a client built with your
publishable key β safe to ship, because RLS is what protects the
data:
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';
export const createClient = () => createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
Server Components can read the session from cookies but can't write them,
so refreshing an expiring token is the job of one file at the project root. In
Next 16 that file is proxy.ts β
middleware.ts is the deprecated name for the same thing.
It refreshes the token and writes it back to both the request and the response.
Which call you reach for on the server matters, and the answer changed recently:
| Call | Use it for | Cost |
|---|---|---|
getClaims() | Protecting pages and data. Verifies the token's signature locally against the project's public keys. | No network call. |
getUser() | When you need the freshest profile β it asks the Auth server. | A round trip per call. |
getSession() | Only when you need the raw tokens to forward somewhere. | Cheap, but the user object on it isn't trustworthy on its own. |
Never gate a page on getSession(). The session comes from cookies, and cookies can be forged. It doesn't re-verify anything. Use getClaims() to decide whether someone gets in.
On a separate backend
You don't have to give up your own API. If you're running the Python service from Part 6, the frontend sends the same token it already has, in the header the whole web already understands:
Authorization: Bearer <supabase access token>
Your backend then verifies it locally. Supabase publishes its public keys, so there's no call back to Supabase on the hot path β fetch the key set once, cache it, and check the signature yourself:
# the project's public keys β cache these, don't fetch per request
https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
# then, per request:
# 1. verify the signature against the matching key
# 2. check exp and iss
# 3. trust sub as the user id β never read the id from the body
This is the same shape as the rest of the series. Verifying a signature at the edge of your service and passing a trusted user id inwards is exactly what the platform's Lambda Authorizer (Part 4) does, and Part 9 covers the header contract in full. Only the issuer changes.
Supabase Auth vs Cognito
They both issue JWTs and both publish a JWKS endpoint. The difference is what the token is for:
| Supabase Auth | Amazon Cognito | |
|---|---|---|
| What the token buys you | A database identity β role and auth.uid() are read by Postgres itself. | Proof of identity for your app. Nothing downstream interprets it for you. |
| Where rules live | RLS policies, next to the data, enforced on every query. | In your application code, or an API Gateway authorizer you write. |
| Tokens issued | Access + refresh. | ID + access + refresh β and conflating the first two is the classic Cognito bug. |
| Can the browser hit the data directly? | Yes β that's the design. | No. Cognito fronts an API you build. |
| Roles & permissions | app_metadata and custom claims. | Groups, surfaced as the cognito:groups claim. |
| AWS credentials for a user | No equivalent. | Identity Pools vend temporary IAM credentials β genuinely useful for direct S3 access. |
| Setup cost | Minutes; it's on by default in a new project. | A user pool, an app client, domains, flows β an afternoon, and easy to misconfigure. |
| Where it hurts | You're adopting Postgres-shaped auth; escaping RLS later means rebuilding enforcement. | Painful to migrate off (password hashes don't export), and the console is a maze. |
Choosing: if your data lives in Supabase Postgres, use Supabase Auth β fighting to put Cognito in front of RLS throws away the main benefit. If you're already on AWS with API Gateway and Lambda, Cognito is the path of least resistance, and Identity Pools are the one capability Supabase has no answer to. Both are fine; mixing them without a reason is how you end up maintaining two user directories.
The AWS alternatives
AWS hands you individual services you assemble. Every job Vercel and Supabase do has an AWS counterpart:
| Job | Vercel / Supabase | AWS equivalent |
|---|---|---|
| Host the frontend | Vercel | S3 + CloudFront, or AWS Amplify Hosting |
| Run backend code | Vercel Functions / Supabase RPC | Lambda + API Gateway, or containers on ECS Fargate |
| Database | Supabase Postgres | RDS / Aurora (SQL) or DynamoDB (NoSQL) |
| Auth | Supabase Auth | Cognito |
| Realtime | Supabase Realtime | API Gateway WebSockets, or AppSync |
| File storage | Supabase Storage | S3 |
| Deploy on push | Built in | Amplify, or GitHub Actions + SAM/CDK/Terraform |
Quickest AWS deploy: build to static files, aws s3 sync to a bucket, put CloudFront in front. Or use Amplify Hosting for Vercel-like git-push deploys on AWS.
Which should you choose?
| If you⦠| Lean toward |
|---|---|
| Want to ship fast with a small team | Vercel + Supabase |
| Are already on AWS / have compliance needs | All-AWS |
| Want the shortest path to "it works" | Vercel + Supabase |
Capture your deploy setup in CLAUDE.md
Write your platform choices where Claude Code sees them every session β so "deploy this" just works:
# CLAUDE.md
## Deploy
- Frontend: Vercel, auto-deploys on push to main; preview on every PR.
- Manual: vercel (preview) / vercel --prod (production).
- Data: Supabase project ref abcd β migrations in supabase/.
## Environments & secrets
- Local env in .env.local (gitignored). Never commit keys.
- anon key = public/client-safe; service-role key = server-only.
- Mirror NEXT_PUBLIC_* vars in Vercel β Settings β Environment Variables.
## AWS alternative
- Host: aws s3 sync ./out s3://<bucket> + CloudFront invalidation, or Amplify.
## Rule
- Every deploy comes from git β no manual uploads.
How Claude Code helps
Ask for the platform Skills by name and let Claude Code run the deploy commands:
vercel
Deploy, env vars, domains, CLI workflows.
supabase
Database, Auth, Realtime, RLS, migrations.
aws-serverless / aws-amplify
Lambda, API Gateway, SAM, Amplify hosting.
"Which should I use?"
Describe your app + constraints; let Claude Code recommend a stack.
Next up β Part 3 Β· Monorepos, Subtrees & Shipping Many Services: how many repositories you keep, how shared code travels between them, and how each deployable reaches Vercel or AWS. Then Part 4 lays the AWS base layer.
After that, add a backend β your choice of three: Part 5 Β· Node.js API (in your Next.js app), Part 6 Β· Python API (AWS Lambda + SAM), or Part 7 Β· Docker + ECS Fargate (a containerized service). Same app β pick the stack.