Next.js

Server Actions

45 min Lesson 8 of 40

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:

  1. Create a form to add new todos with validation (min 3 characters)
  2. Implement a Server Action to toggle todo completion status
  3. Add a delete todo Server Action with authentication check
  4. Implement optimistic UI updates for toggling todos
  5. Add loading states using useFormStatus
  6. Display validation errors using useFormState
  7. 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