Skip to main content

How a running Next.js app talks to the server: RSC fetches and Server Actions

· 24 min read
Pere Pages
Software Engineer
A running Next.js app sending requests to its server and getting streamed answers back: RSC payloads for reads, Server Action POSTs for writes

When you first open a Next.js app, the browser downloads a document: a page of HTML (HyperText Markup Language). The browser reads it, and then React makes it interactive (this step is called hydration). After that, open the Network tab of your browser's developer tools and you'll see that the app never asks for a document again. Instead you see a small, steady number of unusual requests. Some are GETs that return something that isn't HTML. Others are POSTs to the page you're already on, and their answer arrives in pieces. Those POSTs look the strangest, and they're what this post explains.

note

This post describes Next.js 16 and React 19, as of 2026. The wire format is internal to both and changes between versions, so check the current docs before relying on a detail.

This post continues From URL bar to interactive, which described, step by step, how the app starts until the page becomes clickable. This one starts right after hydration. It describes every way a running App Router app (the Next.js routing system built on the app/ folder) talks to its server:

  • the reads
  • the writes
  • the React hooks (functions) you use around them
  • what happens without JavaScript (JS)
  • why every one of those POSTs is a public endpoint (an address anyone can call) that you must protect, even though nothing warns you

The one idea to remember​

After hydration, a running App Router app never downloads an HTML document again. The only exception is a hard navigation: a full page reload, or a link to another site. A hard navigation starts the whole boot sequence from the previous post again.

Every normal move inside the app uses the data format that the boot post described in its RSC payload section: the React Server Components (RSC)[1] payload. React's internal name for this format is "Flight". The app sends it over HTTP (Hypertext Transfer Protocol) with only two request types, or methods:

DirectionWhat it doesMethodWhat comes back
ReadMove to a page, load a page early, or refresh the current pageGETA streamed RSC payload with the parts of the page that changed
WriteRun a change on the server (a Server Action)POSTA streamed RSC payload: the return value + any parts of the page drawn again

Both directions use the same Flight format. The only real differences are the HTTP method, and whether the request carries a body (data sent with the request). Remember this: the rest of the post fills in the two rows of that table.

