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 action attribute.
  • 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 deleteTodoAction or combined with the useActionState hook (like manageTodoAction) to leverage response state and pending status.

  • When using the useActionState hook, 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 revalidatePath function 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 revalidatePath the 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!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Using Kafka with Docker and NodeJS

Sitemap

React Practice Series

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Kubernetes Practice Series