
If you've worked with Nuxt long enough, you've probably felt the friction. You define a server route in server/api/, you use useFetch or $fetch on the client, and you get typed responses back. That part is great. But then you realize your inputs aren't validated, your error handling is ad-hoc, and you're writing the same boilerplate auth checks in every single route handler. I got tired of it, so I built something to fix it.
nuxt-safe-action is a Nuxt module that gives you type-safe server actions with Zod validation, composable middleware, and reactive Vue composables. It works with Nuxt 3 and 4, supports Zod v3 and v4, and tries to stay out of your way while solving the parts that useFetch doesn't cover.
The Problem I Kept Running Into
Here's the pattern I found myself repeating across every Nuxt project:
- Create a server route
- Manually parse and validate the request body (or skip it and hope for the best)
- Copy-paste authentication checks from another route
- Figure out how to return validation errors to the client in a consistent format
- On the client, manage loading states, error states, and success states manually
None of this is hard on its own, but it adds up. And the worst part is that your input types aren't connected to your client code. You're maintaining two sources of truth: the shape you expect on the server and the shape you send from the client. If they drift apart, you find out at runtime.
I looked at what other ecosystems were doing. Next.js has server actions. SvelteKit has form actions. tRPC solves this for any framework but comes with significant bundle overhead and its own routing paradigm. I wanted something that felt native to Nuxt, used file-based routing conventions developers already know, and didn't require rethinking how you structure your app.
So I built nuxt-safe-action.
What It Does
The idea is simple. You define actions on the server with a Zod schema and a handler. The module generates routes, creates typed client references, and gives you a composable to call them reactively from your components.
Here's what it looks like in practice.
1. Create an Action Client
You create a base action client once and reuse it. This is where you configure error handling and middleware.
import { createSafeActionClient } from '#safe-action'
export const actionClient = createSafeActionClient({
handleServerError: (error) => {
console.error('Action error:', error.message)
return error.message
},
})
Need authentication? Chain a middleware:
export const authActionClient = actionClient
.use(async ({ next, event }) => {
const session = await getUserSession(event)
if (!session) throw new Error('Unauthorized')
return next({ ctx: { userId: session.user.id } })
})
The event parameter is the full H3 event, so you have access to headers, cookies, and everything else you'd normally have in a Nuxt server route. The ctx object is typed and flows through to your action handler, meaning any action using authActionClient automatically has ctx.userId available without any extra work.
2. Define an Action
Drop a file in server/actions/ and export a default action:
import { z } from 'zod'
import { actionClient } from '../utils/action-client'
export default actionClient
.schema(z.object({
title: z.string().min(1, 'Title is required').max(200),
body: z.string().min(1, 'Body is required'),
}))
.action(async ({ parsedInput }) => {
const post = await db.post.create({ data: parsedInput })
return { id: post.id, title: post.title }
})
parsedInput is fully typed based on your Zod schema. If validation fails, the error is returned to the client as field-level validation errors without your handler ever running.
3. Use It in a Component
On the client, you import the action from a virtual module and use it with the useAction composable:
<script setup lang="ts">
import { createPost } from '#safe-action/actions'
const { execute, data, isExecuting, hasSucceeded, validationErrors } = useAction(createPost, {
onSuccess({ data }) {
console.log('Created post:', data.title)
},
onError({ error }) {
console.error('Failed:', error)
},
})
</script>
<template>
<form @submit.prevent="execute({ title: 'Hello', body: 'World' })">
<div v-if="validationErrors?.title">
{{ validationErrors.title.join(', ') }}
</div>
<button :disabled="isExecuting">
{{ isExecuting ? 'Creating...' : 'Create Post' }}
</button>
<p v-if="hasSucceeded">Created: {{ data?.title }}</p>
</form>
</template>
Everything is typed end-to-end. The input to execute() is typed from your Zod schema. The data ref is typed from your handler's return type. The validationErrors object knows which fields exist. No manual type definitions, no casting, no guessing.
How It Compares
I get asked this a lot, so here's a quick comparison:
$fetch / useFetch | tRPC-nuxt | nuxt-safe-action | |
|---|---|---|---|
| End-to-end type safety | output only | full | full |
| Input validation | manual per route | via Zod | built-in Zod |
| Per-action middleware | no | yes | composable chain |
| Field-level validation errors | no | no | built-in |
| File-based routing | server/api/ | procedure-based | server/actions/ |
| Reactive composable | useFetch | useQuery | useAction |
| Bundle overhead | none | heavy | minimal |
The key thing that sets nuxt-safe-action apart from tRPC is that it doesn't replace Nuxt's conventions. You're still writing files in server/, using familiar patterns, and the module handles the plumbing. And compared to raw $fetch, you get input validation, middleware, and field-level errors without writing any of that yourself.
Middleware Composition
One of the features I'm most happy with is how middleware composes. You can build up layers of clients for different access levels:
// Base client with error handling
export const actionClient = createSafeActionClient({
handleServerError: (error) => {
console.error('Action error:', error.message)
return error.message
},
})
// Authenticated client
export const authActionClient = actionClient
.use(async ({ next, event }) => {
const session = await getUserSession(event)
if (!session) throw new Error('Unauthorized')
return next({ ctx: { userId: session.user.id } })
})
// Admin client (inherits auth middleware)
export const adminActionClient = authActionClient
.use(async ({ next, ctx }) => {
const user = await db.user.findUnique({ where: { id: ctx.userId } })
if (user?.role !== 'admin') throw new Error('Forbidden')
return next({ ctx: { ...ctx, isAdmin: true } })
})
Each client inherits the entire middleware chain from its parent. The adminActionClient automatically runs the auth check before the role check, and the handler gets ctx.userId and ctx.isAdmin fully typed. No duplication, no forgetting to add an auth check to a sensitive route.
Getting Started
If you want to try it out, the setup takes about two minutes:
npx nuxi module add nuxt-safe-action
Or install manually:
pnpm add nuxt-safe-action zod
export default defineNuxtConfig({
modules: ['nuxt-safe-action'],
})
That's it. Create your action client in server/utils/, drop actions in server/actions/, and start using useAction in your components.
The full documentation is at nuxt-safe-action.vercel.app and the source code is on GitHub.
What's Next
The module is still young, and I'm actively working on it. Some things I'm exploring:
- Optimistic updates out of the box
- Integration with Nuxt's built-in caching layer
- More granular error types for different failure scenarios
If you end up using it, I'd genuinely love to hear about your experience. Open an issue, start a discussion, or just drop a star on the repo if you find it useful. Building something like this in the open has been a great learning experience, and community feedback is what shapes the direction it goes.
Happy coding, Philip!