Flight is React's way to turn a rendered component tree into text that can travel over the network. For actions, it also carries the arguments and the return value. It isn't HTML and it isn't JSON (JavaScript Object Notation). It's a stream of numbered rows, one per line, each written as id:payload. A row can:

  • point at an earlier row by its number ($1);
  • point at the code file of a Client Component by its module identifier (ID) (I["/_next/…",[…]]);
  • stand in for a value that is still on its way ($@1 means a promise that hasn't resolved yet).

The format is made for the React runtime to read, not for people. That's why it looks like random text in the browser's developer tools (DevTools). Its MIME (Multipurpose Internet Mail Extensions) type, the label that tells the browser what kind of data a response holds, is text/x-component. When this post says "RSC payload", it means a Flight stream.

Here are two rows from a Flight stream. Row 3 is a div rendered on the server, and inside it is a reference to a Client Component that row 4 describes:

3:["$","div",null,{"className":"card","children":["$","$L4",null,{"count":3}]}]
4:I["/_next/static/chunks/counter-a1b2.js",["Counter"]]

Row 3 is plain server output. The $L4 inside it is a placeholder that says: "a Client Component goes here, and row 4 says where its code is." This reference by ID is what lets Server Components and Client Components mix in one payload.


The read path (GET)​

When the app needs data or a new screen, it calls fetch — not for a page, but for a payload.

A navigation is a GET for a payload, not a page​

Click a <Link> (or call router.push()), and the client router (the part of Next.js in the browser that moves you between pages) sends a GET to the new page's address. The request carries one important header: RSC: 1. It tells the server: "don't render a document — send the Flight payload."

Next.js also sends Next-Router-State-Tree. This header is a compact copy of the page parts that are on screen right now, so the server can work out only what changed. The answer comes back as Content-Type: text/x-component, and it streams (it arrives in pieces).

GET /dashboard/settings ← the DESTINATION page
RSC: 1 ← "send the Flight payload, not a document"
Next-Router-State-Tree: %5B%22%22%2C… ← URL-encoded copy of the client's current tree

→ 200 Content-Type: text/x-component (streamed, in chunks)
2:I["/_next/static/chunks/settings-9f2a.js",["SettingsForm"]]
0:["$","div",null,{"children":["$","$L2",null,{}]}] ← only the changed segment

Notice what's missing: no <!doctype html>, no <head>, no framework <script> tags. It's the same numbered-row Flight format from above, with only the parts that differ. The client then reconciles them: it compares them with what's on screen and updates only what changed.

Partial rendering: only the changed segments​

Next.js splits each page into route segments: one per folder in the address, each with its own layout and page. Because the request told the server which tree you have now — in that Next-Router-State-Tree header — the server can render and return only the segments that differ.

Move between two pages that share a layout, and the layout isn't fetched or rendered again. It stays on screen, and only the inner segment changes. This is why moving around an App Router app doesn't feel like a full page load.

If HTTP is stateless, how does the server know the tree?​

It doesn't. HTTP is stateless: the server doesn't remember you from one request to the next, and that's the point. There's no server process per user, no copy of your tree in the server's memory, no connection kept open. The server keeps nothing about your app.

Instead, the client holds the real state, and it sends the part the server needs with every request. The Next-Router-State-Tree header is the current tree, written as text and attached to that one GET. The server reads it, compares, renders the changed segments, and then forgets everything. The next request brings its own new copy.

This design is why the app is fast and can grow to many users; it isn't a problem the app has to work around. In the table below, RAM (random-access memory) is a server's working memory, and a CDN (content delivery network) is a group of servers spread around the world:

If the server kept your tree in memory…Because the server keeps nothing…
Every user needs memory on one specific serverAny request can go to any server
Each user's requests must go to "their" server (sticky sessions)A CDN, or a serverless platform that starts servers only when needed, can run zero copies when idle and many when busy
A restart or a new deploy loses the requests in progressServers can be replaced at any time; nothing is lost

best good okay worst

The cost: on each navigation the client sends its router state again — a few hundred bytes of header. That's a small price for a server that never has to remember anyone. (The client-side Router Cache from the boot post is the client's memory of pages it already loaded; the server keeps none.)

Prefetching: the payload is usually already here​

When a <Link> appears on screen, Next.js prefetches it: it loads the payload of the page behind the link early, at low priority, before you click. The request carries Next-Router-Prefetch: 1.

  • Static routes (pages that are the same for every visitor) are prefetched in full.
  • Dynamic routes (pages built for each request) are prefetched only up to the nearest loading boundary, the point where the page shows its loading.tsx placeholder. So at least the outer frame of the page appears at once.

You can force or turn off prefetching with the prefetch prop. This is why most moves inside the app feel like they don't use the network at all — and when the payload is already in the cache, they don't.

GET /dashboard/settings ← sent when the link appears on screen, not on click
RSC: 1
Next-Router-Prefetch: 1 ← "only the part that can be loaded early, low priority"
Next-Router-State-Tree: %5B%22%22%2C…

→ 200 Content-Type: text/x-component (kept in the Router Cache for the real click)

The only difference from a real navigation is the extra Next-Router-Prefetch: 1 header — same URL (Uniform Resource Locator), same method. When you do click, the payload is already in the client cache.

router.refresh(): reload this page's data, keep your state​

There's a third read, and it isn't a navigation. router.refresh()[2] asks the server again for the RSC payload of the page you're already on. It clears the Router Cache entry for that page, and merges the new server render into the tree without losing client React state (form inputs, useState, scroll position).

Use it to get fresh server data after something changed outside the app, for example when another user edited a record. It's the same GET RSC request, pointed at the current page instead of a new one.

GET /dashboard/settings ← the page you're ALREADY on
RSC: 1
Next-Router-State-Tree: %5B%22%22%2C…

→ 200 Content-Type: text/x-component (fresh render; client state is kept when merged)

