Using Server Actions in NextJS
Introduction
NextJS Server Actions is a powerful feature that allows you to perform data mutations directly on the server without needing to manually create API Routes, with key benefits including:
- Tight Integration with Forms: Works seamlessly with the HTML form
actionattribute. - Progressive Enhancement: The application can still perform basic functions even if JavaScript is not fully loaded or is disabled.
- Security: Automatically protects against CSRF attacks and keeps sensitive processing logic on the server.
- Simplified Code: Reduces boilerplate code when connecting Client and Server.
Detail
First, let's create the app/todo/schema.ts file to define the types and validate data using Zod.
import {z} from 'zod'
export const TodoSchema = z.object({
id: z.string().optional().nullable(),
title: z.string().min(1, 'Title is required').max(100),
})
export type Todo = z.infer<typeof TodoSchema>
export type ActionState = {
success: boolean
message: string
errors?: Record<string, string[]>
}
Create the app/todo/action.ts file.
'use server'
import {revalidatePath} from 'next/cache'
import {cookies} from 'next/headers'
import {ActionState, Todo, TodoSchema} from './schema'
let todos: Todo[] = []
export async function manageTodoAction(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const cookie = await cookies()
const token = cookie.get('token')?.value
if (!token) {
throw new Error('Unauthorized')
}
await new Promise(res => setTimeout(res, 800))
const id = formData.get('id') as string
const title = formData.get('title') as string
const validated = TodoSchema.safeParse({id, title})
if (!validated.success) {
return {
success: false,
message: 'Invalid data',
errors: validated.error.flatten(issue => issue.message).fieldErrors,
}
}
todos.push({id: Math.random().toString(), title})
revalidatePath('/todo')
return {success: true, message: 'Updated successfully'}
}
export async function deleteTodoAction(id: string) {
todos = todos.filter(t => t.id !== id)
revalidatePath('/todo')
return {success: true}
}
export async function getTodos() {
return todos
}
- These are server actions, the 'use server' keyword indicates that this code only runs on the server and will not be sent to the client, making it suitable for handling important logic.
- When using Server Actions, NextJS automatically checks the Origin, Hostname and Next-Action ID (is a random code generated in build time) of the server before execution, so by default you have built-in CSRF protection.
- Server Actions can be used like API handles so they can be called via Postman, thus to enhance security you should check permissions such as with cookies before allowing service usage.
Create the app/todo/ProductManager.tsx file.
'use client'
import {deleteTodoAction, manageTodoAction} from '@/app/action/product/action'
import type {Todo} from '@/app/action/product/schema'
import {DeleteOutlined} from '@ant-design/icons'
import {
Button,
Empty,
Form,
Input,
Space,
Spin,
Typography,
message,
} from 'antd'
import {useActionState, useEffect, useRef} from 'react'
const {Text, Title} = Typography
export default function TodoManager({initialTodos}: {initialTodos: Todo[]}) {
const [antdForm] = Form.useForm()
const formRef = useRef<HTMLFormElement>(null)
const [state, formAction, isPending] = useActionState(manageTodoAction, {
success: false,
message: '',
})
useEffect(() => {
if (state.success) {
antdForm.resetFields()
if (state.message) message.success(state.message)
}
}, [state, antdForm])
return (
<div className="max-w-xl mx-auto mt-10 p-6 bg-white rounded-xl shadow-lg relative overflow-hidden">
{isPending && (
<div className="absolute inset-0 bg-white/60 z-10 flex items-center justify-center backdrop-blur-[1px]">
<Spin size="large" description="Processing..." />
</div>
)}
<Title level={3} className="text-center mb-6">
Task Inventory
</Title>
<form ref={formRef} action={formAction}>
<Form
form={antdForm}
component={false}
>
<div className="flex gap-2 mb-8 items-start">
<Form.Item
name="title"
className="flex-1 m-0"
validateStatus={state.errors?.title ? 'error' : ''}
help={state.errors?.title?.[0]}
rules={[{required: true, message: 'Please input task!'}]}
>
<Input
name="title"
placeholder="What's your next task?"
size="large"
disabled={isPending}
allowClear
/>
</Form.Item>
<Form.Item className="m-0">
<Button
type="primary"
size="large"
htmlType="submit"
loading={isPending}
>
Add Task
</Button>
</Form.Item>
</div>
</Form>
</form>
<div className="flex flex-col gap-3">
{initialTodos.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="No tasks yet"
/>
) : (
initialTodos.map(todo => (
<div
key={todo.id}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors group"
>
<Space className="flex-1">
<Text className="text-base">{todo.title}</Text>
</Space>
<Button
type="text"
danger
className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
icon={<DeleteOutlined />}
onClick={() => deleteTodoAction(todo.id!)}
/>
</div>
))
)}
</div>
</div>
)
}
You can see that server actions allow direct use within the component like
deleteTodoActionor combined with theuseActionStatehook (likemanageTodoAction) to leverage response state and pending status.When using the
useActionStatehook, React handles data submission in a very flexible way.- If the browser can process JavaScript, it will call the API to the server action as usual.
- If for any reason the browser cannot load or execute JavaScript, it will use the classic form submission mechanism to send data to the server action.
- The limitation is that it lacks the flexibility of async/await like calling an API typically, so it is not suitable for handling complex UI logic (like processing Modals).
You may notice that in this component after adding and deleting items I have no operations to reload the list of items, but everything still works, that is thanks to using the
revalidatePathfunction within server actions.- This is the Single-Flight Mutation mechanism of NextJS, when using server actions for add and delete, the list data has changed, when I use
revalidatePaththe NextJS server automatically re-renders the server components and then returns them as an RSC Payload. - On the client side, React then only needs Reconciliation of this data to load onto the UI.
- You can check the network to see only 1 request was sent to handle both tasks efficiently.
Create the app/todo/page.tsx file to load todos from the server side and pass them to the TodoManager component.
import {getTodos} from './action'
import TodoManager from './ProductManager'
export default async function TodoPage() {
const todos = await getTodos()
return (
<main className="min-h-screen bg-gray-100 py-12">
<TodoManager initialTodos={todos} />
</main>
)
}
The result is as follows:
You can disable JavaScript to see this feature still works, at which point it will execute a form submit command that reloads the page.
You can also see that when using server actions, the request header includes origin, host, next-action and cookie so that NextJS can automatically check CSRF and allow you to validate before use.
Testing NextJS Single-Flight Mutation, only 1 request is needed to add/delete and reload content because the returned result is a React Server Component Payload containing component information already rendered on the NextJS server.
Happy coding!
Comments
Post a Comment