cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Show a change at onceCall updateTag after saving, so the action's own answer brings the page back with the new data.
  • 02
    Pick the right callTell updateTag, revalidatePath, refresh() and revalidateTag apart by what each marks out of date and what the page shows next.
  • 03
    Finish 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.

The staff page, with an action that only savesNext.js
app/_data/teas/data/
app/teas/page.tsx
import { 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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teasTea shop
Our teas
  • Green tea
  • Mint tea

Saved in the database: 2 teas

Add tea
Focus
the button “Add tea”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. 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. During next build, Next.js ran getTeas once and kept its list.
  • Line 11, cacheTag("teas"), gives the stored result a name, a tag. Code can later say "everything tagged teas is 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.

Three windows. The database, data/teas.json, holds Green tea, Mint tea and Jasmine tea. The cached copy of the list, tagged teas and made during the build, holds Green tea and Mint tea. The page /teas after a reload shows the cached copy's two teas, and Saved in the database: 3 teas, which an arrow says is read from the database for each request.the databasedataGreen teaMint teaJasmine teacached copy, tag teascacheGreen teaMint teamade during the build/teas after a reload/teasGreen teaMint teaSaved in the database: 3 teasthe count: read for each request
/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.
Real-life analogyA menu board and a recipe book

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 getTeas stored, tagged teas
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
The main idea

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:

The staff page, with updateTag after savingNext.js
app/_data/teas/data/
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");}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teasTea shop
Our teas
  • Green tea
  • Mint tea
  • Jasmine tea

Saved in the database: 3 teas

Add tea
Focus
the button “Add tea”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. 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.

A browser window showing Green tea, Mint tea and Jasmine tea after the answer. An arrow labeled POST /teas with next-action goes to the server window, which lists three steps: saveTea, 3 teas in the file; updateTag teas; render /teas again. An arrow labeled 200 with an RSC payload goes back to the browser.browserGreen teaMint teaJasmine teaafter the answerPOST /teasnext-action200RSC payloadserver1. saveTea: 3 teas in the file2. updateTag("teas")3. render /teas again
One click on Add tea with 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?

Playground: one click on Add tea, step by step
Who does whatWith updateTag
What the visitor seesafter step 1
tea-shop.example/teas

Our 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.

Step 1 of 7
Browser: Asha clicks Add tea

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.

Each step is one thing that happens, in order. The first and last screens are what Chrome showed in the recordings of these two versions. Reset goes back to the first step of the version with updateTag.

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:

A Route Handler that calls updateTagNext.js
app/_data/api/teas/teas/data/
app/api/teas/route.ts
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 });}
Recorded from Next.js 16.4.3
$ 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:

The same Route Handler with revalidateTagNext.js
app/_data/api/teas/teas/data/
app/api/teas/route.ts
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 });}
Recorded from Next.js 16.4.3

GET /teas200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-stale-time 300
x-nextjs-postponed 1
X-Powered-By Next.js
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
Content-Type text/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:

The staff page, with revalidatePath after savingNext.js
app/_data/teas/data/
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");}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teasTea shop
Our teas
  • Green tea
  • Mint tea
  • Jasmine tea

Saved in the database: 3 teas

Add tea
Focus
the button “Add tea”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. 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 called getTeas, updateTag("teas") would expire its list too. revalidatePath("/teas") wouldn't: /menu would keep the old list.
  • A path reaches everything on one page. Every cached result /teas used, 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 like revalidatePath("/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:

The staff page, with refresh() after savingNext.js
app/_data/teas/data/
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();}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teasTea shop
Our teas
  • Green tea
  • Mint tea

Saved in the database: 3 teas

Add tea
Focus
the button “Add tea”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. 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.

Playground: what does the page show after Add tea?
The staff page, with this version of app/teas/actions.tsNext.js
app/_data/teas/data/
app/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);}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teasTea shop
Our teas
  • Green tea
  • Mint tea

Saved in the database: 2 teas

Add tea
Focus
the button “Add tea”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. 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
Try it yourself
Only save

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.

Every version was built with Next.js 16.4.3 and clicked through in real Chrome: open /teas, type Jasmine tea, click Add tea, then open /teas again (twice, except with revalidateTag, where only the first visit after the click is certain). Only app/teas/actions.ts changes. The Steps tab opens on the click. Reset goes back to the first version.

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.

