Back to Blog

Next.js 14 Server Actions: A Complete Guide

Master Next.js 14 Server Actions for building full-stack applications with server-side mutations, form handling, and progressive enhancement.

Kamruzzaman

Kamruzzaman

Lead Developer

November 20, 2024
14 min read
#next.js#react#server-actions#full-stack#typescript
Next.js 14 Server Actions: A Complete Guide

Next.js 14 introduces Server Actions as a stable feature, revolutionizing how we handle server-side mutations and form submissions in React applications. This comprehensive guide covers everything you need to know.

Next.js Server Actions Flow DiagramNext.js Server Actions Flow Diagram

What Are Server Actions?

Server Actions are asynchronous functions that run on the server and can be called from both Server and Client Components. They enable you to handle form submissions and data mutations without writing API routes.

Key Benefits

  1. Simplified Data Mutations: No need for separate API endpoints
  2. Progressive Enhancement: Forms work without JavaScript
  3. Type Safety: End-to-end TypeScript support
  4. Automatic Revalidation: Built-in cache revalidation
  5. Better DX: Less boilerplate code

Basic Usage

Creating a Server Action

'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  // Validate data
  if (!title || !content) {
    throw new Error('Title and content are required')
  }

  // Database operation
  await db.post.create({
    data: { title, content }
  })

  // Revalidate cache
  revalidatePath('/posts')

  // Redirect
  redirect('/posts')
}

Using in Server Components

import { createPost } from './actions'

export default function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" type="text" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </form>
  )
}

Using in Client Components

'use client'

import { createPost } from './actions'
import { useFormStatus } from 'react-dom'

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creating...' : 'Create Post'}
    </button>
  )
}

export default function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" type="text" required />
      <textarea name="content" required />
      <SubmitButton />
    </form>
  )
}

Advanced Patterns

Form Validation with Zod

'use server'

import { z } from 'zod'

const createPostSchema = z.object({
  title: z.string().min(3).max(100),
  content: z.string().min(10),
  tags: z.array(z.string()).optional(),
})

export async function createPost(formData: FormData) {
  const data = {
    title: formData.get('title'),
    content: formData.get('content'),
    tags: formData.getAll('tags'),
  }

  // Validate
  const validatedData = createPostSchema.parse(data)

  // Process...
  await db.post.create({ data: validatedData })

  revalidatePath('/posts')
}

Error Handling

'use server'

import { z } from 'zod'

type State = {
  errors?: {
    title?: string[]
    content?: string[]
  }
  message?: string
}

export async function createPost(
  prevState: State,
  formData: FormData
): Promise<State> {
  try {
    const validatedData = createPostSchema.parse({
      title: formData.get('title'),
      content: formData.get('content'),
    })

    await db.post.create({ data: validatedData })

    revalidatePath('/posts')
    return { message: 'Post created successfully' }
  } catch (error) {
    if (error instanceof z.ZodError) {
      return {
        errors: error.flatten().fieldErrors,
        message: 'Validation failed',
      }
    }
    return { message: 'Failed to create post' }
  }
}

Using with useFormState

'use client'

import { useFormState } from 'react-dom'
import { createPost } from './actions'

const initialState = { message: '', errors: {} }

export default function CreatePostForm() {
  const [state, formAction] = useFormState(createPost, initialState)

  return (
    <form action={formAction}>
      <div>
        <input name="title" type="text" />
        {state.errors?.title && (
          <p className="error">{state.errors.title[0]}</p>
        )}
      </div>

      <div>
        <textarea name="content" />
        {state.errors?.content && (
          <p className="error">{state.errors.content[0]}</p>
        )}
      </div>

      {state.message && <p>{state.message}</p>}

      <button type="submit">Submit</button>
    </form>
  )
}

Optimistic Updates

'use client'

import { useOptimistic } from 'react'
import { addTodo } from './actions'

export default function TodoList({ todos }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo: string) => [
      ...state,
      { id: Date.now(), text: newTodo, completed: false }
    ]
  )

  async function formAction(formData: FormData) {
    const text = formData.get('todo') as string
    addOptimisticTodo(text)
    await addTodo(text)
  }

  return (
    <>
      <form action={formAction}>
        <input name="todo" type="text" />
        <button type="submit">Add</button>
      </form>

      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  )
}

Security Considerations

1. Always Validate Input

'use server'

export async function updateProfile(formData: FormData) {
  // Never trust client input
  const validated = profileSchema.parse({
    name: formData.get('name'),
    email: formData.get('email'),
  })

  // Proceed with validated data
}

2. Implement Authorization

'use server'

import { auth } from '@/lib/auth'

export async function deletePost(postId: string) {
  const session = await auth()

  if (!session?.user) {
    throw new Error('Unauthorized')
  }

  const post = await db.post.findUnique({ where: { id: postId } })

  if (post.authorId !== session.user.id) {
    throw new Error('Forbidden')
  }

  await db.post.delete({ where: { id: postId } })
}

3. Rate Limiting

'use server'

import { ratelimit } from '@/lib/redis'

export async function sendEmail(formData: FormData) {
  const ip = headers().get('x-forwarded-for') ?? 'unknown'

  const { success } = await ratelimit.limit(ip)

  if (!success) {
    throw new Error('Too many requests')
  }

  // Proceed with email sending
}

Performance Optimization

1. Partial Prerendering

// app/posts/page.tsx
export default function PostsPage() {
  return (
    <>
      {/* Static content */}
      <Header />

      {/* Dynamic content with Server Action */}
      <Suspense fallback={<Skeleton />}>
        <PostsList />
      </Suspense>
    </>
  )
}

2. Selective Revalidation

'use server'

export async function updatePost(id: string, data: PostData) {
  await db.post.update({ where: { id }, data })

  // Only revalidate affected paths
  revalidatePath(`/posts/${id}`)
  revalidatePath('/posts', 'page')  // Just the page, not layouts
}

3. Streaming Updates

'use server'

export async function processLargeFile(formData: FormData) {
  const file = formData.get('file') as File

  // Stream processing
  const stream = file.stream()
  const reader = stream.getReader()

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    // Process chunk
    await processChunk(value)
  }
}

Testing Server Actions

import { createPost } from './actions'
import { describe, it, expect, vi } from 'vitest'

vi.mock('next/cache', () => ({
  revalidatePath: vi.fn(),
}))

describe('createPost', () => {
  it('creates a post successfully', async () => {
    const formData = new FormData()
    formData.append('title', 'Test Post')
    formData.append('content', 'Test Content')

    await createPost(formData)

    const post = await db.post.findFirst({
      where: { title: 'Test Post' }
    })

    expect(post).toBeDefined()
    expect(post.content).toBe('Test Content')
  })

  it('validates input', async () => {
    const formData = new FormData()
    formData.append('title', 'ab')  // Too short

    await expect(createPost(formData)).rejects.toThrow()
  })
})

Conclusion

Server Actions in Next.js 14 provide a powerful, type-safe way to handle server-side mutations. They simplify full-stack development by eliminating the need for separate API routes while maintaining progressive enhancement and security.

Key takeaways:

  • Use 'use server' directive for server-only code
  • Leverage built-in hooks for better UX
  • Always validate and authorize
  • Implement proper error handling
  • Use selective revalidation for performance

Server Actions represent the future of full-stack React development, and mastering them will significantly improve your Next.js applications.

Kamruzzaman

About Kamruzzaman

Lead Developer

Expert in web development with years of experience building production systems and sharing knowledge with the developer community.