After the write
The post-write seams of a Server Action, refreshing the cache with revalidatePath, writing atomically with db.transaction, redirecting, and adding an idempotency key.
Your createInvoice action is almost done. Over the last four lessons it has grown from an empty 'use server' stub into a body that parses the incoming FormData, checks the caller, and reaches into your db/queries helpers. It is one return ok({ id }) away from looking finished.
But “looks finished” and “is correct” are two different things here. Three things still have to be right, and a newcomer gets all three wrong by default:
- The user creates an invoice, navigates back to the list, and the new row isn’t there. The list page was serving a cached copy, and nothing told the cache its data just changed.
- The invoice row wrote, but its line items didn’t, because a constraint failed on the second insert. Now the database holds a half-built invoice: a header with no body, the kind of record the rest of the app can’t handle.
- The user double-clicked the submit button, and there are now two identical invoices.
None of these show up when you click through the happy path once. They show up in production, under real users, on a slow network. This lesson fills the last two seams of the action, the mutate and revalidate seams, and it also teaches the ordering rules that separate a mutation that works from one that’s subtly broken.
You already have the shape this lesson completes. The five-seam spine, parse → authorize → mutate → revalidate → return, has been the skeleton of every action since the first lesson of this chapter, “The use server seam”. You met cached reads back in the App Router chapters, and you met database transactions on the read side. This lesson is where all three meet the mutation. We won’t re-derive the Result type or the parse discipline; we’ll build directly on top of them. The action you assemble at the end is the exact one the invoicing project picks up later.
Refreshing the cache after a write
Section titled “Refreshing the cache after a write”Start with the symptom, because it’s the one you’ll hit first and the one that’s most confusing when you don’t understand it.
Next.js 16 renders dynamically by default: every request re-runs your Server Components against fresh data. But fresh-every-time is slow for a page that reads the same list of invoices on every load, so you opt that read into the cache with 'use cache'. Now the invoices list page is fast, because the framework serves a stored copy instead of hitting the database.
Then createInvoice writes a new row. The user navigates back to /invoices, and the new invoice isn’t there. The page is still serving the copy it cached before the write. The data is in the database; it just isn’t in the cache, and the cache is what the user sees. The stale copy will eventually expire on its own, but “eventually” is not an answer when the user is staring at a list that’s missing the thing they just created.
The fix is to tell the cache that the data it holds is now 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 doesn’t hear about it. The row is in the database, yet nothing tells the cached /invoices page its data changed, so the user navigates back to a 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 the cached entry for that path stale, so the next request to the route rebuilds the page from fresh data and the new row appears.
Read revalidatePath('/invoices') literally: the argument is the URL path of the page whose cache you want to invalidate. For the straightforward list-and-detail case the project builds, that mental model is all you need, because the path string is the URL. You write the same string a user would see in their address bar.
There’s one wrinkle worth naming once. When the path contains a dynamic segment, say your invoices live under /[org]/invoices, the framework can’t tell from the bracketed string alone whether you mean the page itself or the layout it sits in, so you pass a second argument to disambiguate: revalidatePath('/[org]/invoices', 'page'). The second argument is 'page' | 'layout', and it’s required whenever the path has a dynamic segment. Keep this one fact in your back pocket; the rest of this lesson uses plain static paths.
Order is correctness, not style
Section titled “Order is correctness, not style”Where in the action does revalidatePath go? The answer is fixed, and the reason is worth slowing down for.
revalidatePath operates on the server-side cache: it marks a cached entry stale so the next request rebuilds it. It does not rebuild anything on the spot, and it knows nothing about whether your database write has happened yet. So if you call it before the db.insert runs, you invalidate the cache against a database that hasn’t changed. The next request dutifully refetches and reads the same old data, because the new row isn’t written yet. You’ve spent the cache invalidation on nothing, and the user still sees a stale 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 the five-seam order is parse → authorize → mutate → revalidate → return and not some other arrangement. Revalidate is seam four: it fires after the mutate, because it has nothing fresh to point the cache at until the write commits, and before the return, because the return is the last thing the action does. The seam order isn’t a style preference you can rearrange to taste. It’s a correctness constraint, and getting it wrong produces a bug that passes every quick manual test and fails the moment a real user looks at the page twice.
Why this lesson stops at revalidatePath
Section titled “Why this lesson stops at revalidatePath”revalidatePath is not the only cache-invalidation tool, and you might already be wondering when you’d reach for the others. You met the full set in the App Router chapter on caching: updateTag for read-your-writes inside an action, revalidateTag for the eventual case such as a webhook or a background job, revalidatePath, and router.refresh() on the client. That decision tree was settled there, and this lesson deliberately does not re-open it.
Here is the frame to keep. revalidatePath is the blunt, always-correct move for “the data on this path changed.” The tag-based tools earn their extra weight only once your app has a deliberate tagging scheme and wants surgical, read-your-writes invalidation, which is a concern for later, when the SaaS patterns demand it. Until then, calling revalidatePath after every mutation that touches a cached page is the right reflex.
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”Refreshing the list is half the post-write story. The other half is navigation. After creating an invoice, the natural thing to do is send the user straight to its detail page: they made something, so show it to them. You’ll reach for this immediately, so let’s get it right and clear up the one place it seems to conflict with what you already know.
The tool is redirect:
redirect(`/invoices/${invoice.id}`);You call it at the end of the action, and the framework navigates the browser to the new URL. The thing to internalize is that redirect() is a framework convention, not an error and not a return value. Under the hood the runtime implements it by throwing a special control-flow signal, which it catches internally and turns into an HTTP redirect. You write redirect(...), and the framework does the navigation. This is the same Architectural Principle you saw in the previous lesson: lean on the platform’s seams instead of inventing your own, and redirect is one of those seams.
But “it throws” should give you pause, because the lesson “Result, or throw” told you to return a Result and throw only the unexpected. So which is it: does createInvoice return a Result, or does it throw a redirect?
It does both, on different paths, and there’s no contradiction. redirect() rides the throw mechanism, but it is not a failure. It fires on the success path, after the write and the revalidate, instead of returning the Result. Think of it the way the previous lesson framed notFound(): a “throw that isn’t an error.” The action has exactly two endings. Either it succeeds and navigates the user away with redirect(), or it returns a Result the caller renders in place. It never does both, because a redirect ends the function.
The try/catch trap
Section titled “The try/catch trap”Because redirect() works by throwing, it interacts badly with a pattern you’ve already used. In the lesson “Result, or throw” you wrapped a database write in try/catch to map a known violation, a duplicate key, into a Result failure. Now picture that 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. redirect throws its control-flow signal, the catch treats it like any other error, and instead of the detail page the user sees a generic failure. The redirect never happens.
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 catch guards only the mutation. The narrow try maps the known violation and re-throws the rest. redirect is the last statement, outside any catch, so its signal propagates cleanly and the navigation fires.
There are two defenses, in order of preference:
- Call
redirect()at the very end of the action, outside anytry/catch. This is the default this course writes, and it’s the one in the “Redirect last” tab above. Keep yourtry/catchnarrow, wrapping only the database call you’re mapping errors for, and the redirect never lands inside it. - If a redirect genuinely must live inside a
tryblock, re-throw the framework’s control-flow signals so they propagate past your catch. Next.js exposes a predicate for detecting them, so your catch can let them through while still handling real errors.
Reach for the first one by default: put redirect() last, outside the catch. You’ll almost never need the second.
Atomic multi-step writes with db.transaction
Section titled “Atomic multi-step writes with db.transaction”So far we’ve treated the mutate seam as a single db.insert. Real mutations are rarely that tidy. Creating an invoice doesn’t write one row; it writes the invoice header and its line items, two separate inserts. The moment a mutation has two steps, a new failure mode appears.
Picture the second insert failing. The header wrote fine, but a constraint failed on the line items, or the connection dropped mid-write. Your two await db.insert(...) calls have no relationship to each other as far as the database is concerned, and the first one already committed. So now the database holds an invoice with no line items: a half-built, internally inconsistent record. Every part of the app that assumes an invoice has at least one line will trip over it. Plain sequential inserts give you no all-or-nothing guarantee, because each one stands or falls alone.
The fix is a transaction. You wrap the related writes in a single 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 opens the transaction and runs the callback inside it. Whatever the callback returns becomes the value of invoice out here, but only once the transaction has committed. Everything between the braces is one atomic unit.
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 the callback goes through tx, the transaction handle, not the outer db. This is the binding: 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 and .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 write is a db/queries helper, and it takes tx as its first argument. Passing tx keeps the second write inside the same transaction. This is why those helpers were written to accept tx up front: the contract pays off right here.
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 from the callback commits the transaction: both inserts land together. Any throw before this line rolls both of them back, so the header insert is undone too.
The shape to memorize is db.transaction(async (tx) => { ... }). Every write inside the callback uses tx, not db. If the callback returns normally, the transaction commits and every write inside it lands together, atomically . If anything throws, the transaction rolls back , and every write so far is undone, as if none of them happened. Either both rows exist or neither does. There is no in-between for the rest of the app to trip over.
When do you reach for one? The trigger you learned on 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. Creating an invoice with its lines is the textbook case.
In the five-seam shape, the transaction is the mutate seam, seam three. Parse and authorize run before it; revalidate and return run after it. One point is worth being precise about: the transaction is for atomicity, not for error handling. Mapping a duplicate-key error into a Result is still the try/catch job from the lesson “Result, or throw”, and that try/catch wraps the whole db.transaction(...) call from the outside. The transaction’s job is narrow: make the writes all-or-nothing.
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 lesson deliberately leaves alone the levers on the transaction itself. Isolation levels like { isolationLevel: 'serializable' }, nested transactions as savepoints, row locking with SELECT ... FOR UPDATE, and the retry loop for serialization failures are all things you met on the read side, and they’re not re-taught here. This lesson uses the default isolation and a plain flat transaction, which is the right call for creating an invoice and its lines. With experience, the judgment becomes clear: most action transactions need nothing more, so reach for serializable only when a consistency-sensitive flow specifically names the trigger, which is a later concern.
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”The one subtle bug in all of this is worth isolating, because it fails silently. A transaction only governs the writes that go through its tx handle. Any db/queries helper you call inside the callback has to take tx and use it for its writes. If a helper instead closes over the shared, pooled db, the one exported from your db module, then its writes run on a different connection, outside your transaction entirely.
Nothing errors, and the code looks right. But that helper’s write won’t roll back when the transaction does, because it was never part of the transaction. You’re back to the half-built record, except now it’s hidden behind a helper that appears to be inside the transaction. This is exactly why your db/queries helpers were written to take tx as their first parameter: so the call site reads insertInvoiceLines(tx, ...) and the connection threads through correctly. When you write a helper that runs inside a transaction, take tx, and never let it reach for the global db.
Keep external calls outside the transaction
Section titled “Keep external calls outside the transaction”Here is the single most important transaction rule for any SaaS app, the one that turns a working feature into a production incident if you get it wrong. Never put external IO inside a transaction.
Make it concrete with money, where the stakes are clearest. Your action creates an invoice and charges the customer’s card. The tempting shape is to do 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, then the transaction rolls back. Stripe takes the customer’s money, a later line throws, the invoice rows vanish, and there’s no rollback for a charge that already went through. 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 database connection is released the instant the transaction commits, never parked on the network.
Walk the failure in the first tab. The stripe.charges.create call succeeds, so the card is charged, in the real world, for real money. Then some later line in the transaction throws, and the whole transaction rolls back. The invoice rows disappear. But the charge already happened, out in Stripe’s systems, and there is no rollback for a charge that already went through. The customer has been billed for an invoice that no longer exists. The database and the outside world have diverged, and one of them is holding the customer’s money. Swap Stripe for an email and it’s the same problem in a milder form: the customer gets a confirmation email for an invoice that doesn’t exist.
There’s a second, quieter reason, and it bites at scale. A transaction holds a database connection from the pool for its entire duration. An external call inside the transaction, whether fetch, Stripe, an email send, an upload, or a queue trigger, holds that connection open across a slow network round-trip. One request doing that is fine. A thousand requests doing it at once is pool starvation : every connection is parked waiting on someone else’s server, new requests can’t get a connection, and the whole app stalls, not because the database is slow, but because you tied your connections to the network.
So the rule rests on two reasons. Correctness: there’s no rollback for an external effect, so it must not be inside something that can roll back. Performance: don’t park a pooled connection on a network call. Both point to the same reflex:
External side effects fire after the transaction commits, never inside it. Capture only what you need from the transaction, 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);One forward pointer is worth flagging. “Fire it after the commit” is correct, but it has a gap: if the process dies in the instant between the commit and the external call, the effect never fires and you’ve got an invoice with no confirmation email. For effects that must survive that gap, the durable answer is a background job, work that’s recorded and retried independently of the request. That’s a later chapter. For this lesson, the rule is complete as stated: no external IO inside the transaction, and fire it after the commit.
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, and it’s the double-click. The user clicks “Create invoice,” the network hangs for a second, and they click again. Or the browser, seeing a POST that didn’t get a clean response, silently retries it. Either way the action runs twice, and the database gets two identical invoices.
For internal CRUD this is annoying. For an action that charges money, sends an email, or ships a physical package, it’s a genuine incident: you’ve billed someone twice, or shipped two boxes. The cache and transaction work you just did doesn’t help here, because each run is a perfectly valid, fully-committed mutation. The problem isn’t that either write is wrong; it’s that there are two of them when the user meant one.
The fix the field has settled on is an idempotent key, and the mechanism is straightforward once you see it:
- The form generates one stable key per intent, a
crypto.randomUUID()placed in a hidden input, and renders it once. Because it’s generated when the form renders, the same submission, retried, carries the same key. Two clicks of the same button send the same key. - The action reads that key, checks a small dedup ledger (a table with a unique constraint on the key), and branches: if the key was already processed, it returns the prior result without writing again; if it’s new, it does the write and records the key in the same atomic step.
The key collapses the duplicate. The second run sees a key it has already handled and returns the first run’s result instead of creating a second invoice.
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…
Here’s the nuance newcomers get wrong, and it’s worth getting right the first time: the key must come from the form, generated once at render, not derived on the server from a hash of the input fields. A server-side hash of the inputs would treat two legitimately distinct submissions that happen to be identical, two real invoices for the same customer, same amount, same day, as a duplicate, and silently drop the second. That’s a real invoice the business needed, swallowed by a dedup that was too clever. The key is an identifier of intent, not of content, which is exactly why it lives on the form.
So what do you write today? Just the form-side seam, one line:
<input type="hidden" name="idempotencyKey" defaultValue={crypto.randomUUID()} />That’s the whole of this lesson’s contribution to idempotency: a hidden input that carries a fresh UUID, ready for an action to read. (Use defaultValue, not value, because the input is uncontrolled, which is the form convention you’ll meet properly in the forms chapter; one line is enough for now.) The other half, the unique constraint, the atomic check-and-claim that records the key and does the write in one step, the dedup ledger, and the cleanup, is a self-contained pattern that the webhooks chapter teaches in full, applying the exact same idea to webhooks, actions, jobs, and public endpoints at once.
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 reflex to carry forward: every action that creates something non-recoverable, whether it charges money, sends mail, or ships goods, needs an idempotency key from day one. Pure internal CRUD can wait until the duplicate-row bug actually bites. But the form-side seam costs one line, so there’s rarely a reason not to 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 whole thing in one place: the finished createInvoice, in order, which is also the artifact the invoicing project picks up and extends.
Before you read it, one note on what’s real and what’s still a placeholder, so nothing misleads you:
- parse, mutate (the transaction), revalidate, and return / redirect are real, working code. The deliberately-stubbed skeleton this chapter has carried since the first lesson ends here for these seams.
- authorize is still a one-line abstract check, a
getCurrentUser()call used as a stand-in. The real wrapper that reads the session and enforces a role lands in the authentication chapter. This is the one intentionally-unfinished line. - The external side effect appears as 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. safeParse against Object.fromEntries(formData), with a Result validation failure if the shape is wrong, straight from the parse and Result lessons. 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 one-line caller check, used abstractly here. The authentication chapter lifts this into a reusable authedAction wrapper; for now it’s a placeholder, and the only unfinished line in the action.
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. The atomic write: header and lines together via tx, committing 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. The external call fires after the commit (outside the transaction), and revalidatePath marks the cached list stale, also after the commit and before the return. Revalidate is seam four, and it sits exactly here for a reason.
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. On success the action navigates the user to the new invoice with redirect, placed last and outside any catch so its control-flow signal isn’t swallowed. (Had the action needed to report a failure the caller renders in place, this is where return ok({ id }) or an err(...) would sit instead.)
Read top to bottom, it’s a sequence you can scan in seconds: parse, authorize, mutate, fire side effects and revalidate, navigate. That readability is the payoff of the thin-action shape from the previous lesson: the action orchestrates, and the real work lives in the helpers it calls. Every action you write from here follows this template.
Now commit to the ordering yourself. The drill below shuffles the seams of an action that creates an invoice and sends the user to its detail page. Put them back in the order that’s actually correct, and notice that the two wrong placements it most wants to catch are the exact mistakes this lesson warned about: revalidating before the write, and not putting the redirect last.
Order the bodies of a Server Action that creates an invoice and sends the user to its detail page. Drag the items into the correct order, then press Check.
export async function createInvoice( formData: FormData,): Promise<Result<{ id: string }>> { // ↓ put these in order}safeParse the FormData, and return a validation Result if it fails db.transaction revalidatePath('/invoices') so the cached list refreshes redirect to the new invoice’s detail page Where this leaves you
Section titled “Where this leaves you”The action surface is closed. createInvoice now parses on entry, writes atomically, refreshes the cache, and navigates the user, and you know why each piece sits where it does: revalidate after the mutate because it has nothing fresh to point at sooner; the cache call and every external effect outside the transaction because neither can be rolled back; the redirect last so its control-flow signal isn’t caught. Those orderings aren’t style. They’re the difference between a mutation that works and one that breaks under the first real user.
You also wrote one forward-compatible seam, the idempotency hidden input, and named the slots the later chapters fill: the full cache-invalidation decision tree, transaction isolation and locking, the idempotency implementation, the real auth wrapper, and durable background effects. The shape is complete, and the depth comes as you need it.
Going deeper
Section titled “Going deeper”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.
External resources
Section titled “External resources”The cards above are the canonical API references for the three new tools. These go deeper on the two ideas this lesson opened but deliberately 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.