DEV Community

Cover image for Next.js Server Actions with Supabase: Complete Production Guide
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on • Originally published at iloveblogs.blog

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)
  })
}
Enter fullscreen mode Exit fullscreen mode

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}>
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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'
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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()
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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]


Originally published at https://iloveblogs.blog

Top comments (0)