Next.js 16 Optimistic UI Tutorial: Server Actions 2026

May 18, 2026

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,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 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:

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 that useActionState can 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:

  • addNoteAction returns validation errors so useActionState can show them inline. Server Actions are public POST endpoints, so input validation with Zod is non-negotiable.4
  • 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.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:

  • 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.7
  • useFormStatus only reads the status of its parent form, so it must live in a child component — <SubmitButton> here.8
  • 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.5
  • 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:

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:

  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:

# 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

SymptomLikely causeFix
useFormStatus is always pending: falseHook called in the form component itself, not a childMove 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> propWrap in startTransition(() => dispatch(payload))
Optimistic delete stays gray foreverYour action returns {error: ...} instead of throwinguseOptimistic only rolls back on throw — change the action to throw or update parent state5
Form inputs blank out after successReact 19 auto-resets uncontrolled <form action={fn}> after a successful actionUse controlled inputs, or requestFormReset from react-dom to manage explicitly11
Body exceeded 1MB limit on a file uploadDefault serverActions.bodySizeLimit is 1MBBump it in next.config.ts (bodySizeLimit: '5mb') or use a presigned-URL upload pattern10
Action runs but page does not refreshForgot the revalidatePath call inside the actionAdd revalidatePath("/") after every mutation so the Server Component re-renders6

Next steps

Footnotes

Footnotes

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

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

  5. 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 2 3 4

  6. revalidatePath invalidates the data fetched on a given path on the next request. https://nextjs.org/docs/app/api-reference/functions/revalidatePath 2

  7. useActionState reference — with a <form action={dispatch}>, the reducer signature is (previousState, formData). https://react.dev/reference/react/useActionState

  8. 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 2

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

  10. 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 2

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


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.