Server Actions in Next.js
Server Actions are a powerful feature in Next.js that enable you to run server-side code directly from your components. They provide a seamless way to handle form submissions, mutations, and data updates without creating API routes.
What Are Server Actions? Server Actions are asynchronous functions that execute on the server. They can be called from Server Components or Client Components, enabling progressive enhancement and better user experiences.
Creating Your First Server Action
Server Actions are defined using the 'use server' directive at the top of an async function or file:
Inline Server Action
// app/posts/create/page.tsx
export default function CreatePostPage() {
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Database operation runs on the server
await db.post.create({
data: { title, content }
});
// Redirect after successful creation
redirect('/posts');
}
return (
<form action={createPost}>
<input type="text" name="title" required />
<textarea name="content" required></textarea>
<button type="submit">Create Post</button>
</form>
);
}
Progressive Enhancement: Forms with Server Actions work even when JavaScript is disabled. The browser handles the form submission natively, and Next.js processes it on the server.
Separate Server Action Files
For better organization, create dedicated files for your Server Actions:
// app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validate input
if (!title || title.length < 3) {
throw new Error('Title must be at least 3 characters');
}
// Create post in database
const post = await db.post.create({
data: {
title,
content,
published: false
}
});
// Revalidate the posts list
revalidatePath('/posts');
// Redirect to the new post
redirect(`/posts/${post.id}`);
}
export async function deletePost(postId: string) {
await db.post.delete({
where: { id: postId }
});
revalidatePath('/posts');
}
export async function updatePost(postId: string, formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.post.update({
where: { id: postId },
data: { title, content }
});
revalidatePath(`/posts/${postId}`);
revalidatePath('/posts');
}
Using Server Actions from components:
// app/posts/create/page.tsx
import { createPost } from '@/app/actions/posts';
export default function CreatePostPage() {
return (
<form action={createPost}>
<input type="text" name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Post content" required></textarea>
<button type="submit">Create Post</button>
</form>
);
}
Server Actions in Client Components
You can use Server Actions from Client Components for interactive forms with optimistic updates:
// app/components/PostForm.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions/posts';
// Submit button that shows loading state
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
);
}
export default function PostForm() {
const [state, formAction] = useFormState(createPost, { message: '' });
return (
<form action={formAction}>
<div>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required></textarea>
</div>
{state.message && (
<p className="error">{state.message}</p>
)}
<SubmitButton />
</form>
);
}
useFormState Hook
The useFormState hook allows you to update state based on the result of a form action:
// app/actions/posts.ts
'use server';
export async function createPost(
prevState: { message: string },
formData: FormData
) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validation
if (!title || title.length < 3) {
return { message: 'Title must be at least 3 characters' };
}
if (!content || content.length < 10) {
return { message: 'Content must be at least 10 characters' };
}
try {
await db.post.create({
data: { title, content }
});
revalidatePath('/posts');
redirect('/posts');
} catch (error) {
return { message: 'Failed to create post. Please try again.' };
}
}
// app/components/CreatePostForm.tsx
'use client';
import { useFormState } from 'react-dom';
import { createPost } from '@/app/actions/posts';
export default function CreatePostForm() {
const initialState = { message: '' };
const [state, formAction] = useFormState(createPost, initialState);
return (
<form action={formAction}>
<input type="text" name="title" />
<textarea name="content"></textarea>
{state.message && (
<div className="alert">{state.message}</div>
)}
<button type="submit">Create</button>
</form>
);
}
Important: When using useFormState, your Server Action must accept two parameters: prevState (the previous state) and formData (the form data). The function must return an object that represents the new state.
useFormStatus Hook
The useFormStatus hook provides information about the form submission status:
// app/components/SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ label }: { label: string }) {
const { pending, data, method, action } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className={pending ? 'loading' : ''}
>
{pending ? 'Submitting...' : label}
</button>
);
}
// Usage in a form
// app/posts/create/page.tsx
import { createPost } from '@/app/actions/posts';
import { SubmitButton } from '@/app/components/SubmitButton';
export default function CreatePostPage() {
return (
<form action={createPost}>
<input type="text" name="title" />
<textarea name="content"></textarea>
<SubmitButton label="Create Post" />
</form>
);
}
Hook Limitation: useFormStatus must be called from a component that is rendered inside a <form>. It won't work if called in the same component that renders the form.
Handling Non-Form Actions
Server Actions can also be triggered programmatically, not just from forms:
// app/actions/posts.ts
'use server';
export async function likePost(postId: string, userId: string) {
await db.like.create({
data: {
postId,
userId
}
});
revalidatePath(`/posts/${postId}`);
return { success: true };
}
// app/components/LikeButton.tsx
'use client';
import { useState, useTransition } from 'react';
import { likePost } from '@/app/actions/posts';
export default function LikeButton({
postId,
userId,
initialLikes
}: {
postId: string;
userId: string;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
const [isPending, startTransition] = useTransition();
const handleLike = () => {
// Optimistic update
setLikes(likes + 1);
startTransition(async () => {
try {
await likePost(postId, userId);
} catch (error) {
// Revert optimistic update on error
setLikes(likes);
}
});
};
return (
<button onClick={handleLike} disabled={isPending}>
❤️ {likes} {isPending && '...'}
</button>
);
}
Validation and Error Handling
Always validate input and handle errors properly in Server Actions:
// app/actions/posts.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
// Define validation schema
const postSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters'),
content: z.string().min(10, 'Content must be at least 10 characters'),
tags: z.array(z.string()).max(5, 'Maximum 5 tags allowed')
});
export async function createPost(formData: FormData) {
// Parse and validate
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.getAll('tags')
};
const validationResult = postSchema.safeParse(rawData);
if (!validationResult.success) {
return {
success: false,
errors: validationResult.error.flatten().fieldErrors
};
}
const { title, content, tags } = validationResult.data;
try {
const post = await db.post.create({
data: {
title,
content,
tags: {
connectOrCreate: tags.map(tag => ({
where: { name: tag },
create: { name: tag }
}))
}
}
});
revalidatePath('/posts');
return {
success: true,
postId: post.id
};
} catch (error) {
console.error('Failed to create post:', error);
return {
success: false,
errors: { _form: ['Failed to create post'] }
};
}
}
Authentication in Server Actions
Verify user authentication and authorization in Server Actions:
// app/actions/posts.ts
'use server';
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
export async function deletePost(postId: string) {
// Check authentication
const session = await auth();
if (!session?.user) {
redirect('/login');
}
// Check authorization
const post = await db.post.findUnique({
where: { id: postId },
select: { authorId: true }
});
if (post?.authorId !== session.user.id) {
throw new Error('Unauthorized: You can only delete your own posts');
}
// Perform deletion
await db.post.delete({
where: { id: postId }
});
revalidatePath('/posts');
redirect('/posts');
}
Security: Always validate user permissions in Server Actions. Never trust client-side checks alone. Server Actions can be called directly, bypassing your UI.
Revalidation with Server Actions
Update cached data after mutations using revalidatePath() and revalidateTag():
// app/actions/posts.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function publishPost(postId: string) {
await db.post.update({
where: { id: postId },
data: { published: true, publishedAt: new Date() }
});
// Revalidate specific paths
revalidatePath('/posts');
revalidatePath(`/posts/${postId}`);
revalidatePath('/'); // Home page if it shows posts
// Revalidate by tag (if you tagged your fetch requests)
revalidateTag('posts');
return { success: true };
}
Cookies and Headers
Access cookies and headers in Server Actions:
// app/actions/preferences.ts
'use server';
import { cookies, headers } from 'next/headers';
export async function updateTheme(theme: string) {
// Set a cookie
cookies().set('theme', theme, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/'
});
return { success: true };
}
export async function trackAction() {
// Read headers
const headersList = headers();
const userAgent = headersList.get('user-agent');
const referer = headersList.get('referer');
// Read cookies
const sessionId = cookies().get('sessionId')?.value;
// Log or track the action
await db.analytics.create({
data: {
sessionId,
userAgent,
referer,
timestamp: new Date()
}
});
return { success: true };
}
File Uploads with Server Actions
Handle file uploads using FormData:
// app/actions/upload.ts
'use server';
import { writeFile } from 'fs/promises';
import { join } from 'path';
export async function uploadImage(formData: FormData) {
const file = formData.get('image') as File;
if (!file) {
return { success: false, error: 'No file provided' };
}
// Validate file type
if (!file.type.startsWith('image/')) {
return { success: false, error: 'File must be an image' };
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: 'File must be less than 5MB' };
}
try {
// Read file as buffer
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Generate unique filename
const filename = `${Date.now()}-${file.name}`;
const path = join(process.cwd(), 'public', 'uploads', filename);
// Write file to disk
await writeFile(path, buffer);
// Return public URL
return {
success: true,
url: `/uploads/${filename}`
};
} catch (error) {
return { success: false, error: 'Failed to upload file' };
}
}
// app/components/ImageUploadForm.tsx
'use client';
import { uploadImage } from '@/app/actions/upload';
import { useState } from 'react';
export default function ImageUploadForm() {
const [imageUrl, setImageUrl] = useState(null);
async function handleSubmit(formData: FormData) {
const result = await uploadImage(formData);
if (result.success) {
setImageUrl(result.url);
} else {
alert(result.error);
}
}
return (
<form action={handleSubmit}>
<input type="file" name="image" accept="image/*" required />
<button type="submit">Upload</button>
{imageUrl && (
<img src={imageUrl} alt="Uploaded" width={300} />
)}
</form>
);
}
Practice Exercise
Task: Build a complete todo application using Server Actions:
- Create a form to add new todos with validation (min 3 characters)
- Implement a Server Action to toggle todo completion status
- Add a delete todo Server Action with authentication check
- Implement optimistic UI updates for toggling todos
- Add loading states using useFormStatus
- Display validation errors using useFormState
- Implement proper revalidation after each action
Requirements:
- Use TypeScript with proper types
- Implement Zod validation schema
- Add error handling for all actions
- Use progressive enhancement (works without JS)
- Implement authentication checks
Bonus Challenge: Add a Server Action to bulk delete completed todos and implement a confirmation dialog with useFormState.
Summary
- Server Actions are async functions that run on the server using 'use server'
- They enable form handling without creating API routes
- Use useFormState to manage form state and display validation errors
- Use useFormStatus to show loading states during form submission
- Server Actions support progressive enhancement (work without JavaScript)
- Always validate input and check authentication/authorization
- Use revalidatePath() and revalidateTag() to update cached data
- Server Actions can be called from forms or programmatically
- Access cookies and headers using Next.js server utilities
- Handle file uploads using FormData in Server Actions