Finishing the Server Action write
The post-write seams of a Server Action, revalidating the cache, writing atomically with db.transaction, redirecting, and adding an idempotency key.
Your createInvoice action already parses the incoming FormData, checks the caller, and calls your db/queries helpers; it’s one return ok({ id }) from finished.
But three things a newcomer gets wrong by default are still missing:
- The user creates an invoice, returns to the list, and the new row isn’t there, because nothing told the cache its data changed.
- A constraint fails on the second insert, so the invoice header saved but its line items didn’t. The database now holds a half-built invoice the rest of the app can’t handle.
- The user double-clicked submit, and there are now two identical invoices.
None of these surface when you click the happy path once; they surface in production, under real users, on a slow network. This lesson fills the last two seams of the parse → authorize → mutate → revalidate → return spine, mutate and revalidate, plus the ordering rules that separate a working mutation from a subtly broken one. This is the action the invoicing project picks up later.
Refreshing the cache after a write
Section titled “Refreshing the cache after a write”Next.js 16 renders dynamically by default: every request re-runs your Server Components against fresh data. Re-fetching the same invoice list on every load is wasteful, so you opt that read into the cache with 'use cache', and the framework now serves a stored copy instead of querying the database.
Then createInvoice writes a new row. The user navigates back to /invoices and the new invoice isn’t there: the page still serves the copy it cached before the write. The row is in the database, but the cache is what the user sees, and it will only refresh itself long after they’ve given up looking.
The fix is to tell the cache its copy is out of date. That’s what revalidatePath does:
export async function createInvoice(formData: FormData) { const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors); }
const invoice = await insertInvoice(parsed.data);
// → next lesson return ok({ id: invoice.id });}The write lands, but the cache never hears about it. The user navigates back to a /invoices list still missing the new invoice.
export async function createInvoice(formData: FormData) { const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors); }
const invoice = await insertInvoice(parsed.data);
revalidatePath('/invoices'); return ok({ id: invoice.id });}One line, after the write, before the return. revalidatePath('/invoices') marks that path’s cached entry stale, so the next request rebuilds the page and the new row appears.
Read revalidatePath('/invoices') literally: the argument is the URL path of the page whose cache you want to invalidate, the same string the user sees in their address bar.
When the path contains a dynamic segment, say /[org]/invoices, the framework can’t tell from the bracketed string whether you mean the page or its layout, so a second argument disambiguates: revalidatePath('/[org]/invoices', 'page'). That 'page' | 'layout' argument is required whenever the path has a dynamic segment. The rest of this lesson uses plain static paths.
Why revalidate must run after the write
Section titled “Why revalidate must run after the write”revalidatePath marks a cached entry stale so the next request rebuilds it; it knows nothing about whether your database write has happened. Call it before insertInvoice and you invalidate the cache against unchanged data, so the next request refetches the same old list.
revalidatePath('/invoices'); // invalidates against data that hasn't changed yetconst invoice = await insertInvoice(parsed.data);revalidatePath('/invoices'); // ✓ after the write — now there's something fresh to point atThis is why revalidate is seam four in parse → authorize → mutate → revalidate → return: after the mutate, with nothing to point the cache at until the write commits, and before the return. The order is a correctness constraint, not a preference. Get it wrong and you ship a bug that passes manual testing and fails the moment a real user looks at the page twice.
revalidatePath is the blunt, always-correct move for “the data on this path changed,” the right reflex after every mutation that touches a cached page. The tag-based tools from the caching chapter earn their extra weight only once your app has a deliberate tagging scheme.
Back to the caching chapter: when updateTag, revalidateTag, and router.refresh earn their weight over revalidatePath.
Sending the user to the new record
Section titled “Sending the user to the new record”After creating an invoice, the natural move is to send the user to its detail page. You’ll reach for this constantly, and it’s the one place that seems to clash with what you already know.
The tool is redirect:
redirect(`/invoices/${invoice.id}`);Call it at the end of the action and the framework navigates the browser to the new URL. redirect() is a framework convention, not an error and not a return value: the runtime implements it by throwing a special control-flow signal, then catches that signal internally and turns it into an HTTP redirect. Architectural Principle #5 again, lean on the platform’s seams instead of inventing your own.
That it throws should give you pause, because “Result, or throw” told you to return a Result and throw only the unexpected. The action has exactly two endings: it succeeds and navigates away with redirect(), or it returns a Result the caller renders in place, never both. Like notFound(), redirect() rides the throw mechanism on the success path, after the write and the revalidate, instead of returning a Result.
The try/catch trap
Section titled “The try/catch trap”Picture a try/catch wrapping the whole action body, with redirect() inside it:
try { const invoice = await insertInvoice(parsed.data); revalidatePath('/invoices'); redirect(`/invoices/${invoice.id}`);} catch (e) { // catches the redirect signal too return err('internal', 'Something went wrong.');}The broad catch swallows the navigation. It treats redirect’s control-flow signal like any other error, so the user sees a generic failure instead of the detail page.
let invoice;try { invoice = await insertInvoice(parsed.data);} catch (e) { if (isUniqueViolation(e)) return err('conflict', 'That slug is already in use.'); throw e;}
revalidatePath('/invoices');redirect(`/invoices/${invoice.id}`);The narrow catch guards only the mutation. It maps the known violation and re-throws the rest, leaving redirect outside any catch so its signal propagates and the navigation fires.
Two defenses, in order of preference:
- The course default: call
redirect()at the very end of the action, outside anytry/catch, as in the “Redirect last” tab. Keep thetry/catchnarrow around only the database call you’re mapping errors for, and the redirect never lands inside it. - If a redirect must live inside a
try, use the predicate Next.js exposes to detect framework signals and re-throw them, so your catch lets them through while still handling real errors.
Atomic multi-step writes with db.transaction
Section titled “Atomic multi-step writes with db.transaction”So far the mutate seam has been a single db.insert. Creating an invoice takes two: it writes the invoice header and its line items. With two writes, a new failure mode appears.
Say the second insert fails, because a constraint rejects the line items or the connection drops. The first insert already committed, so the database now holds an invoice with no lines: a half-built record that every part of the app expecting at least one line will trip over. Sequential inserts stand or fall alone, with no all-or-nothing guarantee.
A transaction provides that guarantee: wrap the related writes in one unit that either fully succeeds or fully fails.
const invoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoicesTable) .values(data) .returning(); await insertInvoiceLines(tx, linesFor(invoice.id)); return invoice;});db.transaction runs the callback as one atomic unit. What it returns becomes invoice out here, but only once the transaction has committed.
const invoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoicesTable) .values(data) .returning(); await insertInvoiceLines(tx, linesFor(invoice.id)); return invoice;});Every write inside goes through tx, the transaction handle, not the outer db. tx is what enrolls a write in this transaction; a write that reached for db here would silently run outside it.
const invoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoicesTable) .values(data) .returning(); await insertInvoiceLines(tx, linesFor(invoice.id)); return invoice;});The first insert writes the header; .returning() hands back the new row, so its generated id is available to the next step.
const invoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoicesTable) .values(data) .returning(); await insertInvoiceLines(tx, linesFor(invoice.id)); return invoice;});The line-items helper takes tx as its first argument, keeping the second write inside the same transaction. This is why those db/queries helpers accept tx up front.
const invoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoicesTable) .values(data) .returning(); await insertInvoiceLines(tx, linesFor(invoice.id)); return invoice;});Returning commits the transaction, so both inserts land together. Any throw before this line rolls both back, undoing the header insert too.
The shape is db.transaction(async (tx) => { ... }). Return normally and the transaction commits atomically ; throw and it rolls back .
When do you reach for one? The trigger from the read side holds: any write that touches more than one table, or any flow where a later step depends on an earlier write having committed. An invoice with its lines is the textbook case.
This transaction is the mutate seam, seam three: parse and authorize run before it, revalidate and return after it. Be precise about its job: it gives you atomicity, not error handling. Mapping a duplicate-key error into a Result is still the try/catch from “Result, or throw”, and that try/catch wraps the whole db.transaction(...) call from the outside.
step 1
insert header
wrote — ok
step 2
insert lines
wrote — ok
COMMIT
both rows saved
step 1
insert header
wrote → undone
step 2
insert lines
throws
ROLLBACK
nothing saved
This is a default flat transaction, the right call for an invoice and its lines. Isolation levels, savepoints, SELECT ... FOR UPDATE, and serialization retries were covered on the read side.
When the default flat transaction isn't enough — isolation levels, savepoints, FOR UPDATE, and the serialization-failure retry loop.
Threading tx correctly
Section titled “Threading tx correctly”This bug fails silently. A transaction only governs writes that go through its tx handle, so any helper you call inside the callback must take tx and use it. If a helper instead closes over the shared, pooled db, its writes run on a different connection, outside the transaction. Nothing errors and the code looks right, but that write won’t roll back when the transaction does, leaving you the half-built record again, now hidden behind a helper that appears to be inside the transaction.
The rule: a helper meant to run inside a transaction takes tx as its first parameter, like insertInvoiceLines(tx, ...), and never reaches for the global db.
Keep external calls outside the transaction
Section titled “Keep external calls outside the transaction”This is the single most important transaction rule for any web app. Never put external IO inside a transaction.
Money makes the stakes clearest. Your action creates an invoice and charges the customer’s card. The tempting shape puts both inside the transaction, so they feel like one unit:
const invoice = await db.transaction(async (tx) => { const [invoice] = await tx.insert(invoicesTable).values(data).returning(); await insertInvoiceLines(tx, linesFor(invoice.id));
await stripe.charges.create({ amount: invoice.total, customer: invoice.customerId });
return invoice;});The charge succeeds, a later line throws, the transaction rolls back. The invoice rows vanish, but a charge that already went through has no rollback, so the customer is billed for an invoice that no longer exists. The transaction also held a pooled connection open across the whole Stripe round-trip.
const invoice = await db.transaction(async (tx) => { const [invoice] = await tx.insert(invoicesTable).values(data).returning(); await insertInvoiceLines(tx, linesFor(invoice.id)); return invoice;});
await chargeForInvoice(invoice);The transaction commits first, then the charge fires. The customer is only ever billed for an invoice that durably exists, and the connection is released the instant the transaction commits.
That is the first reason: an external effect can’t roll back, so it must not sit inside something that can. Swap Stripe for an email and it’s the same problem, milder: a confirmation for an invoice that doesn’t exist.
The second reason is quieter and bites at scale. A transaction holds a pooled connection for its entire duration, so an external call inside it, whether fetch, Stripe, an email send, an upload, or a queue trigger, holds that connection open across a slow network round-trip. One request is fine; a thousand at once is pool starvation , and the whole app stalls.
So external side effects fire after the transaction commits, never inside it. Capture only what you need, let it commit, then do the external work:
const invoice = await db.transaction(async (tx) => { // writes only — no fetch, no Stripe, no email return invoice;});await sendInvoiceEmail(invoice);An action inserts an invoice and its line items inside a db.transaction, and it also has to charge the customer’s card through Stripe. Where does the stripe.charges.create(...) call belong, and why?
db.transaction(...) resolves. The charge is real money the database can’t take back, so it has to wait until the rows it depends on are durably written — and keeping it out of the callback also frees the pooled connection the moment the writes commit.db.transaction(...), so the slow network call is out of the way before any rows are written.awaited in turn.Idempotency, and the seam you write today
Section titled “Idempotency, and the seam you write today”One hole remains: the double-click. The user clicks “Create invoice,” the network hangs, they click again, or the browser silently retries a POST that never got a clean response. The action runs twice and the database gets two identical invoices.
For internal CRUD that’s annoying. For an action that charges money, sends an email, or ships a box, it’s an incident: you’ve billed someone twice. Your cache and transaction work can’t help, because each run is a valid, fully committed mutation. Neither write is wrong; the problem is that there are two when the user meant one.
The fix is an idempotency key , and the mechanism has two halves:
- The form generates one key per intent, a
crypto.randomUUID()in a hidden input, rendered once. Because the key is fixed at render, a retried submission carries the same key. - The action reads that key and checks a small dedup ledger, a table with a unique constraint on the key. A new key gets written and recorded in one atomic step; a key already there returns the prior result instead of writing again.
click
no key
retry click
no key
runs twice
createInvoice
writes each time
Invoice #1
written
Invoice #2
duplicate
click
key: a1f3…
retry click
key: a1f3…
writes
returns existing
checks the key
createInvoice
matches the ledger
Invoice #1
key: a1f3…
The key must come from the form, generated once at render, not derived on the server from a hash of the inputs. A server-side hash would treat two legitimately distinct submissions that happen to match, two real invoices for the same customer, amount, and day, as a duplicate and drop the second. The key identifies intent, not content, which is why it lives on the form.
Today you write only the form-side seam, one line:
<input type="hidden" name="idempotencyKey" defaultValue={crypto.randomUUID()} />A hidden input carrying a fresh UUID for an action to read. (Use defaultValue, not value: the input is uncontrolled.) The other half, the atomic check-and-claim against the dedup ledger, is a self-contained pattern the webhooks chapter teaches in full.
The action-side claim the webhooks chapter builds: a unique constraint, INSERT ... ON CONFLICT DO NOTHING RETURNING as an atomic check-and-claim, and the dedup ledger.
The rule to carry forward: any action that creates something non-recoverable, charging money, sending mail, shipping goods, needs an idempotency key from day one. Pure CRUD can wait, but the form seam costs one line, so add it.
The complete action, end to end
Section titled “The complete action, end to end”Every seam is now taught, so here is the finished createInvoice in order, the artifact the invoicing project picks up and extends. What’s real and what’s still a placeholder:
- parse, mutate, revalidate, and return / redirect are real, working code.
- authorize is still a one-line
getCurrentUser()stand-in; the real wrapper that reads the session and enforces a role lands in the authentication chapter. - The external side effect is a single line after the commit, standing in for the email-send work a later chapter builds.
export async function createInvoice( formData: FormData,): Promise<Result<{ id: string }>> { const parsed = createInvoiceSchema.safeParse( Object.fromEntries(formData), ); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
const user = await getCurrentUser(); if (!user) return err('unauthorized', 'Please sign in.');
const invoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoicesTable) .values({ ...parsed.data, organizationId: user.organizationId, createdBy: user.id }) .returning(); await insertInvoiceLines(tx, linesFor(invoice.id)); return invoice; });
await sendInvoiceEmail(invoice); revalidatePath('/invoices'); redirect(`/invoices/${invoice.id}`);}Parse, seam one. Nothing past this line runs on bad input.
export async function createInvoice( formData: FormData,): Promise<Result<{ id: string }>> { const parsed = createInvoiceSchema.safeParse( Object.fromEntries(formData), ); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
const user = await getCurrentUser(); if (!user) return err('unauthorized', 'Please sign in.');
const invoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoicesTable) .values({ ...parsed.data, organizationId: user.organizationId, createdBy: user.id }) .returning(); await insertInvoiceLines(tx, linesFor(invoice.id)); return invoice; });
await sendInvoiceEmail(invoice); revalidatePath('/invoices'); redirect(`/invoices/${invoice.id}`);}Authorize, seam two. The action’s only unfinished line. The authentication chapter lifts it into a reusable authedAction wrapper.
export async function createInvoice( formData: FormData,): Promise<Result<{ id: string }>> { const parsed = createInvoiceSchema.safeParse( Object.fromEntries(formData), ); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
const user = await getCurrentUser(); if (!user) return err('unauthorized', 'Please sign in.');
const invoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoicesTable) .values({ ...parsed.data, organizationId: user.organizationId, createdBy: user.id }) .returning(); await insertInvoiceLines(tx, linesFor(invoice.id)); return invoice; });
await sendInvoiceEmail(invoice); revalidatePath('/invoices'); redirect(`/invoices/${invoice.id}`);}Mutate, seam three. Header and lines commit as one unit. This is the work this lesson added.
export async function createInvoice( formData: FormData,): Promise<Result<{ id: string }>> { const parsed = createInvoiceSchema.safeParse( Object.fromEntries(formData), ); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
const user = await getCurrentUser(); if (!user) return err('unauthorized', 'Please sign in.');
const invoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoicesTable) .values({ ...parsed.data, organizationId: user.organizationId, createdBy: user.id }) .returning(); await insertInvoiceLines(tx, linesFor(invoice.id)); return invoice; });
await sendInvoiceEmail(invoice); revalidatePath('/invoices'); redirect(`/invoices/${invoice.id}`);}Side effect and revalidate, seam four. Both fire after the commit and before the return: the external call outside the transaction, then revalidatePath to mark the cached list stale.
export async function createInvoice( formData: FormData,): Promise<Result<{ id: string }>> { const parsed = createInvoiceSchema.safeParse( Object.fromEntries(formData), ); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
const user = await getCurrentUser(); if (!user) return err('unauthorized', 'Please sign in.');
const invoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoicesTable) .values({ ...parsed.data, organizationId: user.organizationId, createdBy: user.id }) .returning(); await insertInvoiceLines(tx, linesFor(invoice.id)); return invoice; });
await sendInvoiceEmail(invoice); revalidatePath('/invoices'); redirect(`/invoices/${invoice.id}`);}Return / redirect, seam five. redirect runs last and outside any catch, so its control-flow signal isn’t swallowed. To report a failure the caller renders in place, return ok({ id }) or an err(...) would sit here instead.
Read top to bottom, the sequence scans in seconds: parse, authorize, mutate, fire side effects and revalidate, navigate. That readability is the payoff of the thin action: it orchestrates, while the real work lives in the helpers it calls.
External resources
Section titled “External resources”The canonical API references for the three new tools:
The path-string and 'page' | 'layout' second-argument rules in full.
How redirect throws, where it's safe to call, and the control-flow predicate for try/catch.
The db.transaction API, tx threading, rollback, and the isolation options this lesson left at their defaults.
Going deeper on the two ideas this lesson left half-built, idempotency and the cache model behind revalidatePath:
Stripe's engineering essay on why retries cause double writes and how an idempotency key gives you exactly-once semantics.
The full dedup-ledger pattern in Postgres — unique constraint, atomic check-and-claim, and external calls between transaction phases.
The internals behind revalidatePath — soft tags, stale-while-revalidate, and how an invalidation propagates to the next request.