Next.js Server Actions with Supabase: Complete Production Guide
Developer Guide

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.

2026-03-14
18 min read
Next.js Server Actions with Supabase: Complete Production Guide

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

|

Have more questions? Contact us