Updating the UI after a mutation
Show fresh data after a Server Action: updateTag, revalidatePath, refresh() and redirect, each recorded in Chrome, and which one fits each change.
- 01Show a change at onceCall updateTag after saving, so the action's own answer brings the page back with the new data.
- 02Pick the right callTell updateTag, revalidatePath, refresh() and revalidateTag apart by what each marks out of date and what the page shows next.
- 03Finish an action wellRedirect after updating, keep the calls in the right order, and know what the visitor sees when an action throws.
Saved, but the list didn't change
A mutation is a change to your app's data: a new tea, a new price, an order marked done. In Next.js, a form usually makes one with a Server Action, a function marked "use server" that runs on the server when the form calls it (Server Actions teaches them). Saving is only half of the job. The page the visitor is looking at was made before the change, and something has to bring it up to date.
The tea shop's staff page, /teas, lists the teas and has a box to add one. Asha, who works at the shop, types Jasmine tea and clicks Add tea. Here is the app, with an action that only saves. Like every example here, it's a real Next.js 16.4.3 project: it was built with next build, run with next start, and then used in real Chrome, and what happened was recorded.
app/teas/page.tsximport { Suspense } from "react";import { connection } from "next/server";import { countTeas, getTeas } from "../_data/teas";import { addTea } from "./actions"; async function SavedCount() { await connection(); const count = await countTeas(); return <p>Saved in the database: {count} teas</p>;} export default async function TeasPage() { const teas = await getTeas(); return ( <main> <h1>Our teas</h1> <ul> {teas.map((tea) => ( <li key={tea}>{tea}</li> ))} </ul> <Suspense fallback={<p>Counting…</p>}> <SavedCount /> </Suspense> <form action={addTea}> <input name="name" aria-label="Tea name" /> <button>Add tea</button> </form> </main> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Green tea
- Mint tea
Saved in the database: 2 teas
- Focus
- the button “Add tea”
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
POST/teas200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C49314%5D%7D%2Cnull%2Cnull%2C49250%5D%7D%2Cnull%2Cnull%2C49234%5D
- next-action
- 409464a2b19788ad529cc68048240c9a90f4725957
Start with app/teas/page.tsx, the file shown first. Line 13 gets the list from getTeas(), and lines 18 to 20 show one item for each tea. Lines 22 to 24 show SavedCount, inside Suspense. SavedCount, on lines 6 to 10, waits for a real request with connection() on line 7, then counts the teas on line 8, so it runs again for every visit. Lines 25 to 28 are the form: line 25 hands it the action addTea, and line 26 is the box, named name.
Now open app/_data/teas.ts, a stand-in for the shop's database. It keeps the teas in a JSON file, data/teas.json, which lines 5 to 7 read. getTeas, on lines 9 to 13, is different from the other functions:
- Line 10,
"use cache", tells Next.js to store what the function returns and use that stored result again, instead of running the function for every request. A stored result like this is a cached copy. Duringnext build, Next.js rangetTeasonce and kept its list. - Line 11,
cacheTag("teas"), gives the stored result a name, a tag. Code can later say "everything taggedteasis out of date".
Both get a whole stage of their own later, Rendering and caching. Here, only one thing matters: the list on the page is a copy. The Terminal tab shows ◐ /teas, prerendered as static HTML with dynamic server-streamed content: the heading and the cached list were made during the build, and the count is streamed in for each request.
In app/teas/actions.ts, line 1 marks the file's functions as Server Actions. Line 7 checks that the visitor is staff, lines 8 and 9 read the name and stop when it's empty, and line 10 saves the tea with saveTea, which adds it to the file. Every action starts with checks like these, because a Server Action is a public endpoint: anyone can send it a request, not only your form. requireStaff, in app/_data/staff.ts, is a stand-in that lets everyone through; a real one reads the visitor's session and throws when they aren't staff. Now open the Steps tab, on step 3, the click on Add tea. The click made one request: a fetch, POST /teas, with a next-action header that names the action. It answered 200 with text/x-component, an RSC payload: data for React, which Next.js's router reads instead of HTML. The page still lists Green tea and Mint tea, and the count still says 2. Only the box is empty again, because React resets a form's uncontrolled fields after its action finishes without an error.
Step 4 opens /teas again, as a new visit. The count says Saved in the database: 3 teas, so Jasmine tea was saved. The list still shows two teas.
/teas after a reload, in the version that only saves. The list comes from the copy getTeas stored during the build, which still holds two teas. The count is read from data/teas.json for each request, so it says 3. Saving changed the file, not the copy.A café copies its menu from the recipe book onto a board every morning, so customers don't queue for the book. When the cook adds a recipe to the book, the board doesn't change by itself. Someone has to wipe it and copy it again. If the cook asks for that right away, the next customer reads the new menu.
- In real life: The recipe book in the kitchen
- In Next.js:
data/teas.json, the stand-in database - In real life: The menu board out front, copied from the book this morning
- In Next.js: The list
getTeasstored, taggedteas - In real life: A cook who adds a recipe to the book
- In Next.js: A Server Action that saves
- In real life: Wiping the board and copying it again, right now
- In Next.js:
updateTag("teas") - In real life: Counting the book's pages whenever someone asks
- In Next.js:
SavedCount, read for each request
Saving data and showing it are two jobs. After a Server Action saves, tell Next.js what is now out of date: updateTag for a tag, revalidatePath for a page, or refresh() to render the page again without touching the cache. Then Next.js renders the page the visitor is on again, and the action's answer brings it back in the same round trip.
updateTag: show your own change at once
One line fixes the staff page. updateTag, from next/cache, expires every cached result that has the tag you pass, at once:
app/teas/actions.ts"use server"; import { updateTag } from "next/cache";import { requireStaff } from "../_data/staff";import { saveTea } from "../_data/teas"; export async function addTea(formData: FormData) { await requireStaff(); const name = String(formData.get("name") ?? "").trim(); if (!name) return; await saveTea(name); updateTag("teas");}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Green tea
- Mint tea
- Jasmine tea
Saved in the database: 3 teas
- Focus
- the button “Add tea”
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
POST/teas200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C49314%5D%7D%2Cnull%2Cnull%2C49250%5D%7D%2Cnull%2Cnull%2C49234%5D
- next-action
- 400c76bf67f7153933155436481c9cec2c61965ce4
Line 3 of app/teas/actions.ts imports updateTag. Lines 8 to 11 check and save, as before. Line 12, updateTag("teas"), expires the list getTeas stored, because line 11 of app/_data/teas.ts tagged it teas. Nothing else changed.
The Steps tab is open on the click. It made the same single request, POST /teas, answered 200 with an RSC payload. This time the page shows Green tea, Mint tea and Jasmine tea, and the count says 3. Steps 4 and 5, two new visits, show the same.
Here is what happened inside that one request. When an action expires a tag, Next.js doesn't only mark the old list. It also renders the page Asha is on again, on the server, before it answers. getTeas runs again, because its stored result expired, and reads three teas. The new page goes back in the same answer as the action's result: the single round trip Server Actions saw with revalidatePath. This is called read your own writes: the person who made a change sees it in the very next thing they read.
updateTag: one POST out, one answer back. The action saves and expires the tag, and Next.js renders /teas again in the same request, so the answer already holds Jasmine tea.Step through the click below, with updateTag and without it. Before you start, guess: in the version that only saves, which step is missing?
With updateTagafter step 1Our teas
Green tea
Mint tea
Saved in the database: 2 teas
Jasmine tea Add tea
React is running: the page responds to clicks.
The page isn't complete yet.
She typed Jasmine tea in the box. The page's JavaScript is running, so React calls the form's action instead of letting the browser post the form.
Without updateTag, there's no step where Next.js renders /teas again. The answer still arrives, but it only carries the action's result. The docs list what makes an action's answer carry the page again: calling updateTag, revalidatePath or refresh(), and setting or deleting a cookie. Calling redirect() makes it carry the next page instead.
Only in Server Actions
The shop's phone app adds teas too, through a Route Handler: a route.ts file that answers HTTP requests with code instead of a page (it gets a lesson of its own, next in this stage). Its first version calls updateTag:
app/api/teas/route.tsimport { updateTag } from "next/cache";import { saveTea } from "../../_data/teas"; export async function POST(request: Request) { if (request.headers.get("authorization") !== `Bearer ${process.env.PHONE_APP_KEY}`) { return Response.json({ error: "Not allowed" }, { status: 401 }); } const { name } = await request.json(); if (typeof name !== "string" || !name.trim()) { return Response.json({ error: "A tea needs a name" }, { status: 400 }); } await saveTea(name.trim()); updateTag("teas"); return Response.json({ saved: name });}$ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (4/4) Finalizing page optimization ... Route (app) Revalidate Expire┌ ○ /_not-found├ ƒ /api/teas└ ◐ /teas 15m 1y ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentƒ (Dynamic) server-rendered on demand $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ Ready⨯ Error: updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. See more info here: https://nextjs.org/docs/app/api-reference/functions/updateTag at y (.next/server/chunks/_12i7othk_xq83._.js:78:13879) at n (.next/server/chunks/[root-of-the-server]__1ofer2um-pvtv._.js:1:498) at async U (.next/server/chunks/[root-of-the-server]__1ofer2um-pvtv._.js:3:1713) at async q (.next/server/chunks/[root-of-the-server]__1ofer2um-pvtv._.js:3:2704) at async tW (.next/server/chunks/[root-of-the-server]__1ofer2um-pvtv._.js:3:5030)In app/api/teas/route.ts, line 4 exports POST. Lines 5 to 7 check the phone app's key, sent in the authorization header and kept on the server in .env, and answer 401 without it. Line 8 reads the tea's name from the request's JSON, lines 9 to 11 answer 400 when it isn't a name, line 12 saves it, and line 13 calls updateTag. The recorder sent {"name":"Jasmine tea"} there, like the phone app would. The Terminal tab shows what next start printed: Error: updateTag can only be called from within a Server Action. In the Network tab, the request answered 500, and /teas right after counts 3 teas but lists two: line 12 had already saved when line 13 threw.
The docs keep updateTag for Server Actions, where the person who made the change is waiting for the page. A Route Handler uses revalidateTag instead. Its second argument says how long an out-of-date result may still be sent; { expire: 0 } means not at all, so the next visit waits for a new one:
app/api/teas/route.tsimport { revalidateTag } from "next/cache";import { saveTea } from "../../_data/teas"; export async function POST(request: Request) { if (request.headers.get("authorization") !== `Bearer ${process.env.PHONE_APP_KEY}`) { return Response.json({ error: "Not allowed" }, { status: 401 }); } const { name } = await request.json(); if (typeof name !== "string" || !name.trim()) { return Response.json({ error: "A tea needs a name" }, { status: 400 }); } await saveTea(name.trim()); revalidateTag("teas", { expire: 0 }); return Response.json({ saved: name });}GET /teas200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-stale-time300x-nextjs-postponed1X-Powered-ByNext.jsCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<main>
<h1>Our teas</h1>
<ul>
<li>Green tea</li>
<li>Mint tea</li>
<li>Jasmine tea</li>
</ul>
<!--$?-->
<template id="B:0"></template>
<p>Counting…</p>
<!--/$-->
<form action="" enctype="multipart/form-data" method="POST"><input type="hidden" name="$ACTION_ID_4070709e76ee70d147d909ad77bd9074d340d8be10"><input aria-label="Tea name" name="name"><button>Add tea</button></form>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<script>…</script>
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
<div hidden="" id="S:0">
<p>Saved in the database: <!-- -->3<!-- --> teas</p>
</div>
<script>…</script>
</body>
</html>Line 13 is now revalidateTag("teas", { expire: 0 }). The request answered 200 with {"saved":"Jasmine tea"}, and the Network tab is open on /teas, right after: Jasmine tea is in the list.
revalidatePath: everything cached for one page
Sometimes you don't know the tags, or there are none. revalidatePath names a page by its address instead, and marks everything cached for that page as out of date. This version of app/teas/actions.ts changes only line 12:
app/teas/actions.ts"use server"; import { revalidatePath } from "next/cache";import { requireStaff } from "../_data/staff";import { saveTea } from "../_data/teas"; export async function addTea(formData: FormData) { await requireStaff(); const name = String(formData.get("name") ?? "").trim(); if (!name) return; await saveTea(name); revalidatePath("/teas");}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Green tea
- Mint tea
- Jasmine tea
Saved in the database: 3 teas
- Focus
- the button “Add tea”
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
POST/teas200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C49314%5D%7D%2Cnull%2Cnull%2C49250%5D%7D%2Cnull%2Cnull%2C49234%5D
- next-action
- 40feed70078df688b5fb5a610418710b30f5f03b19
Line 12 is revalidatePath("/teas"). The click's answer shows Jasmine tea and a count of 3, like the updateTag version. For this page, the two are the same. They differ in what they reach:
- A tag reaches every page that uses it. If a second page, say
/menu, also calledgetTeas,updateTag("teas")would expire its list too.revalidatePath("/teas")wouldn't:/menuwould keep the old list. - A path reaches everything on one page. Every cached result
/teasused, tagged or not.
A few rules from the docs:
- A page with a dynamic segment, like
app/teas/[id]/page.tsx, takes a second argument:revalidatePath("/teas/[id]", "page")for every tea's page, or a real address likerevalidatePath("/teas/mint")for one."layout"instead of"page"also reaches every page under that layout. - In a Server Action, it currently also makes every page the visitor already saw load again when they go back to it. The docs call this temporary.
- The docs prefer tags when you can use them: they say exactly which data changed, and nothing more.
refresh(): render the page again, cache untouched
refresh(), also from next/cache, expires nothing. It only asks Next.js to render the current page again and send it back with the action's answer. Here it is in place of line 12:
app/teas/actions.ts"use server"; import { refresh } from "next/cache";import { requireStaff } from "../_data/staff";import { saveTea } from "../_data/teas"; export async function addTea(formData: FormData) { await requireStaff(); const name = String(formData.get("name") ?? "").trim(); if (!name) return; await saveTea(name); refresh();}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Green tea
- Mint tea
Saved in the database: 3 teas
- Focus
- the button “Add tea”
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
POST/teas200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C49314%5D%7D%2Cnull%2Cnull%2C49250%5D%7D%2Cnull%2Cnull%2C49234%5D
- next-action
- 40f63240781928c9ded5b859da88119013f0e68334
After the click, the count says Saved in the database: 3 teas, and the list still shows two teas. The page was rendered again, so SavedCount read the file again. getTeas didn't run: its stored list was still valid, so Next.js used it. Steps 4 and 5 show the same two teas. refresh() never touches the cache.
So refresh() fits a change to data the page reads for each request, with nothing cached in between: a queue of orders, a count, anything behind connection() or cookies(). The docs describe it as refreshing the client router, so the screen matches the server again. If you saw router.refresh() in Linking and navigating, the idea is the same, but this one runs inside the action: the new page comes back in the action's own answer, with no second request from the browser. Like updateTag, it only works in Server Actions.
Five versions of one action
One more call belongs in the comparison, revalidateTag("teas", "max"), the one the phone app's Route Handler could use. Below are five versions of app/teas/actions.ts, each built and clicked through in Chrome. Before you click a version, guess what the list shows after the click, and after each of the two visits that follow.
app/teas/actions.tsNext.jsapp/teas/actions.ts"use server"; import { requireStaff } from "../_data/staff";import { saveTea } from "../_data/teas"; export async function addTea(formData: FormData) { await requireStaff(); const name = String(formData.get("name") ?? "").trim(); if (!name) return; await saveTea(name);}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Green tea
- Mint tea
Saved in the database: 2 teas
- Focus
- the button “Add tea”
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
POST/teas200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C49314%5D%7D%2Cnull%2Cnull%2C49250%5D%7D%2Cnull%2Cnull%2C49234%5D
- next-action
- 409464a2b19788ad529cc68048240c9a90f4725957
Line 10 saves, and that's all. After the click, the list and the count stay as they were. Open /teas again (step 4): the count says 3, but the list still shows two teas.
revalidateTag with "max" is the odd one. It marks the list stale: out of date, but still allowed to be sent while a new one is made. That's called stale-while-revalidate. The click's answer shows nothing new, because a stale tag doesn't make Next.js render the page again. Step 4, the next visit, still gets the old list, and starts making a new one in the background. When that refresh finishes, a later visit gets the new list. How soon that is depends on how long the new list takes to make, so the recording stops at step 4 and makes no promise about it. That's fine for a menu the public reads, where a moment's delay does no harm, and wrong for Asha, who wants to see her own change.
revalidateTag is what the docs describe; every other cell is what Chrome showed. updateTag shows it in the click's own answer. revalidateTag("teas", "max") sends the old list on the next visit too, and a later visit gets the new one once the refresh in the background has finished. refresh() and saving alone never touch the cached list.Each row is one version: what it marks out of date, what the page shows after the click, and where you may call it.
| Call | What it marks out of date | What the action's answer shows | Where you may call it |
|---|---|---|---|
updateTag("teas") | Every cached result tagged teas expires at once. | The page rendered again, with the new data, in the action's own answer. | Server Actions only. |
revalidatePath("/teas") | Everything cached for the page at /teas. | The page rendered again, with the new data, in the action's own answer. | Server Actions and Route Handlers. |
refresh() | Nothing in the cache. | The page rendered again: parts read for each request are new, cached parts stay as they were. | Server Actions only. |
revalidateTag("teas", "max") | Results tagged teas become stale: the next visit still gets them while new ones are made. | No new page in the answer. A later visit shows the change. | Server Actions and Route Handlers. |
| Nothing | Nothing. | No new page in the answer. Cached parts stay old until their lifetime runs out. | Not a call: the action only saves. |
The lifetimes behind these copies and the profiles like "max" are in Cache lifetimes and tags, and time-based revalidation and webhooks in Revalidating cached data.
Redirecting after an action
Many forms live on a page of their own. After saving, the visitor should go back to the list, with the new tea in it. Here the form moved to /teas/new, and the list page has an Add a tea link to it:
app/teas/new/actions.ts"use server"; import { updateTag } from "next/cache";import { redirect } from "next/navigation";import { requireStaff } from "../../_data/staff";import { saveTea } from "../../_data/teas"; export async function addTea(formData: FormData) { await requireStaff(); const name = String(formData.get("name") ?? "").trim(); if (!name) return; await saveTea(name); updateTag("teas"); redirect("/teas");}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Green tea
- Mint tea
- Jasmine tea
Saved in the database: 3 teas
Add a tea- Focus
- nothing: the page itself
- Scrolled
- 0 px down
- Announced to screen readers
Our teas
Requests the page made during this step (3), without the files it loaded
POST/teas/new200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%22new%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C49314%5D%7D%2Cnull%2Cnull%2C49250%5D%7D%2Cnull%2Cnull%2C49250%5D%7D%2Cnull%2Cnull%2C49234%5D
- next-action
- 405a0fb8bb64643dc5e1c234b47946a38cc190f958
GET/teas/new200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /teas
GET/teas/new200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /teas/new/__PAGE__
- next-url
- /teas
In app/teas/new/actions.ts, lines 9 to 11 check, line 12 saves, line 13 expires the tag, and line 14, redirect("/teas") from next/navigation, sends the visitor to the list. The Steps tab is open on the click on Add tea. The click sent POST /teas/new, answered 200 with an RSC payload. There is no request for /teas, yet Chrome is at /teas, and the list shows Jasmine tea. The action's answer already held the list page, rendered after line 13, and the router showed it in place. (The two GET /teas/new requests after it are prefetches: the router loading the list's Add a tea link ahead of time.) Step 5 presses Back: Chrome is on the form again, with no request at all.
Redirects showed the other cases: without JavaScript, the same action answers a 303, and a redirect is sent differently once a page has started streaming. What matters here is the order of lines 13 and 14. Try both:
app/teas/new/actions.tsNext.jsapp/teas/new/actions.ts"use server"; import { updateTag } from "next/cache";import { redirect } from "next/navigation";import { requireStaff } from "../../_data/staff";import { saveTea } from "../../_data/teas"; export async function addTea(formData: FormData) { await requireStaff(); const name = String(formData.get("name") ?? "").trim(); if (!name) return; await saveTea(name); updateTag("teas"); redirect("/teas");}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- Green tea
- Mint tea
- Jasmine tea
Saved in the database: 3 teas
Add a tea- Focus
- nothing: the page itself
- Scrolled
- 0 px down
- Announced to screen readers
Our teas
Requests the page made during this step (3), without the files it loaded
POST/teas/new200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%22new%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C49314%5D%7D%2Cnull%2Cnull%2C49250%5D%7D%2Cnull%2Cnull%2C49250%5D%7D%2Cnull%2Cnull%2C49234%5D
- next-action
- 405a0fb8bb64643dc5e1c234b47946a38cc190f958
GET/teas/new200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /teas
GET/teas/new200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /teas/new/__PAGE__
- next-url
- /teas
Line 13 expires the tag, then line 14 redirects. Chrome lands on /teas with Jasmine tea in the list, from one POST request.
redirect() doesn't return. It throws: it stops the function the way an error does, and Next.js catches it outside your code to send the visitor on. Any line after it never runs. So update first, redirect last.
When an action fails
An action can fail: the name is empty, the database is down. This version of the form's action refuses a tea with no name by throwing an error, and app/teas/new/error.tsx is the screen Next.js shows for that part of the page when something in it throws:
app/teas/new/actions.ts"use server"; import { updateTag } from "next/cache";import { redirect } from "next/navigation";import { requireStaff } from "../../_data/staff";import { saveTea } from "../../_data/teas"; export async function addTea(formData: FormData) { await requireStaff(); const name = String(formData.get("name") ?? "").trim(); if (!name) throw new Error("A tea needs a name"); await saveTea(name); updateTag("teas"); redirect("/teas");}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Nothing was saved. Please try again.
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Errors in Chrome's console during this step (2)
Failed to load resource: the server responded with a status of 500 (Internal Server Error)Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.
Requests the page made during this step (1), without the files it loaded
POST/teas/new500text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%22new%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C530%5D
- next-action
- 409e6cae28f1a30403a9ddb426ac7295f4955083a7
Line 10 reads the name and trims the spaces. Line 11 throws new Error("A tea needs a name") when it's empty, so lines 12 to 14 never run. The recorder clicked Add tea with an empty box. The Steps tab shows what followed:
POST /teas/newanswered500, the status for a server error.- The page shows
Could not add the teaandNothing was saved. Please try again., from lines 6 and 7 oferror.tsx. Line 1 makes it a Client Component, which everyerror.tsxmust be. - Chrome's console logged two errors: the
500, and React's error#441. The messageA tea needs a namenever reached the browser. In a production build, Next.js keeps it in the server's log: the Terminal tab showsError: A tea needs a name, with adigest, a code that links the two. - Step 3 opens
/teas: still 2 teas. The action stopped before it saved, so there was nothing to update.
Error handling explains error screens and the digest. Two habits keep actions out of trouble:
- Return the mistakes you expect. An empty name isn't a crash. Return a message from the action and show it next to the box, so the visitor can fix it and the page stays. The Forms lesson does this with
useActionState. Throw only for what you didn't expect. - Keep
redirect()out oftry. Because it throws, acatcharound it catches the redirect. Redirects recorded that mistake, and its two fixes.
Choosing a call for each change
Every action that changes data ends with one of these questions answered. Each row is one kind of change in the tea shop:
| The change | What to call | Why |
|---|---|---|
| A staff member adds or renames a tea, and the list is cached with a tag. | updateTag("teas") in the action. | She sees her own change in the action's answer. |
| A page shows several cached parts with no tags, and all of them changed. | revalidatePath("/teas"). | Everything cached for that page is made again. |
| The action changed data the page reads for each request, and nothing cached. | refresh(). | The page is rendered again; the cache is left alone. |
| After saving, the visitor should be on another page. | updateTag first, then redirect(). | The new page arrives in the same answer, already up to date. |
| Another program, like the phone app, changes the data through a Route Handler. | revalidateTag("teas", { expire: 0 }), or "max" if a stale visit is fine. | updateTag throws there. |
Sort these changes: which call should each action end with?
- Asha renames Mint tea, and the list from
getTeas(taggedteas) must show the new name at once. - A price changes, and two pages show cached lists tagged
teas. - The specials page caches three lists with no tags, and an edit changes all three.
- A new staff tool must update everything cached for
/teas, and nobody knows which tags it uses. - A cook marks an order done. The order queue is read for each request, and nothing on the page is cached.
- An action changes only the live count on
/teas, whichSavedCountreads for each request. - After Add tea on
/teas/new, the staff member should land on the list, with the new tea in it. - After creating a tea, the visitor should see the tea's own page, which is cached with the tag
teas.
Sort each change: updateTag, revalidatePath, refresh(), or updateTag followed by redirect().
Common mix-ups
- "Saving the data updates the page." No. In the first version the tea was saved, and the list kept two teas after the click and after a new visit. The cached copy stays until something expires it.
- "An action's answer is a new HTML page." No. With JavaScript running, it's a
fetchanswered with an RSC payload. When the action expires something, the payload holds the page rendered again. - "
refresh()clears the cache." No. It renders the page again and leaves the cache alone: the count changed, the cached list didn't. - "
revalidateTagandupdateTagdo the same thing." No. With"max", the next visit still gets the old list.updateTagmakes the next read wait for a new one, and the action's own answer already shows it. - "
updateTagworks anywhere on the server." No. In a Route Handler it throws, and the request answers500. UserevalidateTagthere. - "Lines after
redirect()still run." No.redirect()throws, so anupdateTagafter it never runs. - "The visitor sees the message an action throws." No. In a production build the browser gets React's error
#441, and the message stays in the server's log. Return the messages visitors should read.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
The staff page starts with Green tea and Mint tea. With this app/teas/actions.ts, Asha adds Jasmine tea. What does the list show right after the click?
"use server";
import { requireStaff } from "../_data/staff";
import { saveTea } from "../_data/teas";
export async function addTea(formData: FormData) {
await requireStaff();
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
await saveTea(name);
}
Green tea and Mint tea. The tea is saved in data/teas.json, but nothing expires the list getTeas stored, and nothing makes Next.js render the page again. The recording after the click shows the same two teas and a count of 2.
Same page, this version of the action. After the click on Add tea with Jasmine tea, what number does Saved in the database show?
"use server";
import { refresh } from "next/cache";
import { requireStaff } from "../_data/staff";
import { saveTea } from "../_data/teas";
export async function addTea(formData: FormData) {
await requireStaff();
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
await saveTea(name);
refresh();
}
3: Saved in the database: 3 teas. refresh() renders the page again, so SavedCount reads the file again. The cached list keeps two teas, because refresh() doesn't expire anything.
Change this action so the list shows Jasmine tea in the click's own answer. Which line do you add after saving?
"use server";
import { requireStaff } from "../_data/staff";
import { saveTea } from "../_data/teas";
export async function addTea(formData: FormData) {
await requireStaff();
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
await saveTea(name);
}
"use server";
import { updateTag } from "next/cache";
import { requireStaff } from "../_data/staff";
import { saveTea } from "../_data/teas";
export async function addTea(formData: FormData) {
await requireStaff();
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
await saveTea(name);
updateTag("teas");
}
Add updateTag("teas") after saving, with its import. It expires the stored list, so Next.js renders the page again in the action's answer, and the recording shows Jasmine tea right after the click. revalidatePath("/teas") also works for this page.
With this action, Asha adds Jasmine tea, then opens /teas again. What does the list show on that visit?
"use server";
import { revalidateTag } from "next/cache";
import { requireStaff } from "../_data/staff";
import { saveTea } from "../_data/teas";
export async function addTea(formData: FormData) {
await requireStaff();
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
await saveTea(name);
revalidateTag("teas", "max");
}
The old list: Green tea and Mint tea, with a count of 3. The tag is only stale, so this visit is sent the stored list while a new one is made in the background, as the recording's step 4 shows. A later visit gets Jasmine tea once that refresh has finished.
After Add tea, the staff member lands on /teas. The count says 3, but the list shows only Green tea and Mint tea. What's wrong with app/teas/new/actions.ts?
"use server";
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
import { requireStaff } from "../../_data/staff";
import { saveTea } from "../../_data/teas";
export async function addTea(formData: FormData) {
await requireStaff();
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
await saveTea(name);
redirect("/teas");
updateTag("teas");
}
"use server";
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
import { requireStaff } from "../../_data/staff";
import { saveTea } from "../../_data/teas";
export async function addTea(formData: FormData) {
await requireStaff();
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
await saveTea(name);
updateTag("teas");
redirect("/teas");
}
Swap lines 13 and 14: updateTag("teas") first, then redirect("/teas"). redirect() throws, so in this version updateTag never runs, and the list page arrives with the old list. The recording of the fixed version lands on a list with Jasmine tea.
The phone app posts new teas to this Route Handler, and every request answers 500. What should line 13 call instead, so the next visit to /teas shows the new tea?
import { updateTag } from "next/cache";
import { saveTea } from "../../_data/teas";
export async function POST(request: Request) {
if (request.headers.get("authorization") !== `Bearer ${process.env.PHONE_APP_KEY}`) {
return Response.json({ error: "Not allowed" }, { status: 401 });
}
const { name } = await request.json();
if (typeof name !== "string" || !name.trim()) {
return Response.json({ error: "A tea needs a name" }, { status: 400 });
}
await saveTea(name.trim());
updateTag("teas");
return Response.json({ saved: name });
}
import { revalidateTag } from "next/cache";
import { saveTea } from "../../_data/teas";
export async function POST(request: Request) {
if (request.headers.get("authorization") !== `Bearer ${process.env.PHONE_APP_KEY}`) {
return Response.json({ error: "Not allowed" }, { status: 401 });
}
const { name } = await request.json();
if (typeof name !== "string" || !name.trim()) {
return Response.json({ error: "A tea needs a name" }, { status: 400 });
}
await saveTea(name.trim());
revalidateTag("teas", { expire: 0 });
return Response.json({ saved: name });
}
revalidateTag("teas", { expire: 0 }). Route Handlers may call revalidateTag; { expire: 0 } expires the list at once, so the recorded visit right after shows Jasmine tea. "max" also works if one more visit with the old list is fine.
Check your understanding
For each question, ask two things: what does the call mark out of date, and does the action's answer carry the page again?
Question 1 of 7This action only saves. What does the list on
/teasshow right after the click on Add tea?Read the code, then predictTypeScript"use server"; import { requireStaff } from "../_data/staff"; import { saveTea } from "../_data/teas"; export async function addTea(formData: FormData) { await requireStaff(); const name = String(formData.get("name") ?? "").trim(); if (!name) return; await saveTea(name); }Choose an answer to see the explanation.
Question 2 of 7A Route Handler at
/api/teassaves a tea, then calls a function fromnext/cache. Which one throws there?Read the code, then predictTypeScriptimport { updateTag } from "next/cache"; import { saveTea } from "../../_data/teas"; export async function POST(request: Request) { if (request.headers.get("authorization") !== `Bearer ${process.env.PHONE_APP_KEY}`) { return Response.json({ error: "Not allowed" }, { status: 401 }); } const { name } = await request.json(); if (typeof name !== "string" || !name.trim()) { return Response.json({ error: "A tea needs a name" }, { status: 400 }); } await saveTea(name.trim()); updateTag("teas"); return Response.json({ saved: name }); }Choose an answer to see the explanation.
Question 3 of 7With the page's JavaScript running, what comes back from the Server Action that calls
updateTag?Choose an answer to see the explanation.
Question 4 of 7This action ends with
refresh(). Which part of/teaschanges right after the click?Read the code, then predictTypeScript"use server"; import { refresh } from "next/cache"; import { requireStaff } from "../_data/staff"; import { saveTea } from "../_data/teas"; export async function addTea(formData: FormData) { await requireStaff(); const name = String(formData.get("name") ?? "").trim(); if (!name) return; await saveTea(name); refresh(); }Choose an answer to see the explanation.
Question 5 of 7With
revalidateTag("teas", "max")in the action, what does the first visit to/teasafter the click show?Read the code, then predictTypeScript"use server"; import { revalidateTag } from "next/cache"; import { requireStaff } from "../_data/staff"; import { saveTea } from "../_data/teas"; export async function addTea(formData: FormData) { await requireStaff(); const name = String(formData.get("name") ?? "").trim(); if (!name) return; await saveTea(name); revalidateTag("teas", "max"); }Choose an answer to see the explanation.
Question 6 of 7After this action, Chrome lands on
/teas, the count says 3, and the list still shows two teas. Why?Read the code, then predictTypeScript"use server"; import { updateTag } from "next/cache"; import { redirect } from "next/navigation"; import { requireStaff } from "../../_data/staff"; import { saveTea } from "../../_data/teas"; export async function addTea(formData: FormData) { await requireStaff(); const name = String(formData.get("name") ?? "").trim(); if (!name) return; await saveTea(name); redirect("/teas"); updateTag("teas"); }Choose an answer to see the explanation.
Question 7 of 7In the built app, the action throws
new Error("A tea needs a name"). What does the visitor see?Choose an answer to see the explanation.
Key takeaways
- Saving changes the data, not the copies made from it. A cached result stays until something expires it.
updateTag(tag)expires every result with that tag at once, and the action's answer brings the page back rendered again: read your own writes. Server Actions only.revalidatePath(path)expires everything cached for one page.refresh()expires nothing and renders the page again, for data read on each request.revalidateTag(tag, "max")marks results stale: the next visit still gets the old data while new data is made in the background, and a later visit gets the new data once that has finished. In a Route Handler,{ expire: 0 }expires them at once.- With JavaScript running, the whole round trip is one
POSTanswered with an RSC payload.redirect()adds the next page to that answer, so update before you redirect. - A thrown error shows the nearest
error.tsxand keeps its message on the server. Return the mistakes you expect.
Remember the one-liner.
Save, then say what changed: updateTag for a tag, revalidatePath for a page, refresh() for the rest, and redirect last.
All four calls live in one file of Next.js's source, revalidate.ts, linked here at release v16.4.3. updateTag, at lines 58 to 76, throws when the code running is a Route Handler: the error the recording printed. refresh(), at lines 83 to 103, throws outside a Server Action too, and marks the action as one that refreshes only what isn't cached. The course's last stage, Under the hood, reads them.
Coming next: Route Handlers. A route.ts file answers any HTTP method, for the phone app, partner sites and webhooks, and you'll see when one beats a Server Action.