On the network it looks exactly like a navigation to the same URL. The difference is all in the browser: the router keeps your React state and only replaces the parts the server rendered.

The Router Cache decides whether a navigation or a prefetch uses the network at all. The rule is simple: fresh cache → no request; otherwise ask the server. router.refresh() always asks the server. The boot post's navigation appendix has the full flowchart of when the cache is used; I won't repeat it here.


The write path: Server Actions on the network​

Now the requests this post set out to explain. A Server Action is an async function marked 'use server'[3][4]. It runs on the server, but the client calls it as if it were a normal local function. Behind the scenes, that call is an HTTP POST with a specific shape.

The parts of the POST​

POST /dashboard/settings ← the CURRENT page URL, not /api/anything
Next-Action: 7f9c1a… ← ID of the 'use server' function to run
Content-Type: text/plain ← or multipart/form-data (see below)

[serialized arguments] ← the body: the action's arguments, Flight-encoded

Four parts are worth naming:

  • It POSTs to the page that calls it. There's no REST (Representational State Transfer) endpoint and no /api route (API: application programming interface). This is true even when the action is defined in a shared file like actions.ts: the POST goes to whatever page you're on. Next.js finds the right function from the Next-Action header, not from the path.
  • Next-Action says which function to run. It's an ID that can't be guessed, and it changes between builds.
  • The body carries the arguments. Normal JS values are Flight-encoded and sent as text/plain. As soon as a FormData or a File is involved — the <form action={…}> case — the body becomes multipart/form-data, so files can travel with it.
  • The answer streams back as text/x-component — the same Flight format as every read.

What it really looks like in DevTools​

The block above is simplified. Here's the real request. Say you call updateProfile("Ada Lovelace", true) from a button. Next.js sends:

POST /dashboard/settings HTTP/2
accept: text/x-component
next-action: 40a1f2c9d7e3b6c8a5019f2e7d ← the action's ID, made at build time
content-type: text/plain;charset=UTF-8

["Ada Lovelace",true] ← the arguments array, Flight-encoded

For a <form action={…}> submit, the body is multipart/form-data instead, and it's even stranger. React sends each form field as a separate part, with a name that starts with a number and an underscore (1_), plus one row that says how to rebuild the arguments:

POST /dashboard/settings HTTP/2
next-action: 40a1f2c9d7e3b6c8a5019f2e7d
content-type: multipart/form-data; boundary=----WebKitFormBoundaryK9c

------WebKitFormBoundaryK9c
Content-Disposition: form-data; name="1_name"

Ada Lovelace
------WebKitFormBoundaryK9c
Content-Disposition: form-data; name="1_email"

ada@analytical.engine
------WebKitFormBoundaryK9c
Content-Disposition: form-data; name="0"

["$K1"] ← "argument 0 = rebuild the FormData from the 1_* fields"
------WebKitFormBoundaryK9c--

The response is the part that looks most like random text. It's a stream of numbered Flight rows. Because it streams, the rows can arrive out of number order:

→ 200 content-type: text/x-component

0:{"a":"$@1","f":"$3","b":"K3n7p2Q..."} ← the envelope: a→return value, f→the re-rendered UI (row 3), b→build id
2:I["/_next/static/chunks/app/dashboard/page-8c2f.js",["default"]] ← a Client Component's code file
1:{"ok":true,"id":42} ← your action's REAL return value (fills in the $@1 above)
3:["$","$L2",null,{"user":"$4"}] ← the re-rendered segment (because the action updated data)
4:{"name":"Ada Lovelace","subscribed":true} ← data that row 3 points at

How to read the markers:

MarkerMeaning
0: … 4:Row IDs — each line is id:payload, and rows point at each other by ID
$@1A pending promise (a value not ready yet), filled in by the row with that ID (here, row 1 = the return value)
$L2A lazy Client Component reference (its code loads only when needed) — its module is in row 2
I["/_next/…",["default"]]A client module import — the URL of the code file + the export(s) to load
$4A plain reference to the value in row 4

So the strange POST is really two things. On the way up, it carries your arguments, Flight-encoded. On the way down, it carries your return value and a new render of whatever the action changed, mixed together in one streamed answer.

