SupaLaunch is a SaaS boilerplate built using Supabase and Next.js. It includes authentication, Stripe payments, Postgres database, 20+ TailwindCSS themes, emails, OpenAI API streaming, file storage and more.
Next.js Server Actions provide a powerful way to handle form submissions and data mutations without creating separate API endpoints. In this comprehensive guide, we'll explore how to implement Server Actions effectively, covering everything from basic form handling to advanced security considerations.
Server Actions are asynchronous functions that execute on the server side. They're designed to handle data mutations and form submissions in Next.js applications, offering several key benefits:
Let's start with a simple example of how to create and use a Server Action:
'use server'
export async function createItem(formData: FormData) {
const name = formData.get('name')
const description = formData.get('description')
// Perform database operation
await db.items.create({
data: {
name,
description
}
})
}
You can use this action in both Server and Client Components:
'use client'
import { createItem } from '@/app/actions'
export function CreateForm() {
return (
<form action={createItem}>
<input name="name" type="text" required />
<textarea name="description" required />
<button type="submit">Create Item</button>
</form>
)
}
Server Actions integrate seamlessly with HTML forms. Here are the key patterns:
// Direct form action
<form action={createItem}>
// With additional arguments using bind
const createItemWithId = createItem.bind(null, itemId)
<form action={createItemWithId}>
Server Actions support progressive enhancement out of the box. Forms will work even if JavaScript is disabled:
export default function ProgressiveForm() {
async function handleSubmit(formData: FormData) {
'use server'
// This works with JS disabled
await createItem(formData)
}
return <form action={handleSubmit}>...</form>
}
It's crucial to validate form data before processing it. Here's how to use Zod for type validation:
'use server'
import { z } from 'zod'
const ItemSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().min(1),
price: z.coerce.number().positive()
})
export async function createItem(formData: FormData) {
const validatedFields = ItemSchema.safeParse({
name: formData.get('name'),
description: formData.get('description'),
price: formData.get('price')
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors
}
}
// Process validated data
const { name, description, price } = validatedFields.data
}
React provides hooks to handle loading and error states:
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button disabled={pending} type="submit">
{pending ? 'Creating...' : 'Create Item'}
</button>
)
}
Implement optimistic updates using the useOptimistic
hook:
'use client'
import { useOptimistic } from 'react'
import { createItem } from '@/app/actions'
export function ItemsList({ items }) {
const [optimisticItems, addOptimisticItem] = useOptimistic(
items,
(state, newItem) => [...state, newItem]
)
async function handleCreate(formData: FormData) {
const newItem = {
name: formData.get('name'),
description: formData.get('description')
}
addOptimisticItem(newItem)
await createItem(formData)
}
return (
<div>
{optimisticItems.map(item => (
<div key={item.id}>{item.name}</div>
))}
<form action={handleCreate}>...</form>
</div>
)
}
Next.js provides APIs to revalidate the cache after mutations:
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updateItem(id: string, formData: FormData) {
await db.items.update({
where: { id },
data: {
name: formData.get('name')
}
})
// Revalidate specific path
revalidatePath('/items')
// Or revalidate by tag
revalidateTag('items')
}
Server Actions include several security features:
Encrypted Action IDs: Next.js creates encrypted, non-deterministic IDs for Server Actions.
CSRF Protection: Server Actions only accept POST requests and validate Origin headers.
Allowed Origins: Configure trusted origins in your Next.js config:
/** @type {import('next').NextConfig} */
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ['my-trusted-domain.com'],
bodySizeLimit: '2mb'
}
}
}
module.exports = {
experimental: {
serverActions: {
bodySizeLimit: '1mb' // Default
}
}
}
Always implement comprehensive error handling with specific error types and messages:
'use server'
type ActionResponse = {
success: boolean;
data?: any;
error?: {
message: string;
code?: string;
field?: string;
};
}
export async function createItem(formData: FormData): Promise<ActionResponse> {
try {
// Validate input
const name = formData.get('name')
if (!name) {
return {
success: false,
error: {
message: 'Name is required',
field: 'name'
}
}
}
// Attempt to create item
const item = await db.items.create({
data: { name }
})
return {
success: true,
data: item
}
} catch (error) {
console.error('Create item error:', error)
return {
success: false,
error: {
message: 'Failed to create item. Please try again.',
code: 'CREATE_FAILED'
}
}
}
}
Implement multiple layers of validation:
'use server'
import { z } from 'zod'
// 1. Type validation with Zod
const UserSchema = z.object({
email: z.string().email(),
age: z.number().min(18),
preferences: z.array(z.string())
})
// 2. Business logic validation
function validateBusinessRules(data: z.infer<typeof UserSchema>) {
const errors = []
if (data.preferences.length > 5) {
errors.push('Maximum 5 preferences allowed')
}
// Add more business rules
return errors
}
export async function createUser(formData: FormData) {
// 1. Parse and validate types
const validatedFields = UserSchema.safeParse({
email: formData.get('email'),
age: Number(formData.get('age')),
preferences: formData.getAll('preferences')
})
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors
}
}
// 2. Validate business rules
const businessErrors = validateBusinessRules(validatedFields.data)
if (businessErrors.length > 0) {
return {
success: false,
errors: businessErrors
}
}
// 3. Proceed with creation
try {
// Create user
} catch (error) {
// Handle errors
}
}
Implement strategic cache invalidation:
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updateUserProfile(userId: string, formData: FormData) {
try {
await db.users.update({
where: { id: userId },
data: {
name: formData.get('name'),
bio: formData.get('bio')
}
})
// Revalidate specific paths
revalidatePath(`/users/${userId}`) // User profile page
revalidatePath('/users') // Users list page
// Revalidate related tags
revalidateTag(`user-${userId}`)
revalidateTag('users-list')
return { success: true }
} catch (error) {
return { success: false, error }
}
}
Ensure forms work without JavaScript while providing enhanced functionality when available:
'use client'
import { useFormState } from 'react-dom'
import { createItem } from '@/app/actions'
export function EnhancedForm() {
const [state, formAction] = useFormState(createItem, {
success: false,
error: null
})
return (
<form action={formAction}>
{/* Basic form that works without JS */}
<input type="text" name="name" required />
{/* Enhanced features when JS is available */}
{state.error && (
<div className="error-message">
{state.error.message}
</div>
)}
<SubmitButton />
</form>
)
}
Implement comprehensive security measures:
'use server'
import { headers } from 'next/headers'
import { validateCSRFToken } from '@/lib/security'
export async function secureAction(formData: FormData) {
// 1. Validate CSRF token
const headersList = headers()
const token = headersList.get('x-csrf-token')
if (!await validateCSRFToken(token)) {
throw new Error('Invalid CSRF token')
}
// 2. Rate limiting
const ip = headersList.get('x-forwarded-for')
if (await isRateLimited(ip)) {
throw new Error('Too many requests')
}
// 3. Input sanitization
const sanitizedData = sanitizeInput(formData)
// 4. Authorization check
const user = await getCurrentUser()
if (!user.hasPermission('create:items')) {
throw new Error('Unauthorized')
}
// Proceed with action
// ...
}
Optimize server actions for better performance:
'use server'
export async function optimizedAction(formData: FormData) {
// 1. Batch database operations
const batch = await db.batch([
['set', 'key1', formData.get('value1')],
['set', 'key2', formData.get('value2')]
])
// 2. Parallel processing when possible
const [result1, result2] = await Promise.all([
processData1(formData),
processData2(formData)
])
// 3. Efficient cache invalidation
revalidateTag('affected-data')
}
Implement comprehensive testing for server actions:
import { createItem } from '@/app/actions'
describe('Server Actions', () => {
it('should handle valid input correctly', async () => {
const formData = new FormData()
formData.append('name', 'Test Item')
const result = await createItem(formData)
expect(result.success).toBe(true)
})
it('should handle invalid input appropriately', async () => {
const formData = new FormData()
const result = await createItem(formData)
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
it('should handle server errors gracefully', async () => {
// Test error scenarios
})
})
Next.js Server Actions provide a powerful and secure way to handle form submissions and data mutations. By following the patterns and best practices outlined in this guide, you can build robust applications with excellent user experience and strong security.
Key takeaways: