Forms are fundamental to web applications. With Server Actions, you can build progressively enhanced forms that work without JavaScript, provide excellent user experience, and handle complex scenarios like file uploads and multi-step flows. Server Actions eliminate the need for API routes, giving you type-safe, co-located form handling. Let's build production-ready forms!
Basic Form with Server Action
Simple Contact Form
'use server';
import { revalidatePath } from 'next/cache';
export async function submitContact(formData: FormData) {
// Extract form fields
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// Save to database
await db.contacts.create({
data: {
name,
email,
message,
createdAt: new Date(),
},
});
// Send notification email
await sendEmail({
to: 'support@example.com',
subject: `New contact from ${name}`,
body: message,
});
return { success: true };
}
// ✅ Extract fields from FormData
// ✅ Process data (save + email)
// ✅ Return responseimport { submitContact } from '@/app/actions/contact';
export default function ContactPage() {
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Contact Us</h1>
<form action={submitContact} className="space-y-6">
{/* Name field */}
<div>
<label htmlFor="name" className="block font-semibold mb-2">
Name
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Email field */}
<div>
<label htmlFor="email" className="block font-semibold mb-2">
Email
</label>
<input
type="email"
id="email"
name="email"
required
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Message field */}
<div>
<label htmlFor="message" className="block font-semibold mb-2">
Message
</label>
<textarea
id="message"
name="message"
required
rows={5}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Submit button */}
<button
type="submit"
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 transition"
>
Send Message
</button>
</form>
</div>
);
}
// ✅ action={submitContact} connects form to Server Action
// ✅ name attributes match FormData keys
// ✅ Works without JavaScript
// ✅ Accessible with labels and htmlForProgressive Enhancement in Action
This form works perfectly even with JavaScript disabled! The browser handles the submission, Server Action processes it, and the page refreshes. With JavaScript enabled, you can add loading states and client-side enhancements.
Handling Different Input Types
Text Inputs
// Server Action
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const slug = formData.get('slug') as string;
const excerpt = formData.get('excerpt') as string;
// Use values...
}
// Form
<input type="text" name="title" placeholder="Post title" />
<input type="text" name="slug" placeholder="post-slug" />
<textarea name="excerpt" placeholder="Brief description" />
// ✅ Text inputs as strings
// ✅ Textarea handled same wayCheckboxes
// Server Action
export async function createPost(formData: FormData) {
// Checkbox is 'on' if checked, absent if unchecked
const published = formData.get('published') === 'on';
const featured = formData.get('featured') === 'on';
await db.posts.create({
data: {
title: formData.get('title') as string,
published,
featured,
},
});
}
// Form
<label className="flex items-center gap-2">
<input type="checkbox" name="published" />
<span>Publish immediately</span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="featured" />
<span>Feature this post</span>
</label>
// ✅ Check with === 'on'
// ✅ Returns 'on' if checked, absent if not
// ✅ Convert to booleanRadio Buttons
// Server Action
export async function createPost(formData: FormData) {
const status = formData.get('status') as string;
// 'draft', 'published', or 'archived'
await db.posts.create({
data: {
title: formData.get('title') as string,
status,
},
});
}
// Form
<div className="space-y-2">
<label className="flex items-center gap-2">
<input type="radio" name="status" value="draft" defaultChecked />
<span>Draft</span>
</label>
<label className="flex items-center gap-2">
<input type="radio" name="status" value="published" />
<span>Published</span>
</label>
<label className="flex items-center gap-2">
<input type="radio" name="status" value="archived" />
<span>Archived</span>
</label>
</div>
// ✅ Same name for all radios
// ✅ Different values
// ✅ Only checked value submittedSelect Dropdowns
// Server Action
export async function createPost(formData: FormData) {
const category = formData.get('category') as string;
await db.posts.create({
data: {
title: formData.get('title') as string,
category,
},
});
}
// Single select
<select name="category" required className="px-4 py-2 border rounded">
<option value="">Select category</option>
<option value="tech">Technology</option>
<option value="design">Design</option>
<option value="business">Business</option>
</select>
// Multiple select
<select name="tags" multiple className="px-4 py-2 border rounded">
<option value="react">React</option>
<option value="nextjs">Next.js</option>
<option value="typescript">TypeScript</option>
</select>
// Get multiple values:
const tags = formData.getAll('tags') as string[];
// ['react', 'nextjs'] if both selected
// ✅ Single select: get()
// ✅ Multiple select: getAll()Number and Date Inputs
// Server Action
export async function createEvent(formData: FormData) {
// Numbers come as strings - parse them
const price = parseFloat(formData.get('price') as string);
const capacity = parseInt(formData.get('capacity') as string, 10);
// Dates come as strings - convert to Date
const eventDate = new Date(formData.get('eventDate') as string);
await db.events.create({
data: {
name: formData.get('name') as string,
price,
capacity,
eventDate,
},
});
}
// Form
<input type="number" name="price" step="0.01" min="0" />
<input type="number" name="capacity" min="1" />
<input type="date" name="eventDate" />
<input type="datetime-local" name="eventDateTime" />
<input type="time" name="eventTime" />
// ✅ Numbers as strings - parse with parseInt/parseFloat
// ✅ Dates as strings - convert with new Date()
// ✅ Validate parsed valuesFile Uploads
Single File Upload
'use server';
import { writeFile } from 'fs/promises';
import { join } from 'path';
export async function uploadImage(formData: FormData) {
const file = formData.get('image') as File;
if (!file) {
return { success: false, error: 'No file uploaded' };
}
// Validate file type
if (!file.type.startsWith('image/')) {
return { success: false, error: 'Only images allowed' };
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: 'File too large (max 5MB)' };
}
// Convert to buffer
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Generate unique filename
const filename = `${Date.now()}-${file.name}`;
const filepath = join(process.cwd(), 'public', 'uploads', filename);
// Save file
await writeFile(filepath, buffer);
return {
success: true,
url: `/uploads/${filename}`,
};
}
// ✅ Extract file with get() as File
// ✅ Validate type and size
// ✅ Convert to buffer and save
// ✅ Return file URL'use client';
import { uploadImage } from '@/app/actions/upload';
import { useState } from 'react';
export default function UploadPage() {
const [result, setResult] = useState<{ success: boolean; url?: string; error?: string } | null>(null);
const [preview, setPreview] = useState<string | null>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) {
setPreview(URL.createObjectURL(file));
}
}
async function handleSubmit(formData: FormData) {
const response = await uploadImage(formData);
setResult(response);
}
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Upload Image</h1>
<form action={handleSubmit} className="space-y-6">
{/* File input */}
<div>
<label className="block font-semibold mb-2">
Choose Image
</label>
<input
type="file"
name="image"
accept="image/*"
required
onChange={handleFileChange}
className="w-full"
/>
</div>
{/* Preview */}
{preview && (
<div>
<p className="font-semibold mb-2">Preview:</p>
<img src={preview} alt="Preview" className="max-w-md rounded-lg" />
</div>
)}
{/* Submit */}
<button
type="submit"
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Upload
</button>
{/* Result */}
{result && (
<div className={`p-4 rounded-lg ${result.success ? 'bg-green-100' : 'bg-red-100'}`}>
{result.success ? (
<>
<p className="text-green-800 mb-2">Upload successful!</p>
<img src={result.url} alt="Uploaded" className="max-w-md rounded" />
</>
) : (
<p className="text-red-800">{result.error}</p>
)}
</div>
)}
</form>
</div>
);
}
// ✅ File input with accept attribute
// ✅ Preview before upload
// ✅ Show uploaded image
// ✅ Error handlingMultiple File Upload
'use server';
export async function uploadMultipleImages(formData: FormData) {
// Get all files with same name
const files = formData.getAll('images') as File[];
if (files.length === 0) {
return { success: false, error: 'No files uploaded' };
}
const uploadedUrls: string[] = [];
for (const file of files) {
// Validate each file
if (!file.type.startsWith('image/')) {
continue; // Skip non-images
}
if (file.size > 5 * 1024 * 1024) {
continue; // Skip large files
}
// Save file
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`;
const filepath = join(process.cwd(), 'public', 'uploads', filename);
await writeFile(filepath, buffer);
uploadedUrls.push(`/uploads/${filename}`);
}
return {
success: true,
urls: uploadedUrls,
};
}
// Form with multiple file input:
<input
type="file"
name="images"
accept="image/*"
multiple
required
/>
// ✅ getAll() for multiple files
// ✅ Loop and process each
// ✅ Return array of URLsComplex Form Examples
Example 1: Blog Post Form
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
// Extract all fields
const title = formData.get('title') as string;
const slug = formData.get('slug') as string;
const content = formData.get('content') as string;
const excerpt = formData.get('excerpt') as string;
const category = formData.get('category') as string;
const tags = formData.getAll('tags') as string[];
const published = formData.get('published') === 'on';
const featured = formData.get('featured') === 'on';
// Handle featured image
const imageFile = formData.get('featuredImage') as File;
let imageUrl = null;
if (imageFile && imageFile.size > 0) {
// Save image and get URL
imageUrl = await saveImage(imageFile);
}
// Create post
const post = await db.posts.create({
data: {
title,
slug,
content,
excerpt,
category,
tags,
published,
featured,
featuredImage: imageUrl,
publishedAt: published ? new Date() : null,
},
});
// Revalidate blog page
revalidatePath('/blog');
// Redirect to new post
redirect(`/blog/${post.slug}`);
}
// ✅ Handles all input types
// ✅ Optional file upload
// ✅ Revalidation and redirectimport { createPost } from '@/app/actions/posts';
export default function CreatePostPage() {
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Create New Post</h1>
<form action={createPost} className="space-y-6">
{/* Title */}
<div>
<label htmlFor="title" className="block font-semibold mb-2">
Title
</label>
<input
type="text"
id="title"
name="title"
required
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
{/* Slug */}
<div>
<label htmlFor="slug" className="block font-semibold mb-2">
Slug
</label>
<input
type="text"
id="slug"
name="slug"
required
placeholder="post-url-slug"
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
{/* Content */}
<div>
<label htmlFor="content" className="block font-semibold mb-2">
Content
</label>
<textarea
id="content"
name="content"
required
rows={15}
className="w-full px-4 py-2 border rounded-lg font-mono"
/>
</div>
{/* Excerpt */}
<div>
<label htmlFor="excerpt" className="block font-semibold mb-2">
Excerpt
</label>
<textarea
id="excerpt"
name="excerpt"
rows={3}
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
{/* Category */}
<div>
<label htmlFor="category" className="block font-semibold mb-2">
Category
</label>
<select
id="category"
name="category"
required
className="w-full px-4 py-2 border rounded-lg"
>
<option value="">Select category</option>
<option value="tech">Technology</option>
<option value="design">Design</option>
<option value="business">Business</option>
</select>
</div>
{/* Tags */}
<div>
<label className="block font-semibold mb-2">Tags</label>
<div className="space-y-2">
<label className="flex items-center gap-2">
<input type="checkbox" name="tags" value="react" />
<span>React</span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="tags" value="nextjs" />
<span>Next.js</span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="tags" value="typescript" />
<span>TypeScript</span>
</label>
</div>
</div>
{/* Featured Image */}
<div>
<label htmlFor="featuredImage" className="block font-semibold mb-2">
Featured Image
</label>
<input
type="file"
id="featuredImage"
name="featuredImage"
accept="image/*"
className="w-full"
/>
</div>
{/* Options */}
<div className="space-y-2">
<label className="flex items-center gap-2">
<input type="checkbox" name="published" />
<span>Publish immediately</span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="featured" />
<span>Feature this post</span>
</label>
</div>
{/* Submit */}
<button
type="submit"
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700"
>
Create Post
</button>
</form>
</div>
);
}
// ✅ Complete post creation form
// ✅ All input types covered
// ✅ File upload included
// ✅ Works without JavaScriptExample 2: Registration Form
'use server';
import { hash } from 'bcrypt';
import { redirect } from 'next/navigation';
export async function register(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string;
const agreeToTerms = formData.get('agreeToTerms') === 'on';
// Validate passwords match
if (password !== confirmPassword) {
return {
success: false,
error: 'Passwords do not match',
};
}
// Validate terms agreement
if (!agreeToTerms) {
return {
success: false,
error: 'You must agree to the terms',
};
}
// Check if user exists
const existingUser = await db.users.findUnique({
where: { email },
});
if (existingUser) {
return {
success: false,
error: 'Email already registered',
};
}
// Hash password
const hashedPassword = await hash(password, 10);
// Create user
await db.users.create({
data: {
name,
email,
password: hashedPassword,
},
});
// Redirect to login
redirect('/login');
}
// ✅ Password validation
// ✅ Terms agreement check
// ✅ Duplicate email check
// ✅ Password hashing
// ✅ Redirect after successForms Project Structure
Organization of forms with Server Actions
Select a file or folder to see details
Form Handling Best Practices
1. Use Semantic HTML
// ✅ GOOD: Semantic, accessible form
<form action={submitForm}>
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
required
aria-describedby="email-help"
/>
<span id="email-help">We'll never share your email</span>
<button type="submit">Submit</button>
</form>
// ❌ BAD: Non-semantic, inaccessible
<div onClick={handleSubmit}>
<div>Email</div>
<div contentEditable />
<div>Submit</div>
</div>
// Use proper form elements for accessibility2. Always Use name Attributes
// ✅ GOOD: name attribute for FormData
<input name="email" type="email" />
// ❌ BAD: No name attribute
<input type="email" />
// Won't appear in FormData!
// name attribute is required for FormData extraction3. Provide Visual Feedback
// ✅ GOOD: Focus states and validation feedback
<input
type="email"
name="email"
required
className="border rounded px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
aria-invalid={hasError}
/>
{hasError && (
<span className="text-red-600 text-sm">
Please enter a valid email
</span>
)}
// Visual feedback improves UX4. Handle Both Success and Error States
// ✅ GOOD: Complete error handling
export async function submitForm(formData: FormData) {
try {
// Validate
if (!formData.get('email')) {
return { success: false, error: 'Email required' };
}
// Process
await processData(formData);
return { success: true, message: 'Success!' };
} catch (error) {
return { success: false, error: 'Something went wrong' };
}
}
// Handle all cases gracefully5. Use Proper Input Types
// ✅ GOOD: Correct input types
<input type="email" name="email" /> // Email validation
<input type="tel" name="phone" /> // Phone keyboard on mobile
<input type="url" name="website" /> // URL validation
<input type="number" name="age" /> // Number keyboard
<input type="date" name="birthdate" /> // Date picker
// ❌ BAD: Generic text for everything
<input type="text" name="email" />
<input type="text" name="phone" />
// Proper types improve UX and validationKey Takeaways
- action attribute - connects form to Server Action
- FormData - automatic data collection from form fields
- Progressive enhancement - works without JavaScript
- name attributes - required for FormData keys
- File uploads - extract with get() as File
- Multiple values - use getAll() for arrays
- Accessibility - labels, htmlFor, ARIA attributes
- Type parsing - numbers and dates come as strings
What's Next?
You've mastered form handling with Server Actions! Next, we'll explore Form Validation and Error Handling—adding robust validation with Zod, displaying field-specific errors, implementing progressive validation, and creating user-friendly error experiences. You'll build forms that guide users to success!
We'll cover schema validation, error display patterns, inline validation, and building production-grade form validation systems.
♿ Accessibility Matters
Always use semantic HTML, labels, and proper ARIA attributes. Accessible forms work for everyone—keyboard users, screen readers, and users with disabilities. Good accessibility is good UX for all users!