In this article, we will explore Next.js Server Actions, which were introduced in Next.js 14 and are now stable. This is a good opportunity to learn and use them in large Next.js projects.
What are server actions? π
Actions can only be run on the server. They are simple, regular, and asynchronous functions that are helpful for async tasks like file management, database requests (CRUD operations), and more.
In simple terms, Server Actions are equivalent to API calls, but they are fully integrated. This is because we can send headers and cookies directly in Server Actions. π
Purpose of Server Action
- The purpose of Server Actions is to make data fetching and mutations simpler while removing unnecessary APIs.
- They are simpler than API calls and require less boilerplate code.
- Server Actions can be directly called from both types of components: Server Components and Client Components.
Behaviour of Server Action
- Server Action arguments and return values must be serializable by React. Serializable means they can be converted into a format that can be stored or transmitted, such as primitive data types in JavaScript. Non-serializable values include functions, class instances, symbols, DOM elements, and BigInt.
- Server Actions inherit the runtime of the page and layout. If the page and layout use the Edge runtime, the Server Action must run in the Edge runtime, and vice versa for the Node.js runtime.
- Server Actions can also handle caching and revalidate data. π€
How to implement?
The 'use server';
directive is used to create a Server Action.
For example
import { createTask } from "../_actions/actions";
export default function TodoForm() {
return (
<form action={async (formData) => {
'use server';
// trigrred server action which will run only on server
await createTask(formData)
}}>
{/* form body */}
</form>
)
}
In the example, we created a form and passed an asynchronous function to the formβs action attribute. Inside that function, we used the use server directive, which ensures that the function runs only on the server.
That was a simple example of a Server Action. However, the most convenient approach is to create a separate file for the Server Action. In that file, write 'use server';
at the top to ensure the function executes only on the server.
How can we conveniently implement Server Actions in a real-world application?
In this example, we will create a to-do application with a file system, using Server Actions in Next.js 14.
Step 1
Initialize you latest Nextjs project.
npx create-next-app@latest
Step 2
Create form for the task creation
components/task-form.tsx
"use client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useState } from "react"
export default function TodoForm() {
const [title, setTitle] = useState("")
const [desc, setDesc] = useState("")
const [type, setType] = useState<'TODO'|'IN_PROGRESS'|'COMPLETED'>('TODO')
async function onSubmit(formData: FormData) {
formData.append('type', type)
}
return (
<div className="w-2/3 md:w-1/3 mx-auto p-4 space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Create Todo</h1>
<p className="text-gray-500 dark:text-gray-400">Add a new todo item to your list</p>
</div>
{/* */}
<form action={onSubmit} className="space-y-4 w-full">
<Input placeholder="Enter todo title" name="title" onChange={(e) => setTitle(e.target.value)} value={title} />
<Textarea placeholder="Enter todo description" className="min-h-[100px]" name="desc" onChange={(e) => setDesc(e.target.value)} value={desc} />
<Select onValueChange={(e) => setType(e as 'TODO'|'IN_PROGRESS'|'COMPLETED')} defaultValue={type}>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="TODO">Todo</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="COMPLETED">Completed</SelectItem>
</SelectContent>
</Select>
<Button type="submit" className="w-full">
Create Todo
</Button>
</form>
</div>
)
}
Step 3
Make the directory inside that directory create file for task action like actions/task-actions.ts
'use server';
import fs from 'fs';
import { revalidatePath } from 'next/cache';
const FILE_PATH = './app/server-action/_actions/task.json'
export const createTask = async (formData: FormData): Promise<{ success: boolean }> => {
try {
const title = formData.get('title') as string
const desc = formData.get('desc') as string
const type = formData.get('type') as 'TODO' | 'IN_PROGRESS' | 'COMPLETED'
// Ensure the file exists before reading
if (!fs.existsSync(FILE_PATH)) {
fs.writeFileSync(FILE_PATH, JSON.stringify({ tasks: [] }))
}
const allTask = fs.readFileSync(FILE_PATH, 'utf-8')
const task = JSON.parse(allTask)
console.log({task})
const newId = task.tasks.length ? task.tasks.length + 1 : 0
task.tasks.push({ id: newId, title, desc, type })
fs.writeFileSync(FILE_PATH, JSON.stringify(task))
revalidatePath('/server-action')
return { success: true }
} catch (error) {
console.error('Error creating task:', error)
return { success: false }
}
}
export const getAllTasks = async(): Promise<{ tasks: { id: number; title: string; desc: string; type: 'TODO' | 'IN_PROGRESS' | 'COMPLETED' }[]}> => {
const allTask = fs.readFileSync(FILE_PATH, 'utf-8')
return JSON.parse(allTask)
}
export const deleteTaskById = async (id: number): Promise<{ success: boolean }> => {
try {
const allTask = fs.readFileSync(FILE_PATH, 'utf-8');
const task = JSON.parse(allTask);
const updatedTasks = task.tasks.filter((task: { id: number; }) => task.id !== id);
fs.writeFileSync(FILE_PATH, JSON.stringify({ tasks: updatedTasks }));
revalidatePath('/server-action')
return { success: true };
} catch (error) {
console.error('Error deleting task:', error);
return { success: false };
}
}
We will use a JSON file to manipulate tasks by applying different CRUD operations, following the same procedure used for databases. This is just for simplicity.
Step 4
Trigger the server action inside components/task-form.tsx
file to create the new task in JSON file.
"use client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useState } from "react"
import { createTask } from "../_actions/actions"
import { useToast } from "@/hooks/use-toast"
export default function TodoForm() {
const [title, setTitle] = useState("")
const [desc, setDesc] = useState("")
const [type, setType] = useState<'TODO' | 'IN_PROGRESS' | 'COMPLETED'>('TODO')
const { toast } = useToast()
async function onSubmit(formData: FormData) {
formData.append('type', type)
const res = await createTask(formData)
if (res.success) {
setTitle("")
setDesc("")
setType('TODO')
toast({
title: "Success",
description: "Task created successfully",
})
}
else {
toast({
title: "Error",
description: "Something went wrong",
variant: 'destructive'
})
}
}
return (
<div className="w-2/3 md:w-1/3 mx-auto p-4 space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Create Todo</h1>
<p className="text-gray-500 dark:text-gray-400">Add a new todo item to your list</p>
</div>
{/* */}
<form action={onSubmit} className="space-y-4 w-full">
<Input placeholder="Enter todo title" name="title" onChange={(e) => setTitle(e.target.value)} value={title} />
<Textarea placeholder="Enter todo description" className="min-h-[100px]" name="desc" onChange={(e) => setDesc(e.target.value)} value={desc} />
<Select onValueChange={(e) => setType(e as 'TODO' | 'IN_PROGRESS' | 'COMPLETED')} defaultValue={type}>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="TODO">Todo</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="COMPLETED">Completed</SelectItem>
</SelectContent>
</Select>
<Button type="submit" className="w-full">
Create Todo
</Button>
</form>
</div>
)
}
Step 5
Get all tasks in the 'page.tsx'
file and also create a task card inside the components directory, like 'components/task-card.tsx'
.
import React from 'react'
import TodoForm from './_components/todo-form'
import { getAllTasks } from './_actions/actions'
import TaskCard from './_components/task-card'
const page = async () => {
const {tasks} =await getAllTasks()
return (
<div className='min-h-screen flex py-10 items-center justify-center flex-col gap-y-3'>
<div className='flex items-center flex-wrap gap-4 justify-center'>
{
tasks.map((task) => (
<TaskCard key={task.id} title={task.title} description={task.desc} id={task.id} type={task.type} />
))
}
</div>
<TodoForm />
</div>
)
}
export default page
task-card.tsx
'use client'
import { Pencil, Trash2 } from "lucide-react"
import { deleteTaskById } from "../_actions/actions"
import { toast } from "@/hooks/use-toast"
interface TaskProps {
id:number
title: string
description: string
type: 'TODO' | 'IN_PROGRESS' | 'COMPLETED'
}
export default function Task({id, title, description, type }: TaskProps) {
const getTypeColor = (type: string) => {
switch (type) {
case "TODO":
return "bg-red-100 text-red-800"
case "IN_PROGRESS":
return "bg-green-100 text-green-800"
case "COMPLETED":
return "bg-blue-100 text-blue-800"
default:
return "bg-gray-100 text-gray-800"
}
}
return (
<div className="p-4 w-1/4 border rounded-lg hover:shadow-md transition-shadow group">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<h3 className="font-medium text-lg">{title}</h3>
<p className="mt-2 text-sm text-gray-600">{description}</p>
</div>
<div className="flex items-start gap-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium capitalize ${getTypeColor(type)}`}>{type}</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={()=>{}}
className="p-1 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded"
aria-label="Edit task"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={()=>deleteTaskById(id).then((res)=>toast({title: "Success", description: "Task deleted successfully"})).catch((err)=>toast({title: "Error", description: "Something went wrong", variant: 'destructive'}))
}
className="p-1 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded"
aria-label="Delete task"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
)
}
We also use revalidatePath
, which is used to update the cache of a specific path.
Here is the full code in the GitHub repo link here.
Happy coding! ππ
Top comments (1)
This is very helpful