This is the round trip that every "add a thing" feature in your app makes. The names on the boxes are the layers you've met, now doing their jobs in order. Watch what each one is holding.
Server actions are the Next.js shortcut: a function you write once, marked "use server", that the form calls directly. Under the hood it's still an HTTP POST.
"use server" export async function createPost(form) { const post = await db.post.create({ data: { title: form.get("title"), … } }); revalidatePath("/blog"); }
Six hops. The database is the only one that remembers anything after the request ends.
A browser tab can't hold a database password; anyone can open devtools and read it. So the tab never talks to Postgres. It talks to your server, which talks to Postgres.
In Next.js that's three doors into the server, all of which end up in the same Node process:
| door | who uses it | looks like |
|---|---|---|
| Server component | Pages that read | const posts = await db.post.findMany(), right in the component |
| Server action | Forms and buttons that write | "use server" function, called like a normal function |
| Route handler | Other clients: mobile apps, webhooks, cron | app/api/…/route.ts, returns JSON |
An ORM (Prisma, Drizzle) turns a typed call into SQL and a row back into a typed object. It's the reason post.title autocompletes.
// what you write const posts = await db.post.findMany({ where: { published: true }, orderBy: { createdAt: "desc" }, take: 10, }); // what Postgres receives SELECT * FROM "Post" WHERE published = true ORDER BY "createdAt" DESC LIMIT 10;
Prisma also owns the schema file and the migrations. tRPC, if you've seen it in T3, is a typed way for client code to call server functions, sitting one layer above this.
NEXT_PUBLIC_ means "ship this to everyone".Environment variables live in .env and are read by Node. They never reach the browser, unless the name starts with NEXT_PUBLIC_. Then the bundler pastes the value straight into the JavaScript it sends to every visitor.
Rename the database URL and watch it show up in the bundle. This exact mistake is how keys end up on GitHub and in other people's devtools.
Static pages are rendered once at build and served from cache. That's why your new post "doesn't show up": the cached HTML is from before it existed. revalidatePath("/blog") in the server action throws that cache away. Reading cookies() or headers() in a page is different: it doesn't clear a cache, it stops the page being cached at all (the ƒ in your build output).
In next dev every page renders fresh on every request, so this only bites after deploy: works locally, stale in production.
The full caching model in Next.js has changed twice in three years and is genuinely the hardest part of the framework. The one sentence that always holds: if you wrote and it didn't show, something cached the read.
| page is… | rendered | fresh when |
|---|---|---|
| static (default when possible) | once, at build | you revalidate, or redeploy |
| dynamic (uses cookies, headers, search params) | every request | always |
revalidated (revalidate = 60) | at most once per 60 s | within a minute |
Every layer you've walked through appears in that one Save click. Now the last question: why do people bundle these choices into named "stacks"?