A grid. Columns: after the click, next visit, a later visit. updateTag: Jasmine tea in all three. revalidateTag with max: the old list after the click and on the next visit, and Jasmine tea on a later visit once the refresh has finished. refresh(): the old list in all three. Only save: the old list in all three.after the clicknext visita later visitupdateTagJasmine teaJasmine teaJasmine tearevalidateTag, maxold listold listonce refreshedrefresh()old listold listold listonly saveold listold listold list
When Jasmine tea shows in the list. The later visit with 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.

What each call does after a change
CallWhat it marks out of dateWhat the action's answer showsWhere 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.
NothingNothing.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:

A form page that saves, updates the tag, and goes back to the listNext.js
app/_data/teas/new/data/
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");}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teasTea shop
Our teas
  • 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

  1. 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
  2. GET/teas/new200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /teas
  3. 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:

Playground: does the order of two lines matter?
The form page, with this version of app/teas/new/actions.tsNext.js
app/_data/teas/new/data/
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");}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teasTea shop
Our teas
  • 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

  1. 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
  2. GET/teas/new200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /teas
  3. GET/teas/new200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /teas/new/__PAGE__
    next-url
    /teas
Try it yourself
updateTag, then redirect

Line 13 expires the tag, then line 14 redirects. Chrome lands on /teas with Jasmine tea in the list, from one POST request.

Both versions were built with Next.js 16.4.3 and clicked through in real Chrome. Only the order of lines 13 and 14 changes. The Steps tab opens on the click on Add tea. Reset goes back to the first version.

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:

An action that throws, and the error screenNext.js
app/_data/teas/new/data/
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");}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/newTea shop
Could not add the tea

Nothing was saved. Please try again.

Focus
nothing: the page itself
Scrolled
0 px down

Errors in Chrome's console during this step (2)

  1. Failed to load resource: the server responded with a status of 500 (Internal Server Error)
  2. 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

  1. 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/new answered 500, the status for a server error.
  • The page shows Could not add the tea and Nothing was saved. Please try again., from lines 6 and 7 of error.tsx. Line 1 makes it a Client Component, which every error.tsx must be.
  • Chrome's console logged two errors: the 500, and React's error #441. The message A tea needs a name never reached the browser. In a production build, Next.js keeps it in the server's log: the Terminal tab shows Error: A tea needs a name, with a digest, 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 of try. Because it throws, a catch around 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:

Which call fits which change
The changeWhat to callWhy
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?

Which call fits this change?
  • Asha renames Mint tea, and the list from getTeas (tagged teas) 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, which SavedCount reads 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.
Try it yourself
0 of 8 correct

Sort each change: updateTag, revalidatePath, refresh(), or updateTag followed by redirect().

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 fetch answered 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.
  • "revalidateTag and updateTag do the same thing." No. With "max", the next visit still gets the old list. updateTag makes the next read wait for a new one, and the action's own answer already shows it.
  • "updateTag works anywhere on the server." No. In a Route Handler it throws, and the request answers 500. Use revalidateTag there.
  • "Lines after redirect() still run." No. redirect() throws, so an updateTag after 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.

Exercise 1 · Warm-upPredict the list after the click

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?

Starter codeTypeScript
"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);
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upRead the count after refresh()

    Same page, this version of the action. After the click on Add tea with Jasmine tea, what number does Saved in the database show?

    Starter codeTypeScript
    "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();
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeMake the new tea show at once

      Change this action so the list shows Jasmine tea in the click's own answer. Which line do you add after saving?

      Starter codeTypeScript
      "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);
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeThe visit after revalidateTag

        With this action, Asha adds Jasmine tea, then opens /teas again. What does the list show on that visit?

        Starter codeTypeScript
        "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");
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFind the bug in the redirect

          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?

          Starter codeTypeScript
          "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");
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeFix the phone app's Route Handler

            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?

            Starter codeTypeScript
            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 });
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              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?

              The Updating the UI quiz · 7 questionsScore: first tries count
              1. Question 1 of 7This action only saves. What does the list on /teas show 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.

              2. Question 2 of 7A Route Handler at /api/teas saves a tea, then calls a function from next/cache. Which one throws there?

                Read the code, then predictTypeScript
                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 });
                }
                

                Choose an answer to see the explanation.

              3. 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.

              4. Question 4 of 7This action ends with refresh(). Which part of /teas changes 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.

              5. Question 5 of 7With revalidateTag("teas", "max") in the action, what does the first visit to /teas after 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.

              6. 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.

              7. 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 POST answered with an RSC payload. redirect() adds the next page to that answer, so update before you redirect.
              • A thrown error shows the nearest error.tsx and 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.

              Read it in Next.js's own code

              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.

              CompleteFrontend Clear concepts. Working examples.