Next.js · deeper

Press Save. Follow the data all the way down and back.

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.

the round trip
"use server"
export async function createPost(form) {
  const post = await db.post.create({
    data: { title: form.get("title"), … }
  });
  revalidatePath("/blog");
}

1 · browser

FormData title=… body=…

2 · network

POST /blog (multipart form)

3 · server action

createPost(form) runs in Node

4 · ORM (Prisma)

INSERT INTO "Post" (title, body) VALUES ($1, $2)

5 · Postgres

row id 42 written to disk

6 · back

re-render /blog new HTML → tab
press Save, or click any hop

Six hops. The database is the only one that remembers anything after the request ends.

The rule

Server code talks to the database. Browser code asks server code.

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:

doorwho uses itlooks like
Server componentPages that readconst posts = await db.post.findMany(), right in the component
Server actionForms and buttons that write"use server" function, called like a normal function
Route handlerOther clients: mobile apps, webhooks, cronapp/api/…/route.ts, returns JSON
the ORM's job

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.

The thing that leaks

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.

.env → what the bundler emits
DATABASE_URL=postgres://app:[email protected]/app
STRIPE_SECRET_KEY=sk_live_…
NEXT_PUBLIC_SITE_URL=https://myapp.com
bundle.js (sent to every browser)
const SITE = "https://myapp.com";
The part that trips everyone

When the server renders a page, Next.js may keep the result.

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…renderedfresh when
static (default when possible)once, at buildyou revalidate, or redeploy
dynamic (uses cookies, headers, search params)every requestalways
revalidated (revalidate = 60)at most once per 60 swithin a minute

That's the full vertical. Browser to disk and back.

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"?