Skip to main content
Back to Blog
5 min read

Server Actions in production: 4 things I won't do again

Server Actions simplify mutations in Next.js. But after shipping them on three client projects, I've collected four patterns that looked fine in development and broke in production.

next.js

Server Actions are one of the best things about modern Next.js. One function, 'use server', no API route, no fetch boilerplate. I use them on every project now.

But they have sharp edges. After shipping Server Actions on three client projects, here are four patterns I won't repeat.

1. Heavy computation inside the action

Server Actions run on the server — obviously. What's less obvious is that they run in the same process that serves your pages. A Server Action that takes 3 seconds to complete blocks that worker for 3 seconds.

On a project with Stripe invoice generation, I put the PDF rendering inside a Server Action. It worked fine in development (one user, no concurrency). In production, three users generating invoices simultaneously slowed the entire app. Page loads degraded, other Server Actions queued behind the PDF work.

The fix: Server Actions should be thin. They validate input, write to the database, and trigger background work. The PDF generation moved to a background job (a simple queue with BullMQ). The Server Action now writes an invoice_requested row, returns immediately, and the worker picks it up.

'use server'

export async function generateInvoice(formData: FormData) {
  const data = invoiceSchema.parse(Object.fromEntries(formData))

  // Don't do this:
  // const pdf = await renderInvoicePDF(data) // 2-4 seconds

  // Do this:
  await db.invoiceJobs.create({ data, status: 'pending' })
  revalidatePath('/invoices')
}

Rule: if it takes more than 500ms, it doesn't belong in a Server Action.

2. Skipping input validation

"It's server-side, so I don't need to validate." I've heard this from other developers. I've also thought it myself — briefly.

Server Actions are publicly accessible endpoints. Anyone can call them with any payload. The 'use server' directive creates an HTTP endpoint under the hood. If you trust the input shape because "the form only sends valid data," you're trusting the client — which is exactly what server-side code isn't supposed to do.

On one project, a Server Action accepted a role field from a form and wrote it directly to the database. The form had a dropdown with "user" and "admin" options. In development, this was fine. In production, someone used browser dev tools to submit role: "superadmin" — a role that existed in the database but wasn't in the UI.

The fix: validate every Server Action input with Zod (or your schema library of choice). Not just types — business rules too.

'use server'

const updateRoleSchema = z.object({
  userId: z.string().uuid(),
  role: z.enum(['user', 'editor']), // Only allowed roles
})

export async function updateRole(formData: FormData) {
  const { userId, role } = updateRoleSchema.parse(Object.fromEntries(formData))
  // Now safe to write
}

Rule: Server Actions are API endpoints. Treat them like API endpoints.

3. Using Server Actions for data fetching

This is a pattern I see in tutorials and I used it once myself before realizing the cost.

// Don't do this
'use server'
export async function getUsers() {
  return db.users.findMany()
}

// In a client component:
const [users, setUsers] = useState([])
useEffect(() => {
  getUsers().then(setUsers)
}, [])

This works. But it defeats the purpose of Server Components. You're shipping a client component that fetches data from a Server Action, when you could have a Server Component that fetches data directly — with zero client JS, no loading state, and streaming support.

The pattern above also has a subtle performance issue: the data goes through Server Action serialization (React Flight protocol), which adds overhead compared to a direct database call in a Server Component.

When Server Actions should fetch data: when you need to refetch after a mutation in a client component, and revalidatePath/revalidateTag aren't granular enough. That's it.

// Do this: Server Component fetches directly
async function UsersPage() {
  const users = await db.users.findMany()
  return <UserList users={users} />
}

Rule: Server Components fetch data. Server Actions mutate data. Keep them separate.

4. Forgetting revalidation

Server Actions mutate data. After mutation, the cached page still shows the old data. If you forget to revalidate, users see stale state until they hard-refresh.

I shipped a comment form where the Server Action inserted the comment but didn't call revalidatePath. The user posted a comment, the form cleared (success!), but the comment list didn't update. They posted again. And again. Three duplicate comments before they refreshed the page.

The fix: every Server Action that writes data must revalidate. No exceptions.

'use server'

export async function addComment(formData: FormData) {
  const data = commentSchema.parse(Object.fromEntries(formData))
  await db.comments.create({ data })

  // Don't forget this:
  revalidatePath(`/posts/${data.postId}`)
}

There are two revalidation strategies:

I now have a lint rule that flags Server Actions containing db. or prisma. calls without a corresponding revalidatePath or revalidateTag. It's caught three missed revalidations since I added it.

The pattern that works

After these lessons, every Server Action I write follows the same structure:

'use server'

export async function doThing(formData: FormData) {
  // 1. Validate
  const data = schema.parse(Object.fromEntries(formData))

  // 2. Authorize
  const session = await auth()
  if (!session) throw new Error('Unauthorized')

  // 3. Mutate (keep it fast)
  await db.things.create({ data })

  // 4. Revalidate
  revalidatePath('/things')
}

Four steps, in order, every time. Validate, authorize, mutate, revalidate. If the mutation is slow, step 3 becomes "enqueue a background job" instead of doing the work inline.

Server Actions are a great primitive. They just need the same discipline as any other API endpoint — because that's exactly what they are.


Building with Next.js App Router and want to avoid the production gotchas? Let's talk — I've shipped Server Actions on multiple client projects and know where the edges are.