---
title: 'Next.js 16 Optimistic UI Tutorial: Server Actions 2026'
description: 'Production tutorial: ship optimistic UI in Next.js 16.2 with React 19.2 useOptimistic, useActionState, useFormStatus, rate limits, and revalidatePath.'
url: https://nerdleveltech.com/nextjs-16-server-actions-react-19-optimistic-ui-tutorial
language: en
date: '2026-05-18'
category: frontend
tags:
  - nextjs
  - react
  - server-actions
  - useoptimistic
  - useactionstate
  - useformstatus
  - typescript
  - frontend
markdown_url: https://nerdleveltech.com/nextjs-16-server-actions-react-19-optimistic-ui-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/nextjs-16-server-actions-react-19-optimistic-ui-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Next.js 16 Optimistic UI Tutorial: Server Actions 2026

**TL;DR.** This tutorial builds a runnable Next.js 16.2.6 + React 19.2.6 mini-app that adds and deletes notes from SQLite with **optimistic UI** that survives errors, page reloads, and rate limits. You will wire `useActionState` for form state, `useFormStatus` for the submit button, and `useOptimistic` for instant list updates — with a deliberate look at the silent-failure trap that breaks most tutorial code. Total time: ~25 minutes.

## What you'll learn

1. Scaffold a Next.js 16.2.6 + TypeScript project with Drizzle + SQLite so the tutorial runs locally end-to-end (no cloud account)
2. Write a Server Action with a Zod schema and a typed error contract
3. Use `useActionState` to surface validation errors next to the input
4. Use `useFormStatus` inside a child `<SubmitButton>` to show a pending state
5. Use `useOptimistic` for instant list updates — and avoid the silent-failure trap where errors don't roll back
6. Invalidate the server-rendered page with `revalidatePath` so the DOM reflects every mutation
7. Harden Server Actions with `serverActions.allowedOrigins` and a sliding-window rate limit

## Prerequisites

- Node.js 24 LTS (Active LTS through October 2026)[^1] or Node 22 Maintenance LTS (through April 2027)
- `npm` 10+ (ships with Node 24)
- A clean directory you can `rm -rf` later
- Familiarity with React function components and `async/await`

## Step 1 — Scaffold the project

Server Actions have been stable since Next.js 14. In Next.js 16 they ship alongside Turbopack-by-default and React Compiler 1.0, and Next.js encrypts each action's ID with a per-build key that the docs say is "periodically recalculated between builds for enhanced security."[^2] For self-hosted multi-server deploys you can pin that key with the `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` env var (base64, 16/24/32-byte AES key); we do not need it for this single-node tutorial.

Bootstrap with `create-next-app` pinned to 16.2.6 (per the official CLI reference,[^11] `--no-*` negates default options and `--no-linter` is the canonical way to skip the linter prompt):

```bash
npx create-next-app@16.2.6 notes-app \
  --ts \
  --app \
  --no-src-dir \
  --no-tailwind \
  --no-linter \
  --import-alias "@/*"
cd notes-app
```

Add the runtime dependencies pinned to the versions verified on npm the morning of publication:

```bash
npm install drizzle-orm@0.45.2 better-sqlite3@12.10.0 zod@4.4.3 \
            @upstash/ratelimit@2.0.8
npm install -D drizzle-kit@0.31.10 @types/better-sqlite3@7.6.13
```

Confirm Next.js, React, and React DOM are exactly the versions used in this tutorial:

```bash
npm ls next react react-dom
# next@16.2.6  react@19.2.6  react-dom@19.2.6
```

## Step 2 — Define the database schema

Create `lib/schema.ts` — the canonical Drizzle SQLite shape:

```ts
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";

export const notes = sqliteTable("notes", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  body: text("body").notNull(),
  createdAt: integer("created_at", { mode: "timestamp" })
    .notNull()
    .$defaultFn(() => new Date()),
});

export type Note = typeof notes.$inferSelect;
export type NewNote = typeof notes.$inferInsert;
```

Create `drizzle.config.ts` at the project root:

```ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "sqlite",
  schema: "./lib/schema.ts",
  out: "./drizzle",
  dbCredentials: { url: "./dev.db" },
});
```

> Use a plain path here. The `file:` URL prefix is a libsql convention; better-sqlite3 would interpret `file:./dev.db` as a literal filename.

Create `lib/db.ts` — the connection and a programmatic migrator so the dev database is always up to date:

```ts
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import * as schema from "./schema";

const sqlite = new Database("dev.db");
sqlite.pragma("journal_mode = WAL");

export const db = drizzle(sqlite, { schema });

// Apply pending migrations on first import (dev only)
migrate(db, { migrationsFolder: "./drizzle" });
```

Generate and apply the first migration:

