e, ...options })
},
remove(name: string, options: CookieOptions) {
cookieStore.set({ name, value: '', ...options })
},
},
}
)
}
**Rationale:** The `@supabase/ssr` package handles cookie synchronization automatically. By awaiting `cookies()`, we align with Next.js 15's async cookie store, preventing hydration mismatches and ensuring RLS policies evaluate against the correct user session.
### Step 2: Action Definition & Structured Validation
Server Actions should return a predictable shape. Using Zod for validation and a discriminated union for return types eliminates runtime type errors and simplifies client-side state management.
```typescript
// actions/deliverables.ts
'use server'
import { z } from 'zod'
import { createSupabaseClient } from '@/lib/supabase/server'
import { revalidateTag } from 'next/cache'
const DeliverableSchema = z.object({
title: z.string().min(4).max(120),
description: z.string().min(10).max(2000),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
due_date: z.string().datetime().optional()
})
export type DeliverableActionState = {
errors?: Record<string, string[]>
success?: boolean
data?: { id: string; title: string }
}
export async function submitDeliverable(
previousState: DeliverableActionState,
formData: FormData
): Promise<DeliverableActionState> {
const parsed = DeliverableSchema.safeParse({
title: formData.get('title'),
description: formData.get('description'),
priority: formData.get('priority'),
due_date: formData.get('due_date') || undefined
})
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }
}
const db = await createSupabaseClient()
const { data: session } = await db.auth.getSession()
if (!session?.session) {
return { errors: { _form: ['Authentication required'] } }
}
const { data, error } = await db
.from('deliverables')
.insert({
...parsed.data,
owner_id: session.session.user.id,
status: 'pending'
})
.select('id, title')
.single()
if (error) {
return { errors: { _form: [error.message] } }
}
revalidateTag('deliverables-list')
return { success: true, data }
}
Rationale:
safeParse prevents unhandled exceptions from crashing the action.
- Returning
previousState aligns with useFormState's signature, enabling seamless error accumulation.
revalidateTag is preferred over revalidatePath because it decouples cache invalidation from URL structure, making it resilient to route refactors.
Step 3: Component Integration & Loading States
Client components should remain thin. Delegate mutation logic to the action and use React's form hooks to manage UI feedback.
// components/DeliverableForm.tsx
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { submitDeliverable } from '@/actions/deliverables'
function SubmitControl() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? 'Processing...' : 'Submit Deliverable'}
</button>
)
}
export function DeliverableForm() {
const [state, action] = useFormState(submitDeliverable, {})
return (
<form action={action} className="space-y-4">
<input name="title" placeholder="Deliverable title" required />
<textarea name="description" placeholder="Scope details" required />
<select name="priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<input name="due_date" type="datetime-local" />
{state.errors?._form && (
<p className="text-red-600">{state.errors._form[0]}</p>
)}
{state.success && (
<p className="text-green-600">Deliverable created successfully.</p>
)}
<SubmitControl />
</form>
)
}
Rationale: useFormStatus must be extracted into a child component to access the form's pending context. This pattern prevents unnecessary re-renders of the entire form during submission.
Step 4: Optimistic UI & File Attachments
For latency-sensitive workflows, useOptimistic provides immediate feedback while the server action processes. File uploads require careful boundary handling to prevent memory exhaustion.
// actions/attachments.ts
'use server'
import { createSupabaseClient } from '@/lib/supabase/server'
import { revalidateTag } from 'next/cache'
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ALLOWED_MIMES = ['image/png', 'image/jpeg', 'application/pdf']
export async function attachFileToDeliverable(
deliverableId: string,
formData: FormData
) {
const file = formData.get('attachment') as File
if (!file || file.size === 0) return { error: 'No file selected' }
if (!ALLOWED_MIMES.includes(file.type)) return { error: 'Unsupported format' }
if (file.size > MAX_FILE_SIZE) return { error: 'Exceeds 5MB limit' }
const db = await createSupabaseClient()
const { data: session } = await db.auth.getSession()
if (!session?.session) return { error: 'Unauthorized' }
const fileExt = file.name.split('.').pop()
const storagePath = `${deliverableId}/${crypto.randomUUID()}.${fileExt}`
const { error: uploadErr } = await db.storage
.from('project-assets')
.upload(storagePath, file, { upsert: false })
if (uploadErr) return { error: uploadErr.message }
const { data: urlData } = db.storage
.from('project-assets')
.getPublicUrl(storagePath)
await db.from('deliverables').update({
attachment_url: urlData.publicUrl,
updated_at: new Date().toISOString()
}).eq('id', deliverableId)
revalidateTag('deliverables-list')
return { success: true, url: urlData.publicUrl }
}
Rationale: File validation occurs before network transmission to Supabase Storage. Using crypto.randomUUID() prevents filename collisions and enumeration attacks. The action returns a structured result, allowing the client to update local state without full page reloads.
Pitfall Guide
1. Missing 'use server' Directive
Explanation: Server Actions require the directive at the top of the file or function. Without it, Next.js treats the module as client-side, causing runtime errors when accessing server-only APIs like cookies() or revalidateTag.
Fix: Always place 'use server' as the first statement in action files. Use ESLint rules to enforce this convention.
2. Singleton Supabase Client Across Requests
Explanation: Reusing a single Supabase client instance across multiple requests leaks session data and breaks Row Level Security (RLS). Each request must have isolated authentication context.
Fix: Instantiate the client inside the action or use a factory function that accepts the current request context. Never cache the client at module scope.
3. Over-Reliance on revalidatePath
Explanation: Path-based revalidation couples cache invalidation to URL structure. If you refactor routes or add dynamic segments, cache invalidation silently breaks.
Fix: Use revalidateTag for data-centric invalidation. Tag your select queries with db.from('table').select().then(res => { /* tag logic */ }) and invalidate by tag in actions.
4. Ignoring Progressive Enhancement
Explanation: Building forms that only work with JavaScript breaks accessibility and SEO. Server Actions are designed to work via standard HTTP POST when JS is disabled.
Fix: Ensure the action prop points directly to the server function. Avoid wrapping submissions in e.preventDefault() unless you're implementing optimistic updates. Test with JS disabled in DevTools.
5. Unbounded File Uploads
Explanation: Accepting files without size/MIME validation exposes the application to DoS attacks and storage quota exhaustion. Supabase Storage enforces limits, but late validation wastes bandwidth.
Fix: Validate file.size and file.type before calling .upload(). Implement server-side chunking for files >5MB and set explicit cacheControl headers.
Explanation: Calling addOptimistic before awaiting the action can cause state desynchronization if the server rejects the mutation. React won't automatically roll back optimistic state on error.
Fix: Wrap the action call in a try/catch. On rejection, manually remove the optimistic entry or reset the list using the server's error response.
7. Returning Unstructured Errors
Explanation: Throwing exceptions or returning plain strings from actions breaks TypeScript inference and forces clients to parse error messages.
Fix: Use a discriminated union return type ({ success: true, data } | { success: false, errors }). Flatten Zod errors into field-specific arrays for precise UI feedback.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple CRUD mutation | Server Action + useFormState | Minimal boilerplate, native serialization | Low (serverless compute) |
| Real-time collaborative form | Server Action + Supabase Realtime | Decouples UI from network latency | Medium (realtime connections) |
| Large file uploads (>10MB) | Presigned URLs + Client Upload | Bypasses server memory limits | Low (storage egress) |
| Complex multi-step workflow | Server Action + Redis session | Maintains state across requests | Medium (cache infrastructure) |
| Public-facing unauthenticated form | Server Action + Rate Limiting | Prevents abuse without auth overhead | Low (edge middleware) |
Configuration Template
// lib/supabase/server.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createSupabaseClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get: (name) => cookieStore.get(name)?.value,
set: (name, value, opts) => cookieStore.set({ name, value, ...opts }),
remove: (name, opts) => cookieStore.set({ name, value: '', ...opts }),
},
}
)
}
// actions/template.ts
'use server'
import { z } from 'zod'
import { createSupabaseClient } from '@/lib/supabase/server'
import { revalidateTag } from 'next/cache'
const Schema = z.object({ /* fields */ })
export type ActionState = { errors?: Record<string, string[]>; success?: boolean }
export async function executeAction(prev: ActionState, fd: FormData): Promise<ActionState> {
const parsed = Schema.safeParse(Object.fromEntries(fd))
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }
const db = await createSupabaseClient()
// DB operation...
revalidateTag('resource-tag')
return { success: true }
}
Quick Start Guide
- Initialize Project: Run
npx create-next-app@latest my-app --typescript --app and install dependencies: npm i @supabase/ssr zod
- Configure Environment: Add
NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY to .env.local
- Create Action File: Add
actions/resource.ts with 'use server', Zod schema, and Supabase client instantiation
- Build Component: Create a client component using
useFormState and useFormStatus, pointing form action to your server function
- Test & Deploy: Verify submission works with JS disabled, check Supabase dashboard for RLS compliance, and deploy to Vercel/Edge runtime