note

These exact field names (a/f/b) and markers are React and Next.js internals, and they change between versions — newer builds have already renamed the fields again. Don't parse them by hand. What stays the same, and is worth recognizing, is the shape: an envelope, your return value, then re-rendered rows.

One round-trip, two things come back​

This is the clever part, and the reason the answer streams. One Server Action POST[5] returns a payload that carries two things at once: your return value, and an updated user interface (UI).

In the streamed answerWhat it is
The return valueWhatever your action returned — a result object, a status, an error
The re-rendered UIA fresh RSC render of the current page, but only when the action changed something (see the list below)

The fresh render is included only when the action does one of these:

  • calls updateTag or revalidatePath, which mark cached data as out of date right away;
  • calls refresh() (from next/cache), which renders the current page again without touching the cache;
  • sets or deletes a cookie;
  • calls redirect().

revalidateTag is the exception. With a stale-while-revalidate profile (keep serving the old data while new data loads in the background), it doesn't re-render the page in this response, and the change shows up on a later read. An action that does none of these returns only its value.

So a change and the UI update that shows it arrive in one round-trip. The client merges the fresh segments into the current tree with the same comparison process the boot post described in its reconciliation section:

  • only changed subtrees are replaced in the DOM (Document Object Model, the browser's live tree of the page);
  • Client Component code that was already downloaded is reused;
  • nothing that didn't change hydrates again.
You make the change and get the updated screen back in one streamed POST, with no second request.

Redirects and ordering​

Two more behaviors complete the write path:

  • redirect() inside an action doesn't send an HTTP 3xx redirect status. The server renders the destination page and streams its RSC payload back in the same response, and the client router moves to it. There's no second GET.
  • Actions run one at a time.[5] The Next.js client sends one action, waits for it to finish, then sends the next. This keeps the re-rendered page consistent with the result that produced it. The server itself doesn't queue actions; the client does. One consequence: Promise.all won't run actions in parallel. If you need parallel work, do it inside a single action.

The hooks around the round-trip​

You rarely work with the POST directly. Three React hooks each cover one phase of that round-trip, and knowing which phase each one covers is most of what you need to understand:

HookPhase it coversGives you
useActionStateThe whole callThe action's latest return value + a pending flag (true while it runs)
useFormStatusWhile the request runs, inside a <form>The pending state for components inside the form (for example a submit button)
useOptimisticBefore the answer arrivesA temporary optimistic state (the result you expect), shown while the POST runs

Here's how they work together:

  1. When you submit, useOptimistic[6] shows the expected result right away, before the POST returns.
  2. useFormStatus disables the button while the request is running.
  3. When the stream ends, useActionState gives you the real return value, and the fresh UI is already merged in.

You don't need to undo the optimistic value yourself. When the action finishes, or fails, React stops showing it and shows the real state again. If the action returned an error, the error comes back through useActionState, so you can show a message.

Naming note

useActionState is the current hook (React 19 and recent Next.js versions). Older code and posts use useFormState — same idea, old name.


It still works without JavaScript​

This property makes Server Actions more than a nicer fetch. Connect one to a form:

async function subscribe(formData: FormData) {
'use server';
await db.subscribe(formData.get('email'));
}

// in a Server Component
<form action={subscribe}>
<input name="email" type="email" />
<button>Subscribe</button>
</form>

Before the JavaScript has loaded and hydrated the page — or with JavaScript turned off — that form is a plain HTML form. Submitting it makes the browser send a normal POST to the same URL. The server runs the action, and the answer is HTML. The feature falls back to the oldest mechanism on the web.

Once the page has hydrated, the client router catches the submit and sends the streamed Flight POST from the write path instead. There's no full page load, optimistic updates are possible, and the page is updated in place. Same endpoint, same action, two ways to deliver it. This is progressive enhancement — the page works without JavaScript and gets better with it — and you get it by default, not as an extra.


Every Server Action is a public endpoint​

Most tutorials skip this part, and it's the one with real consequences. Anyone can send that Next-Action POST — it's an HTTP endpoint like any other. Next.js adds several protections around it[7]:

ProtectionWhat it does
Encrypted action IDsActions are called by an encrypted ID that changes between builds, not by a readable route. But the ID is in your client JavaScript, so anyone who opens DevTools can read it. It hides nothing.
Dead-code eliminationActions that no client code uses are removed from the client code, so they get no ID and no public endpoint
Encrypted closuresAn action defined inside a component can use variables from around it (its closure). Those values travel to the client and back, so Next.js encrypts them with a key made for each build. The client can't read or change them.
CSRF (Cross-Site Request Forgery) protectionActions accept only POST, and Next.js checks that the Origin header matches the Host header, so another site can't send the request from your user's browser

But — and this is the key point — none of this checks who is calling or what they are allowed to do. Any logged-in user, or a request someone builds by hand, can still call the endpoint. So the rule is simple:

Treat every Server Action like a public API route. Inside the action body, check who the user is (authentication) and whether they may do this (authorization) — in every action, every time. The IDs and the encryption protect the mechanism, not your data.


When it's not an action: Route Handlers​

Server Actions are how your own UI changes data. When you need a real HTTP endpoint at a fixed address, use a Route Handler (route.ts)[8] instead. Here's how the three ways to reach the server compare:

You need…UseShape
To read data for a pageServer Componentasync component; runs on the server; sends HTML on first load, an RSC payload after
To change data from your own UIServer Action'use server' function; POST + streamed Flight; works without JavaScript
A public, stable HTTP endpointRoute Handlerroute.ts; a classic Request → Response at a fixed URL

Use a Route Handler when the caller isn't your React app:

  • webhooks (another service calling you when something happens);
  • a mobile app or a third-party client that calls a stable URL;
  • custom streaming or binary responses;
  • anything that must be a plain fetch target.

Everything else — everyday form submits and writes — is a Server Action, and you get the one-round-trip refresh and progressive enhancement for free.


The short version​

Here's everything in one table. (startTransition is the React function that marks an update as not urgent; an action called from a click handler runs inside it.)

Navigation / prefetch (read)Server Action (write)
MethodGETPOST
What starts it<Link>, router.push(), router.refresh(), prefetch<form action>, a call inside startTransition, a direct call to an imported 'use server' function
URLThe destination pageThe current page
Key headerRSC: 1 (+ Next-Router-State-Tree)Next-Action: <id>
BodyNoneSerialized arguments
Responsetext/x-component, streamedtext/x-component, streamed
  1. After hydration the app stops downloading documents — every server call is an RSC (Flight) payload over HTTP.
  2. Reads are GETs: navigation, prefetch and router.refresh() send RSC: 1 plus the current tree, and get back a streamed payload with only the changed segments.
  3. Writes are POSTs: a Server Action posts to the current page's URL with a Next-Action header and the arguments in the body.
  4. One round-trip returns two things: the action's return value and — when the action updated data, changed a cookie or redirected — the re-rendered UI. The client merges it in place, reuses code it already has, and doesn't hydrate everything again.
  5. useOptimistic, useFormStatus and useActionState cover the phases of that round-trip. redirect() streams the next page back in the same response. Actions run one at a time.
  6. It works without JavaScript: a form action is a normal browser POST without JS, and a streamed Flight POST with it.
  7. Every action is a public endpoint: the framework protects the mechanism, but you check authentication and authorization inside every action.
  8. When you need a stable HTTP endpoint for outside callers, use a Route Handler, not an action.

The picture to keep: a running Next.js app has only two things to say to its server — "show me this" and "do this" — and it says both in the same streamed Flight format. The only difference is GET or POST. The boot sequence gives you a working page; this is how that page talks to the server for the rest of its life.

References​

  1. React — Server Components
  2. Next.js — useRouter (router.refresh())
  3. React — Server Functions
  4. Next.js — use server directive
  5. Next.js — Server Actions and Mutations
  6. React — useOptimistic
  7. Next.js — How to think about data security in Next.js
  8. Next.js — route.js (Route Handlers)