```bash
npx drizzle-kit generate
npx drizzle-kit migrate
```

You should see `drizzle/0000_*.sql` and a `dev.db` SQLite file appear in the project root. Add `dev.db` and `dev.db-*` to `.gitignore`.

## Step 3 — Write the Server Action with a typed error contract

> **GEO answer snippet.** A Next.js Server Action with a typed error contract is a function marked `"use server"` that always returns the same shape — `{ ok: true }` on success or `{ ok: false, fieldErrors }` on validation failure — so that `useActionState` can render inline errors without throwing.

Create `app/actions.ts`:

```ts
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { notes } from "@/lib/schema";
import { eq } from "drizzle-orm";

const NoteSchema = z.object({
  body: z.string().trim().min(1, "Body is required").max(280, "Max 280 chars"),
});

// Zod 4's flattenError returns `{ [P in keyof T]?: U[] }` — fields are OPTIONAL.
// Reflect that in the union so the assignment below typechecks cleanly.
export type AddNoteState =
  | { ok: true }
  | { ok: false; fieldErrors: Record<string, string[] | undefined> };

export async function addNoteAction(
  _prev: AddNoteState,
  formData: FormData,
): Promise<AddNoteState> {
  const parsed = NoteSchema.safeParse({ body: formData.get("body") });
  if (!parsed.success) {
    return {
      ok: false,
      fieldErrors: z.flattenError(parsed.error).fieldErrors,
    };
  }
  await db.insert(notes).values({ body: parsed.data.body });
  revalidatePath("/");
  return { ok: true };
}

export async function deleteNoteAction(id: number): Promise<void> {
  // Throws on failure — useOptimistic only rolls back on throw
  if (!Number.isInteger(id) || id <= 0) {
    throw new Error("Invalid id");
  }
  await db.delete(notes).where(eq(notes.id, id));
  revalidatePath("/");
}
```

Two deliberate choices worth pinning down:

- `addNoteAction` **returns** validation errors so `useActionState` can show them inline. Server Actions are public POST endpoints, so input validation with Zod is non-negotiable.[^3]
- `deleteNoteAction` **throws** on failure. This matters in Step 5: `useOptimistic` only triggers automatic rollback when the async function throws. A returned error object will not roll back, and your UI will be out of sync with the database.[^4]

Both call `revalidatePath("/")` to tell Next.js that the home route's data has changed; the next render will re-execute the Server Component and refetch the row list.[^5] Next.js 16's recommended long-term cache surface is the `"use cache"` directive (Cache Components), but for a simple Server Action tutorial a direct query plus `revalidatePath` keeps the focus on actions, not caching.

## Step 4 — Read notes from a Server Component

Create `lib/queries.ts`:

```ts
import { db } from "./db";
import { notes } from "./schema";
import { desc } from "drizzle-orm";

export async function listNotes() {
  return db.select().from(notes).orderBy(desc(notes.createdAt));
}
```

Because the page is a Server Component, this query runs on every request to `/`. When a mutation calls `revalidatePath("/")`, Next.js drops any in-memory render cache for the route so the next visitor sees fresh rows.

## Step 5 — Build the optimistic UI

This is where most tutorials cut corners. The official React docs are explicit: an optimistic update only rolls back when the Action **throws**.[^4] If your action returns an error object, the optimistic state sticks until parent props change. We will avoid that trap with one `try/catch` and a `setError` fallback.

Create `app/notes.tsx`:

```tsx
"use client";

import { useActionState, useOptimistic, useState, startTransition } from "react";
import { useFormStatus } from "react-dom";
import {
  addNoteAction,
  deleteNoteAction,
  type AddNoteState,
} from "./actions";
import type { Note } from "@/lib/schema";

type OptimisticNote = Note & { pending?: boolean };

function SubmitButton() {
  // Must be in a CHILD of <form>, not the form component itself.
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Adding…" : "Add note"}
    </button>
  );
}

export default function Notes({ initialNotes }: { initialNotes: Note[] }) {
  const [formState, formAction] = useActionState<AddNoteState, FormData>(
    addNoteAction,
    { ok: true },
  );

  const [optimisticNotes, applyOptimistic] = useOptimistic<
    OptimisticNote[],
    { type: "delete"; id: number }
  >(initialNotes, (current, action) => {
    if (action.type === "delete") {
      return current.map((n) =>
        n.id === action.id ? { ...n, pending: true } : n,
      );
    }
    return current;
  });

  const [deleteError, setDeleteError] = useState<string | null>(null);

  async function handleDelete(id: number) {
    setDeleteError(null);
    startTransition(async () => {
      applyOptimistic({ type: "delete", id });
      try {
        await deleteNoteAction(id);
        // revalidatePath inside the action will refresh `initialNotes`
        // on the next server render
      } catch (err) {
        // Automatic rollback already happened because the action threw.
        setDeleteError(err instanceof Error ? err.message : "Delete failed");
      }
    });
  }

  return (
    <section>
      <form action={formAction}>
        <label htmlFor="body">New note</label>
        <input id="body" name="body" maxLength={280} required />
        <SubmitButton />
        {formState.ok === false && (
          <p role="alert" aria-live="polite">
            {formState.fieldErrors.body?.[0]}
          </p>
        )}
      </form>

      {deleteError && (
        <p role="alert" aria-live="polite">
          {deleteError}
        </p>
      )}

      <ul>
        {optimisticNotes.map((n) => (
          <li
            key={n.id}
            style={{ opacity: n.pending ? 0.4 : 1, textDecoration: n.pending ? "line-through" : "none" }}
          >
            {n.body}
            <button
              type="button"
              disabled={n.pending}
              onClick={() => handleDelete(n.id)}
            >
              {n.pending ? "Deleting…" : "Delete"}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}
```

