Next.js 16 Optimistic UI Tutorial: Server Actions 2026
May 18, 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
- Scaffold a Next.js 16.2.6 + TypeScript project with Drizzle + SQLite so the tutorial runs locally end-to-end (no cloud account)
- Write a Server Action with a Zod schema and a typed error contract
- Use
useActionStateto surface validation errors next to the input - Use
useFormStatusinside a child<SubmitButton>to show a pending state - Use
useOptimisticfor instant list updates — and avoid the silent-failure trap where errors don't roll back - Invalidate the server-rendered page with
revalidatePathso the DOM reflects every mutation - Harden Server Actions with
serverActions.allowedOriginsand a sliding-window rate limit
Prerequisites
- Node.js 24 LTS (Active LTS through October 2026)1 or Node 22 Maintenance LTS (through April 2027)
npm10+ (ships with Node 24)- A clean directory you can
rm -rflater - 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,3 --no-* negates default options and --no-linter is the canonical way to skip the linter prompt):
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:
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:
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:
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:
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 interpretfile:./dev.dbas a literal filename.
Create lib/db.ts — the connection and a programmatic migrator so the dev database is always up to date:
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:
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 thatuseActionStatecan render inline errors without throwing.
Create app/actions.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:
addNoteActionreturns validation errors souseActionStatecan show them inline. Server Actions are public POST endpoints, so input validation with Zod is non-negotiable.4deleteNoteActionthrows on failure. This matters in Step 5:useOptimisticonly 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.5
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.6 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:
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.5 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:
"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:
useActionStatereturns[state, dispatch, isPending]— we ignoreisPendingbecauseuseFormStatushandles the button. The reducer signature for a form action is(prevState, formData), the FormData being the second argument.7useFormStatusonly reads the status of its parent form, so it must live in a child component —<SubmitButton>here.8useOptimistictakes the canonicalinitialNotesplus a pure reducer. The reducer flags the item withpending: truerather 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.5- The
try/catchis the safety net. Even thoughdeleteNoteActionthrows and triggers React's automatic rollback, you still need to surface the error to the user.
Render it from app/page.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.9 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:
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.10
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:
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:
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:
npm run dev
# - Local: http://localhost:3000
In the browser:
- Open
http://localhost:3000. Add a note — the submit button shows "Adding…" while the action is in flight (that'suseFormStatus). - Submit an empty form — Zod rejects and the error message appears inline. (We deliberately wired
useOptimisticto the delete path only, because delete is where the rollback story actually matters; adds useuseActionState's synchronous error contract instead.) - Click delete on a row — it fades immediately (optimistic), then disappears once the server confirms via
revalidatePath. - To prove rollback works, temporarily edit
deleteNoteActiontothrow 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:
# 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 component8 |
| 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 state5 |
| 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 explicitly11 |
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 pattern10 |
| Action runs but page does not refresh | Forgot the revalidatePath call inside the action | Add revalidatePath("/") after every mutation so the Server Component re-renders6 |
Next steps
- Wire the same pattern into a longer cache profile with Next.js 16's
use cacheandcacheLifeso the read query stays cheap. - If you came from the Pages Router, the App Router migration tutorial covers the routing changes that unblock Server Actions.
- Refresh your fundamentals on React props and state before introducing more hooks.
Footnotes
Footnotes
-
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. ↩
-
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_KEYenv 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"). ↩ -
create-next-appCLI 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 ↩ -
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 ↩
-
React docs —
useOptimisticDeep Dive "What happens when the Action fails": "If the Action throws an error, the Transition still ends, and React renders with whatevervaluecurrently is." https://react.dev/reference/react/useOptimistic ↩ ↩2 ↩3 ↩4 -
revalidatePathinvalidates the data fetched on a given path on the next request. https://nextjs.org/docs/app/api-reference/functions/revalidatePath ↩ ↩2 -
useActionStatereference — with a<form action={dispatch}>, the reducer signature is(previousState, formData). https://react.dev/reference/react/useActionState ↩ -
useFormStatusreference — "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 ↩ ↩2 -
Next.js docs — Server Actions compare the
Originheader to theHostheader and reject on mismatch as a built-in CSRF mitigation on top ofSameSitecookies. https://nextjs.org/docs/app/guides/data-security ↩ -
serverActionsconfig —bodySizeLimit(default1mb) andallowedOriginsare configured underexperimental.serverActionsinnext.config.ts. https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions ↩ ↩2 -
React 19 launch post —
<form action={fn}>automatically resets uncontrolled inputs after the action completes. Manual control is available viarequestFormResetinreact-dom. https://react.dev/blog/2024/12/05/react-19 ↩