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 having to create API Routes manually.
Distinguishing between Routes Handle and Server Actions
- Similarities:
- Both are code defined on the Server
- Both can be used in Server components and Client components
- Differences:
- Use Routes Handle if you need to call APIs from outside such as from partners or mobile
- Use Server Actions for internal CRUD processing operations
The outstanding advantages of Server Actions compared to Routes Handle include:
Tight integration with Form: Works smoothly with the
actionattribute of HTML forms.Progressive Enhancement: The application can still function basically even when JavaScript has not finished loading or is disabled.
Security: Automatically protects against CSRF attacks and keeps sensitive processing logic on the server side.
Code simplification: Reduces boilerplate code when connecting between Client and Server.
- When using it, you do not need to define APIs manually like Routes Handle and call APIs (such as using fetch)
Just import Server Actions directly to use, taking advantage of Type-Safety for both input params and output
Good integration with Revalidation feature: When processing in Server Actions, if you use revalidatePath, NextJS will automatically clear the cache of the corresponding page and return new data immediately in a single request. If you use Routes Handle, you would have to take many more steps to achieve the same result.
Detail
First, create the file app/todo/schema.ts to define 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 file app/todo/action.ts
'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 keyword 'use server' indicates that this code only runs on the server, will not be sent to the client, and is therefore suitable for processing important logic
- When using Server Actions, NextJS automatically checks the Origin and Hostname of the server before execution, so by default you already have a built-in CSRF protection mechanism
- Server Actions can be used as API handles and can be called via Postman, so to enhance security you should check permissions such as via cookies before allowing service usage
Create file app/todo/ProductManager.tsx
'use client'
import {deleteTodoAction, manageTodoAction} from '@/app/action/product/action'
import type {Todo} from '@/app/action/product/schema'
import {DeleteOutlined, RocketOutlined} from '@ant-design/icons'
import {
Button,
Empty,
Form,
Input,
Space,
Spin,
Typography,
message,
} from 'antd'
import {useActionState, useEffect, useRef, useTransition} from 'react'
const {Text, Title} = Typography
interface Props {
initialTodos: Todo[]
showMessage: () => Promise<{message: string}>
}
export default function TodoManager({initialTodos, showMessage}: Props) {
const [antdForm] = Form.useForm()
const formRef = useRef<HTMLFormElement>(null)
const [isPendingTransition, startTransition] = useTransition()
const [state, formAction, isPending] = useActionState(manageTodoAction, {
success: false,
message: '',
})
useEffect(() => {
if (state.success) {
antdForm.resetFields()
if (state.message) message.success(state.message)
}
}, [state, antdForm])
const handleExtraAction = () => {
startTransition(async () => {
const result = await showMessage()
message.info(result.message)
})
}
return (
<div className="max-w-xl mx-auto mt-10 p-6 bg-white rounded-xl shadow-lg relative overflow-hidden">
{(isPending || isPendingTransition) && (
<div className="absolute inset-0 bg-white/60 z-10 flex items-center justify-center backdrop-blur-[1px]">
<Spin
size="large"
description={isPendingTransition ? 'Syncing...' : '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 || isPendingTransition}
allowClear
/>
</Form.Item>
<Form.Item className="m-0">
<Space>
<Button
type="primary"
size="large"
htmlType="submit"
loading={isPending}
disabled={isPendingTransition}
>
Add Task
</Button>
<Button
size="large"
icon={<RocketOutlined />}
onClick={handleExtraAction}
loading={isPendingTransition}
disabled={isPending}
>
Quick Action
</Button>
</Space>
</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>
)
}
I have listed the usage methods of Server Actions above, you just need to import to use it like a regular function
- Use directly in components like deleteTodoAction
- Use with useActionState hook like manageTodoAction to take advantage of response state and pending status
- Use with useTransition hook like showMessage, during the processing of Server Actions in useTransition, the operations from calling until receiving results and showing on the UI are marked as low priority, so they will not block user interactions
- When using useActionState hook, react handles data submission very flexibly
- If the browser can handle javascript, it will call the API as usual to server actions
- If for some reason the browser cannot load or execute javascript, it will use the classic form submission mechanism to send data to the server action
- Its limitation is not having the flexibility of async/await like when calling regular APIs, so it is not suitable for handling complex UI logic (such as handling Modals)
You may notice that in this component after adding and deleting items, I do not have any operations to reload the item list, but everything still works, thanks to using the revalidatePath function in server actions
- This is NextJS's Single-Flight Mutation mechanism, when using server actions for add and delete, the list data has changed, when I use revalidatePath, the NextJS server will automatically re-render the server components and return them as 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 is sent which handles both tasks effectively
Create file app/todo/page.tsx
import {getTodos} from '@/app/action/product/action'
import TodoManager from './ProductManager'
export default async function TodoPage() {
const todos = await getTodos()
async function fakeServerActionInside() {
'use server'
await new Promise(res => setTimeout(res, 3000))
return {message: 'Fake server action completed!'}
}
return (
<main className="min-h-screen bg-gray-100 py-12">
<TodoManager initialTodos={todos} showMessage={fakeServerActionInside} />
</main>
)
}
- Here you can see todos is the result after executing Server Actions on the server side
- And fakeServerActionInside is a server action defined directly in a server component, you just need to add 'use server' at the beginning of the function and then pass it into the component to use
Results are as follows
You can disable Javascript to see that this feature still works, at which point it will perform 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 cookies so that NextJS automatically checks CSRF and allows you to validate before use
Check 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