---
title: "Server Action"
status: active
audience: [ai-agent, contributor]
language: nl
last_updated: 2026-05-03
when_to_read: "When writing a new server action with auth and Zod validation."
---

# Patroon: Server Action

Altijd in `actions/[domein].ts`. Nooit inline in page.tsx.

```ts
'use server'

import { revalidatePath } from 'next/cache'
import { getIronSession } from 'iron-session'
import { cookies } from 'next/headers'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { productAccessFilter } from '@/lib/product-access'

const schema = z.object({
  productId: z.string().cuid(),
  title: z.string().min(1).max(200),
  priority: z.number().int().min(1).max(4),
})

export async function createPbi(formData: FormData) {
  // 1. Auth
  const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
  if (!session.userId) return { error: 'Niet ingelogd' }
  if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }

  // 2. Validatie
  const parsed = schema.safeParse({
    productId: formData.get('productId'),
    title: formData.get('title'),
    priority: Number(formData.get('priority')),
  })
  if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }

  // 3. Toegang controleren: eigenaar of gekoppeld product member
  const product = await prisma.product.findFirst({
    where: { id: parsed.data.productId, ...productAccessFilter(session.userId) }
  })
  if (!product) return { error: 'Product niet gevonden' }

  // 4. Schrijven
  const last = await prisma.pbi.findFirst({
    where: { product_id: parsed.data.productId, priority: parsed.data.priority },
    orderBy: { sort_order: 'desc' },
  })
  const pbi = await prisma.pbi.create({
    data: { ...parsed.data, product_id: parsed.data.productId, sort_order: (last?.sort_order ?? 0) + 1.0 },
  })

  revalidatePath(`/products/${parsed.data.productId}`)
  return { success: true, pbi }
}
```

## Security-invarianten

- Controleer auth en `session.isDemo` voordat er geschreven wordt.
- Gebruik `productAccessFilter(userId)` voor resources waar eigenaar en gekoppelde Developer beide toegang hebben.
- Gebruik eigenaar-only filters (`user_id: session.userId`) alleen voor eigenaarsacties zoals product archiveren, teamleden beheren of persoonlijke todos.
- Vertrouw nooit losse client-ID's. Als een action meerdere IDs ontvangt, haal ze eerst op met `id in (...)` plus de parent-scope en weiger de operatie als het aantal gevonden records niet exact gelijk is.
- Weiger dubbele IDs in reorder-lijsten of beslissingsobjecten.
- Leid denormalized foreign keys af uit de database-parent. Voorbeeld: gebruik `pbi.product_id` bij story creation, niet `formData.get('productId')`.
- Delete pas nadat ownership/scoping bewezen is; gebruik scoped `deleteMany` als een directe unique `delete` anders een cross-user record kan raken.
