Type-safety Next.js Server Actions
The most efficient way to use server action powered by Zod
- Easy to use: create action, provide Zod schema and use it 🎉
- Protected actions: protect your actions with guards for auth/roles or any other logic
- Provide context: provide the context using middlewares
- Actions reusability: reuse action clients and attach middlewares to it to create new clients
- Use with hooks: integrate with yor client form to show loading/validation error or refetch your data
- Infer action type: infer action context and input type to use it for action handlers in different modules
- Next.js >= 12.4.x
- TypeScript >= 5.x.x
module.exports = {
experimental: {
serverActions: true,
// ...
},
}
// src/lib/server-actions.ts
import { typedServerActionClient } from 'next-typed-action';
import { cookies } from "next/headers";
export const actionClient = typedServerActionClient()
export const authActionClient = actionClient.use((ctx) => {
const userId = cookies().get('userId') // can access previous context value
if (!userId) {
throw new Error('Unauthorized') // protect action guard
}
return {
userId, // context will be merged with previous context automatically
}
})
// src/app/_actions.ts
'use server'
import { z } from 'zod';
import { authActionClient, actionClient } from '@/lib/serverActions'
const loginDto = z.object({
username: z.string(),
password: z.string(),
})
const login = actionClient
.input(loginDto)
.action(({ input, ctx }) => {
// ...
})
const createItem = authActionClient
.input(z.string())
.action(({ input, ctx }) => {
// ...
})
npm install next-typed-action zod
or
yard add next-typed-action zod
or
pnpm add next-typed-action zod
import { typedServerActionClient } from 'next-typed-action';
const actionClient = typedServerActionClient()
const authActionClient = actionClient.use(() => {
// ...
})
const adminActionClient = authActionClient.use(() => {
// ...
})
const otherActionClient = adminActionClient
.use(() => {
// ...
})
.use(() => {
// ...
})
.use()
const itemOperationsActionClient = authActionClient.input(z.string())
const deleteItem = itemOperationsActionClient.action(({ input, ctx }) => {})
const getItem = itemOperationsActionClient.action(({ input, ctx }) => {})
const someActionItem = itemOperationsActionClient.action(({ input, ctx }) => {})
const itemOperationsActionClient = authActionClient.action(
({
input, // void type
ctx
}) => {
// ...
})
'use client'
import { useFormAction } from "next-typed-action";
import { login } from './_actions'
export default LoginForm()
{
const { validation, isLoading, error } = useFormAction(login)
return (
<form action={onSubmit}>
<input name="username"/>
// Show validation error type-safety way for each field in form
{validation?.username && <div>{validation.username[0]}</div>
<input name="password"/>
{validation?.password && <div>{validation.password[0]}</div>
<button type="submit" disabled={isLoading}>Create</button>
// Show server error
{error && <div>{error.message}</div>}
</form>
)
}
import { useFormAction } from "next-typed-action";
import { createItem } from './_actions'
export default CreateItemForm()
{
return (
<div>
<button type="submit" onClick={async () => {
const { error, data, validation, status } = await createItem({ name: 'mock-item' })
// work with returned data
if (status === 'success') {
// ...
} else if (status === 'validationError') {
// ...
} else if (status === 'error') {
// ...
}
}}>
Create
</button>
</div>
)
}
'use client'
import { useFormAction } from "next-typed-action";
const loginThrowable = actionClient
.actionThrowable(({ input, ctx }) => {
// ...
})
export default LoginForm()
{
// Throw error to boundary on server error
// Validation error still will be handled by useFormAction
const { validation, isLoading } = useFormAction(loginThrowable)
return (
<form action={onSubmit}>
// ...
</form>
)
}
import { useFormAction } from "next-typed-action";
const createItemThrowable = actionClient
.input(...)
.actionThrowable(({ input, ctx }) => {
// ...
})
export default CreateItemForm()
{
return (
<div>
<button type="submit" onClick={async () => {
const { data, validation, status } = await createItemThrowable(
{ name: 'mock-item' },
)
}}>
Create
</button>
</div>
)
}
import { typedServerActionClient, inferContext, inferAction, inferInput } from 'next-typed-action';
const actionClient = typedServerActionClient()
type ActionClientContext = inferContext<typeof actionClient> // {}
type ActionClient = inferAction<typeof actionClient> // { ctx: {} }
const authActionClient = actionClient.use(() => ({
userId: 'mock-user-id',
}))
type AuthActionClientContext = inferContext<typeof actionClient> // { userId: string }
type AuthActionClient = inferAction<typeof actionClient> // { ctx: { userId: string } }
const loginActionClient = authActionClient.input(z.object({
username: z.string(),
password: z.string(),
}))
type LoginActionClientContext = inferContext<typeof actionClient> // { userId: string }
type LoginActionClientInput = inferInput<typeof actionClient> // { username: string, password: string }
type LoginActionClient = inferAction<typeof actionClient> // { ctx: { userId: string }, input: { username: string, password: string } }
const {
status: 'error' | 'validationError' | 'success' | 'loading' | 'idle',
data: TData | undefined,
error: string | undefined,
validation: Record<keyof z.input<TSchema>, string[]> | undefined,
isLoading: boolean,
isError: boolean,
isValidationError: boolean,
isSuccess: boolean,
submit: (schema: z.input<TShema> | FormData) => void,
reset: () => void,
} = useFormAction<TSchema, TData>(
typedServerAction: ClientServerActionSafe<TShema, TData>, // action created by typedServerActionClient().[...].action
)
const {
status: 'validationError' | 'success' | 'loading' | 'idle',
data: TData | undefined,
validation: Record<keyof z.input<TSchema>, string[]> | undefined,
isLoading: boolean,
isValidationError: boolean,
isSuccess: boolean,
submit: (schema: z.input<TShema> | FormData) => void,
reset: () => void,
} = useFormAction<TSchema, TData>(
typedServerAction: ClientServerActionThrowable<TShema, TData>, // action created by typedServerActionClient().[...].actionThrowable
)
TODO