Next.js Server Actions with Supabase: Complete Production Guide
Complete guide to Next.js Server Actions with Supabase. Learn validation, error handling, optimistic updates, and production patterns for type-safe forms.
Next.js Server Actions with Supabase: Complete Production Guide#
Server Actions fundamentally changed how we build forms in Next.js. Instead of creating API routes, managing fetch calls, and handling loading states manually, you write async functions that run on the server and call them directly from components.
Combined with Supabase, Server Actions provide a powerful pattern for building type-safe, progressively enhanced applications that work without JavaScript while delivering modern UX when it's available.
This guide covers everything you need to build production-ready Server Actions with Supabase.
Prerequisites#
- Next.js 14+ with App Router
- Supabase project configured
- TypeScript (recommended)
- Basic understanding of React Server Components
Why Server Actions with Supabase#
Traditional approach requires three separate files:
// app/api/posts/route.ts
export async function POST(request: Request) {
const body = await request.json()
// validation, auth, database logic
}
// components/PostForm.tsx
async function handleSubmit(e) {
e.preventDefault()
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(data)
})
}
Server Actions collapse this into a single function:
// actions/posts.ts
'use server'
export async function createPost(formData: FormData) {
const supabase = createClient()
// validation, auth, database logic
}
// components/PostForm.tsx
<form action={createPost}>
Benefits:
- No API route boilerplate
- Automatic serialization
- Progressive enhancement (works without JS)
- Type-safe by default
- Simpler error handling
- Built-in revalidation
Basic Server Action Setup#
Create a dedicated actions directory:
// actions/posts.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const supabase = await createClient()
// Extract form data
const title = formData.get('title') as string
const content = formData.get('content') as string
// Get authenticated user
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return { error: 'Unauthorized' }
}
// Insert into database
const { data, error } = await supabase
.from('posts')
.insert({
title,
content,
user_id: user.id
})
.select()
.single()
if (error) {
return { error: error.message }
}
// Revalidate the posts page
revalidatePath('/posts')
return { success: true, data }
}
Use in a component:
// components/PostForm.tsx
import { createPost } from '@/actions/posts'
export function PostForm() {
return (
<form action={createPost}>
<input type="text" name="title" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</form>
)
}
This works without JavaScript. When JS loads, Next.js intercepts the form submission and calls the action via fetch.
Type-Safe Validation with Zod#
Never trust form data. Always validate:
// actions/posts.ts
'use server'
import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
const PostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10).max(5000),
published: z.boolean().default(false)
})
export type PostFormState = {
errors?: {
title?: string[]
content?: string[]
published?: string[]
_form?: string[]
}
success?: boolean
}
export async function createPost(
prevState: PostFormState,
formData: FormData
): Promise<PostFormState> {
// Parse and validate
const validatedFields = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on'
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors
}
}
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return {
errors: {
_form: ['You must be logged in to create a post']
}
}
}
const { error } = await supabase
.from('posts')
.insert({
...validatedFields.data,
user_id: user.id
})
if (error) {
return {
errors: {
_form: [error.message]
}
}
}
revalidatePath('/posts')
return { success: true }
}
Display validation errors with useFormState:
// components/PostForm.tsx
'use client'
import { useFormState } from 'react-dom'
import { createPost } from '@/actions/posts'
export function PostForm() {
const [state, formAction] = useFormState(createPost, {})
return (
<form action={formAction}>
<div>
<label htmlFor="title">Title</label>
<input
type="text"
id="title"
name="title"
required
/>
{state.errors?.title && (
<p className="text-red-500">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
required
/>
{state.errors?.content && (
<p className="text-red-500">{state.errors.content[0]}</p>
)}
</div>
<div>
<label>
<input type="checkbox" name="published" />
Publish immediately
</label>
</div>
{state.errors?._form && (
<p className="text-red-500">{state.errors._form[0]}</p>
)}
{state.success && (
<p className="text-green-500">Post created successfully!</p>
)}
<button type="submit">Create Post</button>
</form>
)
}
Loading States and Pending UI#
Use useFormStatus to show loading states:
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { createPost } from '@/actions/posts'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
)
}
export function PostForm() {
const [state, formAction] = useFormState(createPost, {})
return (
<form action={formAction}>
{/* form fields */}
<SubmitButton />
</form>
)
}
useFormStatus must be called from a component inside the form, not the form component itself.
Optimistic Updates#
Provide instant feedback with useOptimistic:
'use client'
import { useOptimistic } from 'react'
import { useFormState } from 'react-dom'
import { createPost } from '@/actions/posts'
type Post = {
id: string
title: string
content: string
created_at: string
}
export function PostList({ initialPosts }: { initialPosts: Post[] }) {
const [optimisticPosts, addOptimisticPost] = useOptimistic(
initialPosts,
(state, newPost: Post) => [...state, newPost]
)
const [state, formAction] = useFormState(createPost, {})
async function handleSubmit(formData: FormData) {
// Add optimistic post immediately
addOptimisticPost({
id: crypto.randomUUID(),
title: formData.get('title') as string,
content: formData.get('content') as string,
created_at: new Date().toISOString()
})
// Call server action
await formAction(formData)
}
return (
<>
<form action={handleSubmit}>
{/* form fields */}
</form>
<div>
{optimisticPosts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
</>
)
}
When the Server Action completes, React automatically reconciles the optimistic state with the real data.
File Uploads with Server Actions#
Handle file uploads to Supabase Storage:
// actions/uploads.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function uploadAvatar(formData: FormData) {
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return { error: 'Unauthorized' }
}
const file = formData.get('avatar') as File
if (!file || file.size === 0) {
return { error: 'No file provided' }
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return { error: 'Invalid file type. Use JPEG, PNG, or WebP' }
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
return { error: 'File too large. Maximum size is 5MB' }
}
// Generate unique filename
const fileExt = file.name.split('.').pop()
const fileName = `${user.id}-${Date.now()}.${fileExt}`
// Upload to Supabase Storage
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, file, {
cacheControl: '3600',
upsert: false
})
if (uploadError) {
return { error: uploadError.message }
}
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName)
// Update user profile
const { error: updateError } = await supabase
.from('profiles')
.update({ avatar_url: publicUrl })
.eq('id', user.id)
if (updateError) {
return { error: updateError.message }
}
revalidatePath('/profile')
return { success: true, url: publicUrl }
}
Form component:
'use client'
import { useFormState } from 'react-dom'
import { uploadAvatar } from '@/actions/uploads'
export function AvatarUploadForm() {
const [state, formAction] = useFormState(uploadAvatar, {})
return (
<form action={formAction}>
<input
type="file"
name="avatar"
accept="image/jpeg,image/png,image/webp"
required
/>
{state.error && (
<p className="text-red-500">{state.error}</p>
)}
{state.success && (
<p className="text-green-500">Avatar uploaded successfully!</p>
)}
<button type="submit">Upload Avatar</button>
</form>
)
}
Revalidation Strategies#
Server Actions can trigger cache revalidation:
import { revalidatePath, revalidateTag } from 'next/cache'
// Revalidate specific path
revalidatePath('/posts')
// Revalidate all posts pages
revalidatePath('/posts', 'layout')
// Revalidate by cache tag
revalidateTag('posts')
Use tags for fine-grained control:
// Fetch with cache tag
const { data } = await supabase
.from('posts')
.select()
.then(res => {
// Tag this data
return res
})
// In Server Action
revalidateTag('posts')
Error Handling Patterns#
Structured error handling:
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string; field?: string }
export async function createPost(
formData: FormData
): Promise<ActionResult<Post>> {
try {
const supabase = await createClient()
// Validation
const title = formData.get('title') as string
if (!title || title.length < 3) {
return {
success: false,
error: 'Title must be at least 3 characters',
field: 'title'
}
}
// Auth check
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return {
success: false,
error: 'You must be logged in'
}
}
// Database operation
const { data, error } = await supabase
.from('posts')
.insert({ title, user_id: user.id })
.select()
.single()
if (error) {
// Handle specific Postgres errors
if (error.code === '23505') {
return {
success: false,
error: 'A post with this title already exists',
field: 'title'
}
}
return {
success: false,
error: 'Failed to create post'
}
}
revalidatePath('/posts')
return { success: true, data }
} catch (error) {
console.error('Unexpected error:', error)
return {
success: false,
error: 'An unexpected error occurred'
}
}
}
Common Pitfalls#
1. Forgetting 'use server' Directive#
Server Actions must have 'use server' at the top of the file or function:
// ❌ Missing directive
export async function createPost(formData: FormData) {
// This won't work
}
// ✅ File-level directive
'use server'
export async function createPost(formData: FormData) {
// Works
}
// ✅ Function-level directive
export async function createPost(formData: FormData) {
'use server'
// Works
}
2. Using Client-Only APIs#
Server Actions run on the server. No window, localStorage, or browser APIs:
// ❌ Won't work
export async function saveData(formData: FormData) {
'use server'
localStorage.setItem('key', 'value') // Error!
}
3. Not Handling Race Conditions#
Multiple rapid submissions can cause issues:
'use client'
import { useFormState } from 'react-dom'
import { useState, useTransition } from 'react'
export function PostForm() {
const [isPending, startTransition] = useTransition()
const [state, formAction] = useFormState(createPost, {})
function handleSubmit(formData: FormData) {
startTransition(() => {
formAction(formData)
})
}
return (
<form action={handleSubmit}>
{/* fields */}
<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}
4. Returning Non-Serializable Data#
Server Actions can only return JSON-serializable data:
// ❌ Can't return Date objects
export async function getPost() {
'use server'
return {
created_at: new Date() // Error!
}
}
// ✅ Return ISO strings
export async function getPost() {
'use server'
return {
created_at: new Date().toISOString()
}
}
5. Missing Revalidation#
Changes won't appear without revalidation:
export async function updatePost(id: string, formData: FormData) {
'use server'
const supabase = await createClient()
await supabase.from('posts').update(data).eq('id', id)
// ❌ Forgot to revalidate
return { success: true }
}
// ✅ Always revalidate
export async function updatePost(id: string, formData: FormData) {
'use server'
const supabase = await createClient()
await supabase.from('posts').update(data).eq('id', id)
revalidatePath(`/posts/${id}`)
revalidatePath('/posts')
return { success: true }
}
Production Patterns#
Rate Limiting#
Protect Server Actions from abuse:
// lib/rate-limit.ts
import { createClient } from '@/lib/supabase/server'
export async function checkRateLimit(
userId: string,
action: string,
limit: number,
windowMs: number
): Promise<boolean> {
const supabase = await createClient()
const windowStart = new Date(Date.now() - windowMs)
const { count } = await supabase
.from('rate_limits')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('action', action)
.gte('created_at', windowStart.toISOString())
if (count && count >= limit) {
return false
}
await supabase.from('rate_limits').insert({
user_id: userId,
action,
created_at: new Date().toISOString()
})
return true
}
// actions/posts.ts
export async function createPost(formData: FormData) {
'use server'
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: 'Unauthorized' }
}
// 5 posts per hour
const allowed = await checkRateLimit(user.id, 'create_post', 5, 60 * 60 * 1000)
if (!allowed) {
return { error: 'Rate limit exceeded. Try again later.' }
}
// Continue with post creation
}
Audit Logging#
Track all mutations:
async function logAction(
userId: string,
action: string,
resourceType: string,
resourceId: string,
metadata?: Record<string, any>
) {
const supabase = await createClient()
await supabase.from('audit_logs').insert({
user_id: userId,
action,
resource_type: resourceType,
resource_id: resourceId,
metadata,
created_at: new Date().toISOString()
})
}
export async function deletePost(postId: string) {
'use server'
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: 'Unauthorized' }
}
const { error } = await supabase
.from('posts')
.delete()
.eq('id', postId)
.eq('user_id', user.id)
if (error) {
return { error: error.message }
}
await logAction(user.id, 'delete', 'post', postId)
revalidatePath('/posts')
return { success: true }
}
Summary#
Server Actions with Supabase provide a powerful pattern for building modern web applications:
- Eliminate API route boilerplate
- Type-safe by default with TypeScript
- Progressive enhancement works without JavaScript
- Built-in form handling and validation
- Optimistic updates for instant feedback
- Automatic cache revalidation
Start with basic Server Actions for simple forms, then add validation, optimistic updates, and production patterns as your application grows.
[INTERNAL LINK: nextjs-app-router-complete-guide] [INTERNAL LINK: supabase-authentication-authorization] [INTERNAL LINK: nextjs-supabase-security-best-practices]
Frequently Asked Questions
Related Guides
Complete Type Safety Guide for Next.js and Supabase with TypeScript
Complete guide to type safety in Next.js with Supabase. Learn database type generation, Zod validation, type-safe queries, and production TypeScript patterns.
Optimistic UI Patterns with Next.js Server Actions and Supabase Realtime
Implement optimistic UI updates in Next.js with useOptimistic and Server Actions. Handle rollbacks, conflicts, and Supabase Realtime sync for instant-feeling interfaces.
AI Integration for Next.js + Supabase Applications
Complete guide to integrating AI capabilities into Next.js and Supabase applications. Learn OpenAI integration, chat interfaces, vector search, RAG systems,...