SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Getting Started
Installation
Configuration
Deployment

Components

  • Hero
  • Pricing
  • Features

Features

  • Authentication
  • Payments
  • Emails
  • Database
  • Blog
  • Security Headers
  • Claude Code Skills

Recipes

  • Add a Server Action
  • Add a Database Table
  • Add an OAuth Provider
  • Add an Email Template
  • Customize the Auth Flow
  • Add an Admin Metric

Add a Server Action

End-to-end pattern: Zod schema, validate-then-authorize-then-query, return generic errors. The canonical mutation pattern in SecureStartKit.

What you are building

A Server Action that takes typed input, validates it with Zod, reads identity from the session (never from the request body), authorizes the operation, runs the database mutation, and returns a generic error to the client on failure. This is the canonical mutation pattern in SecureStartKit, and every existing action in actions/ follows it.

The pattern

The order is fixed: validate, then authorize, then query. Each step fails fast and returns before the next runs.

// actions/team.ts
'use server'

import { z } from 'zod'
import { createAdminClient, getUser } from '@/lib/supabase/server'

const inviteSchema = z.object({
  email: z.email(),
  role: z.enum(['member', 'admin']),
})

export async function inviteTeamMember(
  data: z.infer<typeof inviteSchema>
) {
  // 1. Validate input
  const parsed = inviteSchema.safeParse(data)
  if (!parsed.success) {
    return { error: 'Invalid input' }
  }

  // 2. Read identity from session (NOT from request body)
  const user = await getUser()
  if (!user) {
    return { error: 'Not authenticated' }
  }

  // 3. Authorize: is this user allowed to invite?
  const admin = createAdminClient()
  const { data: membership } = await admin
    .from('team_members')
    .select('role, team_id')
    .eq('user_id', user.id)
    .single()

  if (!membership || membership.role !== 'admin') {
    return { error: 'Not authorized' }
  }

  // 4. Query
  const { error } = await admin.from('team_invites').insert({
    team_id: membership.team_id,
    email: parsed.data.email,
    role: parsed.data.role,
    invited_by: user.id,
  })

  if (error) {
    console.error('Failed to insert invite:', error)
    return { error: 'Failed to send invite' }
  }

  return { success: true }
}

What each step is doing

Step 1, validation with Zod. The schema is the contract. Anything the client sends that does not match returns immediately with a generic error. Use safeParse (not parse) so a malformed request never throws an unhandled exception. For complex inputs, the Zod schema doubles as the TypeScript type via z.infer.

Step 2, identity from session. getUser() calls auth.getUser() against the cookie-bound Supabase client. This validates the session token server-side; a forged or expired cookie returns null. Never trust a userId field on the request body; that is the Insecure Direct Object Reference (IDOR) anti-pattern, OWASP A01.

Step 3, authorization. Authentication answers "who is this user." Authorization answers "is this user allowed to do this operation on this resource." These are separate checks. Even an authenticated user might not be allowed to invite to THIS team.

Step 4, query. createAdminClient() uses the service_role key, which bypasses RLS. This is intentional: Server Actions are the trusted access layer, and the RLS deny-all posture is the safety net for code that does not go through them. The error returned to the client is generic ('Failed to send invite'); the underlying database error stays in server logs to avoid leaking schema details.

Calling the action from a form

'use client'

import { useActionState } from 'react'
import { inviteTeamMember } from '@/actions/team'

export function InviteForm() {
  const [state, formAction, pending] = useActionState(
    async (_: unknown, formData: FormData) => {
      return await inviteTeamMember({
        email: formData.get('email') as string,
        role: formData.get('role') as 'member' | 'admin',
      })
    },
    null
  )

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <select name="role">
        <option value="member">Member</option>
        <option value="admin">Admin</option>
      </select>
      <button type="submit" disabled={pending}>Send invite</button>
      {state?.error && <p>{state.error}</p>}
    </form>
  )
}

The useActionState hook is the React 19 + Next.js 16 pattern for form submissions. The action returns the state object that includes either { success: true } or { error: '...' }; render accordingly.

Common mistakes

  • Reading userId from the form body. Always read identity from getUser() server-side. The form is client-controlled and can be tampered with.
  • Returning the raw Supabase error to the client. Database errors leak column names, table names, and constraint details that help attackers map your schema. Always log the real error server-side and return a generic message.
  • Skipping the authorization step. Authentication (is the user logged in) is not authorization (is THIS user allowed to do THIS operation). Both checks are required.
  • Using createClient() instead of createAdminClient(). The browser client respects RLS, which is good for the browser but means your Server Action gets denied because there are no RLS policies. Always use createAdminClient() inside Server Actions.

What to read next

  • The full reasoning for this pattern: Server Actions + Zod, the complete guide
  • The architectural frame: backend-only data access
  • For inputs that need rate limiting: How to rate-limit Next.js Server Actions