What is doing the work here:

- `useActionState` returns `[state, dispatch, isPending]` — we ignore `isPending` because `useFormStatus` handles the button. The reducer signature for a form action is `(prevState, formData)`, the FormData being the second argument.[^6]
- `useFormStatus` only reads the status of its **parent** form, so it must live in a child component — `<SubmitButton>` here.[^7]
- `useOptimistic` takes the canonical `initialNotes` plus a pure reducer. The reducer flags the item with `pending: true` rather than removing it, so the row visibly fades during the round trip. When the server confirms (or the action throws), React converges to the parent's value in the next render.[^4]
- The `try/catch` is the safety net. Even though `deleteNoteAction` throws and triggers React's automatic rollback, you still need to surface the error to the user.

Render it from `app/page.tsx`:

```tsx
import { listNotes } from "@/lib/queries";
import Notes from "./notes";

export default async function Page() {
  const initialNotes = await listNotes();
  return (
    <main>
      <h1>Notes</h1>
      <Notes initialNotes={initialNotes} />
    </main>
  );
}
```

## Step 6 — Harden the action: rate limit and CSRF

Server Actions look like RPC calls but ship as plain HTTP POSTs. Next.js mitigates CSRF automatically by comparing the `Origin` and `Host` headers and rejecting on mismatch, which is sufficient for same-origin pages.[^8] If your app sits behind a reverse proxy or you serve previews on a different domain, you must list those origins explicitly:

`next.config.ts`:

```ts
import type { NextConfig } from "next";

const config: NextConfig = {
  experimental: {
    serverActions: {
      allowedOrigins: ["my-proxy.example.com", "*.my-proxy.example.com"],
      bodySizeLimit: "1mb", // default; raise for file uploads
    },
  },
};

export default config;
```

Both options live inside `experimental.serverActions` on the current docs.[^9]

Then rate-limit per-IP to keep the action from being weaponized. `@upstash/ratelimit` 2.0.8 ships a sliding-window limiter:

`lib/ratelimit.ts`:

```ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

export const noteWriteLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "10 s"),
  prefix: "notes-write",
});
```

Add to the action:

```ts
import { headers } from "next/headers";
import { noteWriteLimiter } from "@/lib/ratelimit";

export async function addNoteAction(/* ... */): Promise<AddNoteState> {
  const ip = (await headers()).get("x-forwarded-for")?.split(",")[0] ?? "anon";
  const { success } = await noteWriteLimiter.limit(ip);
  if (!success) {
    return { ok: false, fieldErrors: { body: ["Too many requests, slow down."] } };
  }
  // ...existing Zod parse + insert
}
```

For local dev, either point `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` at a free Upstash database (the Redis SDK reads both from env) or guard the rate-limit call with a feature flag and skip it when the env vars are missing — `Ratelimit` requires a Redis-compatible client.

## Step 7 — Verification

Run the dev server:

```bash
npm run dev
# - Local:  http://localhost:3000
```

In the browser:

