Next.js Server Actions — Forms Without APIs

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Next.js Server Actions — Forms Without APIs

Server Actions let you call server functions directly from Client Components, eliminating the need for API routes for simple form submissions.

Creating Your First Server Action

A Server Action is an async function marked with 'use server':

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function createPost(title: string, content: string) {
  // This runs on the server
  const post = await db.posts.create({ data: { title, content } })

  // Revalidate to show the new post
  revalidatePath('/blog')

  return post
}

Using Server Actions in Forms

Client Component with form:

// app/blog/new/page.tsx
'use client'

import { createPost } from '@/app/actions'

export default function NewPostPage() {
  async function handleSubmit(e) {
    e.preventDefault()

    const formData = new FormData(e.currentTarget)
    const title = formData.get('title')
    const content = formData.get('content')

    const result = await createPost(title, content)
    console.log('Post created:', result)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <button type="submit">Create Post</button>
    </form>
  )
}

Progressive Enhancement with action

Use the action prop for progressive enhancement:

// app/blog/new/page.tsx
import { createPost } from '@/app/actions'

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

This works even if JavaScript fails to load.

Form Data Binding

With action, form inputs automatically bind to Server Action parameters:

// app/actions.ts
'use server'

export async function updateProfile(formData: FormData) {
  const name = formData.get('name')
  const email = formData.get('email')
  const bio = formData.get('bio')

  await db.users.update({
    where: { id: getCurrentUserId() },
    data: { name, email, bio }
  })

  revalidatePath('/profile')
}
// app/profile/page.tsx
import { updateProfile } from '@/app/actions'

export default function ProfilePage() {
  return (
    <form action={updateProfile}>
      <input name="name" />
      <input name="email" />
      <textarea name="bio" />
      <button type="submit">Update Profile</button>
    </form>
  )
}

Handling Errors

Return errors from Server Actions:

// app/actions.ts
'use server'

export async function deletePost(postId: string) {
  try {
    const post = await db.posts.findUnique({ where: { id: postId } })

    if (!post) {
      return { error: 'Post not found' }
    }

    if (post.authorId !== getCurrentUserId()) {
      return { error: 'Not authorized' }
    }

    await db.posts.delete({ where: { id: postId } })
    revalidatePath('/blog')

    return { success: true }
  } catch (error) {
    return { error: 'Failed to delete post' }
  }
}
// app/blog/[id]/page.tsx
'use client'

import { deletePost } from '@/app/actions'
import { useState } from 'react'

export function DeletePostButton({ postId }: { postId: string }) {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')

  async function handleDelete() {
    setLoading(true)
    const result = await deletePost(postId)

    if (result.error) {
      setError(result.error)
    } else {
      // Post deleted successfully
      window.location.href = '/blog'
    }
    setLoading(false)
  }

  return (
    <div>
      {error && <p className="error">{error}</p>}
      <button onClick={handleDelete} disabled={loading}>
        {loading ? 'Deleting...' : 'Delete Post'}
      </button>
    </div>
  )
}

Server Action Validation

Validate data before processing:

// app/actions.ts
'use server'

import { z } from 'zod'

const PostSchema = z.object({
  title: z.string().min(5, 'Title too short'),
  content: z.string().min(10, 'Content too short'),
  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')
  }

  const validation = PostSchema.safeParse(data)

  if (!validation.success) {
    return { errors: validation.error.flatten().fieldErrors }
  }

  const post = await db.posts.create({ data: validation.data })
  revalidatePath('/blog')

  return { success: true, post }
}

Advanced Patterns

Optimistic Updates

'use client'

import { updatePostTitle } from '@/app/actions'
import { useState, useTransition } from 'react'

export function EditablePostTitle({ initialTitle, postId }) {
  const [optimisticTitle, setOptimisticTitle] = useState(initialTitle)
  const [isPending, startTransition] = useTransition()

  function handleChange(newTitle) {
    setOptimisticTitle(newTitle) // Update UI immediately

    startTransition(async () => {
      const result = await updatePostTitle(postId, newTitle)
      if (result.error) {
        setOptimisticTitle(initialTitle) // Revert on error
      }
    })
  }

  return (
    <input
      value={optimisticTitle}
      onChange={(e) => handleChange(e.target.value)}
      disabled={isPending}
    />
  )
}

Calling Multiple Actions

'use client'

import { createPost, sendNotification } from '@/app/actions'

export function CreateWithNotification() {
  async function handleCreate(formData: FormData) {
    const post = await createPost(
      formData.get('title'),
      formData.get('content')
    )

    if (post) {
      await sendNotification(
        getCurrentUser(),
        `New post: ${post.title}`
      )
    }
  }

  return <form action={handleCreate}>...</form>
}

Real-World Example: Comment System

// app/actions.ts
'use server'

export async function addComment(postId: string, content: string) {
  const userId = getCurrentUserId()

  if (!userId) {
    return { error: 'Must be logged in' }
  }

  if (!content.trim()) {
    return { error: 'Comment cannot be empty' }
  }

  const comment = await db.comments.create({
    data: {
      content,
      postId,
      authorId: userId,
      createdAt: new Date()
    }
  })

  revalidatePath(`/blog/${postId}`)
  return { success: true, comment }
}
// app/blog/[id]/comments.tsx
'use client'

import { addComment } from '@/app/actions'
import { useTransition } from 'react'

export function CommentForm({ postId }: { postId: string }) {
  const [isPending, startTransition] = useTransition()
  const [error, setError] = useState('')

  async function handleSubmit(e) {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)

    setError('')

    startTransition(async () => {
      const result = await addComment(postId, formData.get('content'))

      if (result.error) {
        setError(result.error)
      } else {
        e.currentTarget.reset()
      }
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <p className="error">{error}</p>}
      <textarea name="content" placeholder="Add a comment..." required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Posting...' : 'Post Comment'}
      </button>
    </form>
  )
}

FAQ

Q: Can Server Actions access the database directly? A: Yes, Server Actions run on the server, so they can access databases, call APIs, and use environment variables safely.

Q: What about CORS with Server Actions? A: There's no CORS issue because Server Actions bypass the client-side fetch restrictions—they execute server-to-server.

Q: How do I handle authentication in Server Actions? A: Use session data from your auth library. Check authentication at the start of each action.


Server Actions simplify form handling and reduce boilerplate significantly.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro