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.
Planning a secure form with validation? Use this prompt to design your Server Action:
<context>
I'm building a Next.js application using Server Actions for form handling.
I want to create a type-safe Server Action with proper validation, error handling, and progressive enhancement.
</context>
<specific-scenario>
Form purpose: [What does this form do? e.g., user registration, blog post creation, settings update]
Input fields:
1. [Field name and type - e.g., email (string, email format)]
2. [Field name and type - e.g., password (string, min 8 chars)]
3. [Field name and type - e.g., age (number, optional)]
Business logic:
- [What happens after validation? Database insert, API call, file upload?]
- [Any side effects? Send email, update cache, trigger webhook?]
Error scenarios:
- [What validation errors are possible?]
- [What runtime errors could occur? Database connection, API failure?]
</specific-scenario>
<questions>
1. **Zod schema:** How do I structure the Zod schema for my inputs with proper validation rules?
2. **Server Action signature:** Should I use FormData or parsed object as input?
3. **Error handling:** How do I return typed error responses that the client can display?
4. **Success response:** What should the success payload include? Redirect URL, updated data?
5. **Progressive enhancement:** How do I ensure the form works without JavaScript?
6. **useActionState:** How do I integrate with useActionState for client-side state management?
7. **useFormStatus:** How do I add loading states to the submit button?
8. **Security:** What security considerations do I need (rate limiting, CSRF protection)?
9. **Database operations:** Should I use transactions for multiple operations?
10. **Revalidation:** Should I call revalidatePath() or revalidateTag() after mutations?
</questions>
<current-attempt>
[If you have a draft, paste it here]
</current-attempt>
Provide a complete Server Action implementation with Zod validation, typed error handling, progressive enhancement support, and client-side integration using useActionState and useFormStatus. Explain security best practices and when to use revalidation.This will give you production-ready Server Action code with proper validation and error handling.
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.
Server Actions provide excellent UX with progressive enhancement. Forms work with and without JavaScript, providing a baseline experience that enhances with interactivity.
Server Actions provide full TypeScript support between client and server. Use Zod for runtime validation and TypeScript interfaces for compile-time safety.
Use v0 to scaffold the form UI (labels, inputs, error slots, disabled states). Keep logic in Server Actions; do not generate fetch calls or client validation code.
Prompt:
Create an accessible form with name and email fields inline error slots submit button and disabled loading state using Tailwind presentational only no data fetching or client validation.Open in v0: Open in v0
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>
)
}useFormStatus only works in components that are children of a <form>. That's why we extract the submit button into its own component. This pattern keeps loading state isolated and reusable.
This form works with JavaScript disabled. When JS is off, the form submits like a traditional HTML form with a full page refresh. When JS is enabled, it submits via Server Action without a page refresh, showing loading states and inline errors.
- ✅ Validation happens on the server (client-side validation is optional UX enhancement)
- ✅ Secrets and API keys stay on the server (never in Client Components)
- ✅ Generic error messages prevent information leakage
- ✅ No stack traces exposed to the client
- ✅ Use
safeParseto handle validation errors gracefully
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
In Next.js 16.1.x, revalidateTag() requires a second argument:
// ❌ Old API (breaks in 16.1.x)
revalidateTag('products')
// ✅ New API
revalidateTag('products', 'max') // Stale-while-revalidate
revalidateTag('products', { expire: 0 }) // Immediate expirationYou'll learn to add cacheTag() to your data functions in Lesson 3.1: Cache Components. That lesson covers the full caching mental model: "use cache", cacheLife(), cacheTag(), and revalidateTag().
References
- https://nextjs.org/docs/app/getting-started/updating-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?