Server Actions Form Submission Errors in Next.js
How to diagnose and fix form submission errors when using Server Actions in Next.js App Router
Server Actions Form Submission Errors in Next.js
Introduction
Server Actions are one of the headline features of Next.js 14. They eliminate the need for API routes for form submissions, allowing you to call server-side functions directly from Client Components. However, Server Actions introduce new categories of errors that developers have not encountered before.
After implementing Server Actions for a form-heavy application, I discovered that error handling, validation, and progressive enhancement all require careful attention. This post covers every Server Action error I have encountered and how to resolve them.
Environment
- OS: Windows 11
- Node.js: v20.10.0
- Next.js: 14.0.4
- React: 18.2.0
Problem
Server Actions produced several error patterns:
Error: Actions must be async functions. Found "function submitForm()".Error: An unknown error occurred. This may be due to an error during
the rendering of a Server Component.TypeError: Cannot read properties of null (reading 'formData')Warning: The result of getServerSnapshot should be cached to avoid an infinite loopWhen the action throws an error, the entire page fails without useful feedback:
// This error is swallowed by default
async function submitForm(formData: FormData) {
const name = formData.get('name') as string
if (!name) {
throw new Error('Name is required') // No visible error to user
}
await db.user.create({ data: { name } })
}Analysis
The root causes are:
1. Missing "use server" directive. Server Actions must be defined in files with the "use server" directive, either at the top of the file or inline.
2. Server Actions cannot be used in Server Components directly for form submissions. The form itself must be a Client Component or use the HTML form action.
3. Error boundaries do not catch Server Action errors by default. You need explicit error handling within the action.
4. FormData handling differences. The FormData API behaves differently when called from a Server Component versus a Client Component.
Solution
Fix 1: Correct Server Action definition
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
interface CreateUserResult {
success: boolean
error?: string
userId?: string
}
export async function createUser(
prevState: any,
formData: FormData
): Promise {
const name = formData.get('name') as string
const email = formData.get('email') as string
// Server-side validation
if (!name || name.trim().length === 0) {
return { success: false, error: 'Name is required' }
}
if (!email || !email.includes('@')) {
return { success: false, error: 'Valid email is required' }
}
try {
const user = await db.user.create({
data: { name: name.trim(), email: email.trim() },
})
revalidatePath('/users')
return { success: true, userId: user.id }
} catch (error) {
console.error('Failed to create user:', error)
return { success: false, error: 'Failed to create user' }
}
} Fix 2: Form component with useFormState
// components/CreateUserForm.tsx
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { createUser } from '@/app/actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
)
}
export function CreateUserForm() {
const [state, formAction] = useFormState(createUser, {
success: false,
error: undefined,
})
return (
)
}Fix 3: Action with optimistic updates
// components/LikeButton.tsx
'use client'
import { useOptimistic } from 'react'
import { toggleLike } from '@/app/actions'
interface LikeButtonProps {
postId: string
initialLikes: number
isLiked: boolean
}
export function LikeButton({ postId, initialLikes, isLiked }: LikeButtonProps) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
{ likes: initialLikes, isLiked },
(state, newIsLiked: boolean) => ({
likes: state.likes + (newIsLiked ? 1 : -1),
isLiked: newIsLiked,
})
)
async function handleToggle() {
addOptimisticLike(!optimisticLikes.isLiked)
await toggleLike(postId)
}
return (
)
}Fix 4: Error handling with Error Boundaries
// app/dashboard/error.tsx
'use client'
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
Something went wrong
{error.message}
{error.digest && Error ID: {error.digest}
}
)
}Lessons Learned
Always use
useFormStatefor form Server Actions. It provides a way to pass and return state between the client and server, including error messages.Use
useFormStatusfor loading states. The pending status lets you disable buttons and show loading indicators during form submission.Return structured error responses. Do not throw errors from Server Actions. Return
{ success: false, error: 'message' }objects that the client can handle.Test Server Actions with JavaScript disabled. Server Actions are designed to work with progressive enhancement. Forms should submit even without JavaScript.
Keep Server Actions in separate files. Collocating actions with components creates circular dependency issues when both import shared utilities.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.