Look, I've been wrestling with Next.js Server Actions for months. The official docs? They show you a basic form. Stack Overflow? Mostly people confused about the same things. So after building three production apps and fixing countless bugs, I'm sharing what actually works.
This isn't another "hello world" tutorial. We're building a real CRM that handles edge cases, doesn't break under load, and passes security audits. Plus, I'll show you the performance numbers that'll make your API routes look embarrassing.
Why I Switched From API Routes (And Why You Should Too)
Six months ago, I was skeptical. Server Actions felt like magic—and I don't trust magic in production code. But after rewriting our customer dashboard and seeing 68% faster response times, I'm convinced.
Here's what changed my mind:
Before (API Routes Hell):
// This was my life. Three files for one simple operation.
// pages/api/contacts.js - The API layer nobody asked for
export default function handler(req, res) {
if (req.method === 'POST') {
const { name, email } = req.body
// Validate, sanitize, handle errors... 50 lines later
res.json({ success: true })
}
}
// Client component - More boilerplate
const response = await fetch('/api/contacts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email })
})
if (!response.ok) {
// Handle HTTP errors manually
throw new Error('Network error')
}
After (Server Actions Reality):
// One file. One function. Actually readable.
'use server'
export async function createContact(formData) {
const name = formData.get('name')
const email = formData.get('email')
return await db.contact.create({
data: { name, email }
})
}
// Client just calls it directly
await createContact(formData)
The difference hit me when our mobile users stopped complaining about slow forms. Turns out, eliminating HTTP overhead actually matters.
The CRM That Taught Me Everything
Instead of toy examples, let's build something real. A contact management system that handles the messy stuff production apps actually face:
Form validation that doesn't suck
Error handling that works on mobile
Performance that scales beyond 10 users
Security that passes audits
Getting Started (The Right Way)
# Skip the tutorial hell, go straight to what works
npx create-next-app@latest crm-server-actions --typescript --tailwind --eslint --app
cd crm-server-actions
npm install prisma @prisma/client zod
I use SQLite for local dev because it's simple. PostgreSQL for production because it's not.
// prisma/schema.prisma - Keep it simple
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Contact {
id String @id @default(cuid())
name String
email String @unique
company String?
phone String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
npx prisma generate && npx prisma db push
The Prisma Setup That Actually Works
Don't copy-paste the client setup from tutorials. They all miss the connection pooling issue:
// lib/prisma.js - This prevents connection spam
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis
export const prisma = globalForPrisma.prisma || new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query'] : [],
})
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
Server Actions That Don't Break in Production
Here's where most tutorials stop and real apps begin. Production code handles errors, validates input, and doesn't trust users.
// app/actions.js - The version that survives contact with users
'use server'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
// Never trust user input. Ever.
const ContactSchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name too long'),
email: z.string().email('Invalid email format').max(255),
company: z.string().max(100).optional(),
phone: z.string().max(20).optional(),
notes: z.string().max(500).optional(),
})
export async function createContact(formData) {
'use server'
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
company: formData.get('company') || undefined,
phone: formData.get('phone') || undefined,
notes: formData.get('notes') || undefined,
}
try {
// Validation first. Always.
const validatedData = ContactSchema.parse(rawData)
// Check duplicates before hitting the database
const existingContact = await prisma.contact.findUnique({
where: { email: validatedData.email }
})
if (existingContact) {
return { error: 'Contact with this email already exists' }
}
const contact = await prisma.contact.create({
data: validatedData
})
// This is crucial - cache invalidation that actually works
revalidatePath('/contacts')
revalidatePath('/')
return { success: true, contact }
} catch (error) {
// Zod validation errors
if (error instanceof z.ZodError) {
return { error: error.errors[0].message }
}
// Log for debugging, return generic message for users
console.error('Contact creation failed:', error)
return { error: 'Failed to create contact. Please try again.' }
}
}
export async function getContacts(searchTerm = '') {
'use server'
try {
const contacts = await prisma.contact.findMany({
where: searchTerm ? {
OR: [
{ name: { contains: searchTerm, mode: 'insensitive' } },
{ email: { contains: searchTerm, mode: 'insensitive' } },
{ company: { contains: searchTerm, mode: 'insensitive' } }
]
} : {},
orderBy: { createdAt: 'desc' },
take: 50 // Don't be the developer who crashes prod with SELECT *
})
return contacts
} catch (error) {
console.error('Failed to fetch contacts:', error)
return [] // Fail gracefully
}
}
// Update and delete follow the same pattern...
export async function updateContact(id, formData) {
'use server'
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
company: formData.get('company') || undefined,
phone: formData.get('phone') || undefined,
notes: formData.get('notes') || undefined,
}
try {
const validatedData = ContactSchema.parse(rawData)
const contact = await prisma.contact.update({
where: { id },
data: validatedData
})
revalidatePath('/contacts')
return { success: true, contact }
} catch (error) {
if (error instanceof z.ZodError) {
return { error: error.errors[0].message }
}
console.error('Failed to update contact:', error)
return { error: 'Failed to update contact' }
}
}
export async function deleteContact(id) {
'use server'
try {
await prisma.contact.delete({
where: { id }
})
revalidatePath('/contacts')
return { success: true }
} catch (error) {
console.error('Failed to delete contact:', error)
return { error: 'Failed to delete contact' }
}
}
The Form Component That Handles Real Users
Users will try to break your forms. They'll submit empty data, mash buttons, and lose internet connection mid-request. This form handles it:
// app/components/ContactForm.js - Battle-tested form handling
'use client'
import { useState, useTransition } from 'react'
import { createContact, updateContact } from '@/app/actions'
export default function ContactForm({ contact = null, onSuccess }) {
const [isPending, startTransition] = useTransition()
const [message, setMessage] = useState({ text: '', type: '' })
const isEditing = Boolean(contact)
async function handleSubmit(formData) {
startTransition(async () => {
try {
const result = isEditing
? await updateContact(contact.id, formData)
: await createContact(formData)
if (result.success) {
setMessage({
text: isEditing ? 'Contact updated!' : 'Contact created!',
type: 'success'
})
if (!isEditing) {
document.getElementById('contact-form').reset()
}
onSuccess?.(result.contact)
// Clear success message after 3 seconds
setTimeout(() => setMessage({ text: '', type: '' }), 3000)
} else {
setMessage({ text: result.error, type: 'error' })
}
} catch (error) {
setMessage({ text: 'Something went wrong. Please try again.', type: 'error' })
}
})
}
return (
<div className="max-w-md mx-auto bg-white p-6 rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-6 text-gray-900">
{isEditing ? 'Edit Contact' : 'Add Contact'}
</h2>
<form id="contact-form" action={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Full Name *
</label>
<input
type="text"
id="name"
name="name"
defaultValue={contact?.name || ''}
required
disabled={isPending}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="John Doe"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email Address *
</label>
<input
type="email"
id="email"
name="email"
defaultValue={contact?.email || ''}
required
disabled={isPending}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="*Emails are not allowed*"
/>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-1">
Company
</label>
<input
type="text"
id="company"
name="company"
defaultValue={contact?.company || ''}
disabled={isPending}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="Acme Corp"
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-1">
Phone Number
</label>
<input
type="tel"
id="phone"
name="phone"
defaultValue={contact?.phone || ''}
disabled={isPending}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
placeholder="+1 (555) 123-4567"
/>
</div>
<div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">
Notes
</label>
<textarea
id="notes"
name="notes"
defaultValue={contact?.notes || ''}
disabled={isPending}
rows="3"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed resize-vertical"
placeholder="Additional details about this contact..."
/>
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium py-3 px-4 rounded-md transition-colors duration-200 disabled:cursor-not-allowed"
>
{isPending
? (isEditing ? 'Updating...' : 'Creating...')
: (isEditing ? 'Update Contact' : 'Create Contact')
}
</button>
{message.text && (
<div className={`p-3 rounded-md border ${
message.type === 'success'
? 'bg-green-50 text-green-800 border-green-200'
: 'bg-red-50 text-red-800 border-red-200'
}`}>
{message.text}
</div>
)}
</form>
</div>
)
}
The Contact List That Doesn't Break
Lists seem simple until you need to handle deletions, loading states, and empty states. Here's what works:
// app/components/ContactsList.js - Production-ready list component
'use client'
import { useState, useTransition } from 'react'
import { deleteContact } from '@/app/actions'
export default function ContactsList({ initialContacts }) {
const [contacts, setContacts] = useState(initialContacts)
const [isPending, startTransition] = useTransition()
const [deletingId, setDeletingId] = useState(null)
const handleDelete = (id, name) => {
// Confirm before destructive actions. Always.
if (!confirm(`Delete ${name}? This can't be undone.`)) return
setDeletingId(id)
startTransition(async () => {
const result = await deleteContact(id)
if (result.success) {
setContacts(contacts.filter(contact => contact.id !== id))
} else {
alert('Failed to delete contact. Please try again.')
}
setDeletingId(null)
})
}
return (
<div className="bg-white shadow-md rounded-lg overflow-hidden">
<div className="px-6 py-4 bg-gray-50 border-b">
<h3 className="text-lg font-semibold text-gray-900">
Contacts ({contacts.length})
</h3>
</div>
{contacts.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<div className="w-12 h-12 mx-auto mb-4 bg-gray-200 rounded-full flex items-center justify-center">
<span className="text-2xl"></span>
</div>
<p className="text-lg font-medium">No contacts yet</p>
<p className="text-sm">Add your first contact using the form above.</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{contacts.map((contact) => (
<div key={contact.id} className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<h4 className="text-lg font-medium text-gray-900 truncate">
{contact.name}
</h4>
<p className="text-sm text-gray-600 truncate">{contact.email}</p>
{contact.company && (
<p className="text-sm text-gray-600 truncate mt-1">
{contact.company}
</p>
)}
{contact.phone && (
<p className="text-sm text-gray-600 mt-1">
{contact.phone}
</p>
)}
{contact.notes && (
<p className="text-sm text-gray-500 mt-2 line-clamp-2">
{contact.notes}
</p>
)}
<p className="text-xs text-gray-400 mt-3">
Added {new Date(contact.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</p>
</div>
<button
onClick={() => handleDelete(contact.id, contact.name)}
disabled={deletingId === contact.id}
className="ml-4 px-3 py-1 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{deletingId === contact.id ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}
The Performance Numbers That Convinced Me
I'm naturally skeptical of marketing claims, so I benchmarked everything myself. Here's what I found after testing both approaches with 1,000+ operations:
Real Performance Comparison

The bundle size difference hit me hardest. Less JavaScript means faster page loads, especially on mobile.
Word Limit Constraint, Read the remaining part on our blog