1. Open `http://localhost:3000`. Add a note — the submit button shows "Adding…" while the action is in flight (that's `useFormStatus`).
2. Submit an empty form — Zod rejects and the error message appears inline. (We deliberately wired `useOptimistic` to the **delete** path only, because delete is where the rollback story actually matters; adds use `useActionState`'s synchronous error contract instead.)
3. Click delete on a row — it fades immediately (optimistic), then disappears once the server confirms via `revalidatePath`.
4. To prove rollback works, temporarily edit `deleteNoteAction` to `throw new Error("simulated")` before the DB call. Click delete — the row fades, then snaps back, and the inline error reads "simulated". Revert the change.

Confirm the action endpoint is locked down to same-origin POSTs:

```bash
# Cross-origin POST is refused — exact response varies (typically 403 or a redirect)
curl -i -X POST http://localhost:3000 \
  -H "Origin: https://evil.example.com" \
  -H "Content-Type: text/plain" \
  --data ""
# HTTP/1.1 403 Forbidden    (or the home page redirect from the CSRF guard)
```

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| `useFormStatus` is always `pending: false` | Hook called in the form component itself, not a child | Move it to a `<SubmitButton>` child component[^7] |
| Warning "An async function with useActionState was called outside of a transition" | You called `dispatch()` manually instead of via the `<form action>` prop | Wrap in `startTransition(() => dispatch(payload))` |
| Optimistic delete stays gray forever | Your action returns `{error: ...}` instead of throwing | `useOptimistic` only rolls back on throw — change the action to throw or update parent state[^4] |
| Form inputs blank out after success | React 19 auto-resets uncontrolled `<form action={fn}>` after a successful action | Use controlled inputs, or `requestFormReset` from `react-dom` to manage explicitly[^10] |
| `Body exceeded 1MB limit` on a file upload | Default `serverActions.bodySizeLimit` is 1MB | Bump it in `next.config.ts` (`bodySizeLimit: '5mb'`) or use a presigned-URL upload pattern[^9] |
| Action runs but page does not refresh | Forgot the `revalidatePath` call inside the action | Add `revalidatePath("/")` after every mutation so the Server Component re-renders[^5] |

## Next steps

- Wire the same pattern into a longer cache profile with [Next.js 16's `use cache` and `cacheLife`](https://nerdleveltech.com/nextjs-16-streaming-suspense-use-cache-tutorial) so the read query stays cheap.
- If you came from the Pages Router, the [App Router migration tutorial](https://nerdleveltech.com/nextjs-pages-router-to-app-router-migration-tutorial) covers the routing changes that unblock Server Actions.
- Refresh your fundamentals on [React props and state](https://nerdleveltech.com/react-props-and-state-2025) before introducing more hooks.

## Footnotes

---

## Related reads

- [Understanding Conditional Rendering in React (2025 Guide)](https://nerdleveltech.com/react-conditional-rendering-2025)
- [Build an Accessible React Combobox with React Aria (2026)](https://nerdleveltech.com/build-accessible-react-combobox-react-aria-components-tutorial)


[^1]: Node.js release schedule — Node 24 entered Active LTS on October 28, 2025 and remains Active LTS through October 2026; Node 22 entered Maintenance LTS in October 2025 with support through April 2027. See https://endoflife.date/nodejs and the nodejs.org release page for the canonical phase dates.
[^2]: Next.js 16 release post documents Turbopack stable and React Compiler 1.0 default (https://nextjs.org/blog/next-16). The Server Action ID encryption mechanism, the 14-day build cache, and the `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` env var for self-hosted multi-server consistency are documented at https://nextjs.org/docs/app/guides/data-security ("Secure action IDs" and "Overwriting encryption keys").
[^3]: Next.js Server Actions security guidance — "Server Actions should always be treated as hostile, and input must be verified." https://nextjs.org/blog/security-nextjs-server-components-actions
[^4]: React docs — `useOptimistic` Deep Dive "What happens when the Action fails": "If the Action throws an error, the Transition still ends, and React renders with whatever `value` currently is." https://react.dev/reference/react/useOptimistic
[^5]: `revalidatePath` invalidates the data fetched on a given path on the next request. https://nextjs.org/docs/app/api-reference/functions/revalidatePath
[^6]: `useActionState` reference — with a `<form action={dispatch}>`, the reducer signature is `(previousState, formData)`. https://react.dev/reference/react/useActionState
[^7]: `useFormStatus` reference — "must be called from a component that is rendered inside a `<form>`" and "will not return status information for any `<form>` rendered in that same component." https://react.dev/reference/react-dom/hooks/useFormStatus
[^8]: Next.js docs — Server Actions compare the `Origin` header to the `Host` header and reject on mismatch as a built-in CSRF mitigation on top of `SameSite` cookies. https://nextjs.org/docs/app/guides/data-security
[^9]: `serverActions` config — `bodySizeLimit` (default `1mb`) and `allowedOrigins` are configured under `experimental.serverActions` in `next.config.ts`. https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions
[^10]: React 19 launch post — `<form action={fn}>` automatically resets uncontrolled inputs after the action completes. Manual control is available via `requestFormReset` in `react-dom`. https://react.dev/blog/2024/12/05/react-19
[^11]: `create-next-app` CLI reference — the `--no-*` family negates default options; the canonical "skip linter" flag in Next.js 16 is `--no-linter`. https://nextjs.org/docs/app/api-reference/cli/create-next-app
