Server Actions for Forms
Your contact form works great—until you check the network tab and see your API key in the request payload. Or a user on a slow connection submits before JavaScript loads and nothing happens.
Server Actions fix both: mutations run on the server (no exposed secrets), and forms work with or without JavaScript. Type-safe, progressively enhanced form handling.
Outcome
A working form that posts via Server Action with validation and error surfaces.
Prerequisites
This lesson uses Zod for runtime validation. Zod is a TypeScript-first schema validation library that parses data and returns type-safe results.
Install it in the web app before starting:
pnpm add zod --filter @repo/webOr navigate to the app directory first:
cd apps/web
pnpm add zodFast Track
- Create a Server Action with input validation (zod).
- Add a form that posts to it.
- Handle success and error states.
Hands-On Exercise 2.8
Build secure form handling with Server Actions and progressive enhancement.
Requirements:
- Validate inputs server-side with Zod schema.
- Return typed success/error payloads.
- Render errors inline without leaking stack traces.
- Add loading states with
useFormStatus.
Implementation hints:
- Progressive enhancement: Forms work without JavaScript enabled; HTML form submission fallback.
- useActionState: Manages form submission state and server responses in Client Components (replaces deprecated useFormState in React 19).
- useFormStatus: Provides loading state for submit buttons (pending state).
- Zod validation: Use schema validation for type-safe input checking on the server.
- Type safety: Full TypeScript support between client and server boundaries.
- No API routes needed: Server Actions handle mutations directly, no need for separate /api endpoints.
- Keep secrets server-side; never send API keys to the client.
- Add correlation logging where useful.
- Server Actions run on the server but can be called from Client Components.
Try It
-
Test valid submission:
# Submit form with valid data # Browser shows: "Message sent successfully!"Expected response:
{ "success": true, "message": "Message sent successfully!" } -
Test validation errors:
# Submit form with invalid email # Browser shows: "Invalid email address"Expected response:
{ "errors": { "email": ["Invalid email address"], "message": ["Message must be at least 10 characters"] } } -
Verify progressive enhancement:
- Disable JavaScript in DevTools (Settings → Debugger → Disable JavaScript)
- Submit form - should still work (page refresh with result)
- Re-enable JavaScript - form submits without page refresh
Commit & Deploy
git add -A
git commit -m "feat(core): add Server Action form with validation"
git push -u origin feat/core-server-actions-formDone-When
- Navigate to
/contact, fill form with valid data, submit: "Message sent successfully!" appears - Submit with invalid email (e.g., "notanemail"): "Invalid email address" error appears inline
- Submit with short message (under 10 chars): "Message must be at least 10 characters" error appears
- During submission: button shows "Submitting..." and is disabled (loading state)
- Disable JavaScript in DevTools, submit form: still works (page refreshes with result)
- View page source: no API keys or secrets visible in HTML
Solution
Solution
Install Dependencies
Server Actions use Zod for runtime validation. Install it first:
pnpm add zodServer Action with Zod Validation
'use server'
import { z } from 'zod'
// Define validation schema
const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
})
// Type for form state
type FormState = {
success?: boolean
message?: string
errors?: {
name?: string[]
email?: string[]
message?: string[]
}
}
export async function submitContactForm(
prevState: FormState,
formData: FormData
): Promise<FormState> {
// Extract form data
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
}
// Validate with Zod
const validatedFields = contactSchema.safeParse(rawData)
// Return validation errors if any
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
// Simulate API call or database operation
try {
// In production: await db.contacts.create(validatedFields.data)
// In production: await sendEmail(validatedFields.data)
// biome-ignore lint/suspicious/noConsole: Demo logging for development
console.log('Contact form submitted:', validatedFields.data)
return {
success: true,
message: 'Message sent successfully!',
}
} catch (error) {
// biome-ignore lint/suspicious/noConsole: Error logging for debugging
console.error('Contact form error:', error)
return {
message: 'Failed to send message. Please try again.',
}
}
}Form Component with useActionState
'use client'
import { useActionState } from 'react'
import { submitContactForm } from '@/app/actions/contact'
import { SubmitButton } from '@/app/ui/submit-button'
const initialState = {
message: '',
}
export default function ContactPage() {
const [state, formAction, pending] = useActionState(
submitContactForm,
initialState
)
return (
<div className="mx-auto max-w-md p-6">
<h1 className="mb-4 text-2xl font-bold">Contact Us</h1>
<form action={formAction} className="space-y-4">
{/* Name field */}
<div>
<label htmlFor="name" className="mb-1 block text-sm font-medium">
Name
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full rounded-md border px-3 py-2"
/>
{state?.errors?.name && (
<p className="mt-1 text-sm text-red-600" aria-live="polite">
{state.errors.name[0]}
</p>
)}
</div>
{/* Email field */}
<div>
<label htmlFor="email" className="mb-1 block text-sm font-medium">
Email
</label>
<input
type="email"
id="email"
name="email"
required
className="w-full rounded-md border px-3 py-2"
/>
{state?.errors?.email && (
<p className="mt-1 text-sm text-red-600" aria-live="polite">
{state.errors.email[0]}
</p>
)}
</div>
{/* Message field */}
<div>
<label htmlFor="message" className="mb-1 block text-sm font-medium">
Message
</label>
<textarea
id="message"
name="message"
rows={4}
required
className="w-full rounded-md border px-3 py-2"
/>
{state?.errors?.message && (
<p className="mt-1 text-sm text-red-600" aria-live="polite">
{state.errors.message[0]}
</p>
)}
</div>
{/* Success message */}
{state?.success && (
<p className="font-medium text-green-600" aria-live="polite">
{state.message}
</p>
)}
{/* Generic error message */}
{state?.message && !state?.success && (
<p className="text-red-600" aria-live="polite">
{state.message}
</p>
)}
{/* Submit button with loading state */}
<SubmitButton />
</form>
</div>
)
}Submit Button with useFormStatus
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button
type="submit"
disabled={pending}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{pending ? 'Submitting...' : 'Send Message'}
</button>
)
}Key Patterns
-
Server Action signature with useActionState:
async function action(prevState: State, formData: FormData): Promise<State>- First param: previous state from
useActionState - Second param: FormData from the form submission
- Return: new state object
- First param: previous state from
-
Error handling approach:
- Return errors as state (don't throw)
- Use
safeParsefor validation - Flatten Zod errors:
error.flatten().fieldErrors
-
Progressive enhancement:
- Forms work without JavaScript
useActionStateenhances with inline errors and no page refreshuseFormStatusadds loading states when JS is available
Invalidating Cache After Mutations
When a Server Action mutates data, invalidate related cache entries so users see fresh content:
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProduct(formData: FormData) {
const id = formData.get('id') as string
const name = formData.get('name') as string
const price = formData.get('price') as string
// 1. Mutate data
await db.products.update({
where: { id },
data: { name, price: parseFloat(price) }
})
// 2. Invalidate cache (Next.js 16.1.x requires second argument)
revalidateTag(`product-${id}`, 'max') // Specific product
revalidateTag('products', 'max') // Product list
return { success: true }
}The pattern:
- Mutate: Update database/API
- Invalidate: Call
revalidateTag()for affected cache entries - Revalidate: Next request gets fresh data
References
- https://nextjs.org/docs/app/getting-started/mutating-data
- https://nextjs.org/docs/app/guides/forms
- https://zod.dev/ - Zod documentation
- https://react.dev/reference/react/useActionState - React useActionState hook
- https://react.dev/reference/react-dom/hooks/useFormStatus - React useFormStatus hook
Was this helpful?