Next.js gives you two ways to write server-side logic that a form or button can trigger: a Server Action or a Route Handler under app/api/. They overlap enough that it’s easy to default to one and never reconsider. We use both on FurnitureAxis, and the split isn’t arbitrary.
What a Server Action actually is
A Server Action is a function marked 'use server', called directly from a form’s action prop or a client component’s event handler. Under the hood it’s still a POST request. The Next.js compiler swaps the function reference in the client bundle for an encrypted action ID and a dispatcher that posts back to the server, but the request travels over the wire exactly the way a request to an API route does.
What you get for free is the part people actually want: a form doesn’t need a matching fetch call, a route file, and a client-side handler that parses the response. You write one async function, put it next to the component that calls it, and React’s useActionState gives you a pending state without extra plumbing.
'use server'
export async function updateOrderStatus(orderId: string, status: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const order = await db.order.findFirst({
where: { id: orderId, tenantId: session.user.tenantId },
})
if (!order) throw new Error('Not found')
await db.order.update({ where: { id: orderId }, data: { status } })
revalidatePath('/orders')
}
That’s most of what a mutation in a multi-tenant app needs: check who’s asking, check they own the row, write, revalidate. No separate route file, no client-side fetch wrapper.
Where FurnitureAxis uses them
FurnitureAxis is a multi-tenant SaaS for furniture retailers, built on Next.js 15 with Prisma and PostgreSQL across a schema with more than a hundred models. Inventory adjustments, order updates, commission edits: those mutations go through Server Actions, the same pattern we described in Next.js or Astro, how we choose. A warehouse manager clicks save on an inventory count, the action checks the session, scopes the query to that tenant, writes the row, and revalidates the path. One function, one file, no API contract to keep in sync with a separate fetch call.
Next.js documents this as a deliberate constraint: actions dispatch one at a time per client, so a user firing off three mutations in quick succession has them queued and resolved in order rather than racing each other. For a form that updates one order’s status, that ordering is exactly what you want.
Why that’s not the whole story
The Next.js docs on Server Actions and mutations are direct about the security model: an action is a POST endpoint, and anyone who can construct the same request can reach it, whether or not they go through the UI that renders the form. Next.js checks the request’s Origin against its Host, encrypts the action ID, and strips unused actions from the client bundle, but none of that replaces checking authentication and ownership inside the function itself. That’s true of the updateOrderStatus example above: the tenant scoping in the where clause is doing the actual security work, not the framework.
That same page is also clear about what Server Actions are for: mutations triggered from your own React UI. Plenty of real traffic doesn’t come from there.
A webhook is the clearest case. Stripe, a vendor catalog sync, or any third-party service posting to your app isn’t submitting a React form and has no session cookie to check. It needs a URL, a documented request and response shape, and control over the status code it sends back. Next.js’s own docs point to a Route Handler for exactly this: a route.ts file with a POST export that reads the raw body, verifies a signature, and returns a 200 or a 4xx.
A mobile client is a similar case. FurnitureAxis has an Expo mobile companion app that talks to the same backend, and it isn’t running inside the same Next.js client runtime a browser is. It needs a stable HTTP endpoint with a URL and a JSON contract it can call the same way regardless of what the web app’s UI looks like that week.
There’s also the plain HTTP-shape question. Server Actions are invoked as POST only. Cacheable reads, custom response headers, streaming, or a non-UI response like sitemap.xml call for the full set of GET, PUT, PATCH, DELETE, HEAD, and OPTIONS exports a Route Handler gives you.
The line we actually use
Does this request come from your own React UI, is it a mutation, and does it only need to run once per click? Server Action. Does it come from outside your app’s React tree, does another system need to call it on its own terms, or does it need HTTP semantics a form submission doesn’t give you? Route Handler.
FurnitureAxis’s C# background worker that syncs the vendor catalog, Shopify, and Algolia on a cron schedule doesn’t submit forms either. It calls into the app over HTTP the same way a webhook would, which puts it firmly on the Route Handler side of that line.
Neither is the more “correct” way to write a Next.js backend. Server Actions save you from writing and maintaining a parallel fetch layer for every mutation your own UI triggers. Route Handlers exist because not every caller is your UI. If a request is going to come from Stripe, a cron job, or a phone that isn’t running your React tree, give it a real URL.