Files
findyourpilot/.agent/skills/tanstack-start-best-practices/rules/sf-input-validation.md
2026-03-02 21:16:26 +01:00

4.5 KiB

sf-input-validation: Always Validate Server Function Inputs

Priority: CRITICAL

Explanation

Server functions receive data across the network boundary. Always validate inputs before processing - never trust client data. Use schema validation libraries like Zod for type-safe validation.

Bad Example

// No validation - trusting client input directly
export const updateUser = createServerFn({ method: 'POST' })
  .handler(async ({ data }) => {
    // data is unknown/any - no type safety
    // SQL injection, invalid data, type errors all possible
    await db.users.update({
      where: { id: data.id },
      data: {
        name: data.name,
        email: data.email,
        role: data.role,  // Could be set to 'admin' by malicious client!
      },
    })
  })

// Weak validation - type assertion without runtime check
export const deletePost = createServerFn({ method: 'POST' })
  .handler(async ({ data }: { data: { id: string } }) => {
    // Type assertion doesn't validate at runtime
    await db.posts.delete({ where: { id: data.id } })
  })

Good Example: With Zod Validation

import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'

const updateUserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  // Don't allow role updates from client input!
})

export const updateUser = createServerFn({ method: 'POST' })
  .validator(updateUserSchema)
  .handler(async ({ data }) => {
    // data is fully typed: { id: string; name: string; email: string }
    const user = await db.users.update({
      where: { id: data.id },
      data: {
        name: data.name,
        email: data.email,
      },
    })
    return user
  })

// Validation errors are automatically returned to client
// with proper status codes and messages

Good Example: Complex Validation

const createOrderSchema = z.object({
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity: z.number().int().min(1).max(100),
  })).min(1).max(50),
  shippingAddress: z.object({
    street: z.string().min(1),
    city: z.string().min(1),
    state: z.string().length(2),
    zip: z.string().regex(/^\d{5}(-\d{4})?$/),
  }),
  couponCode: z.string().optional(),
})

export const createOrder = createServerFn({ method: 'POST' })
  .validator(createOrderSchema)
  .handler(async ({ data }) => {
    // All data is validated and typed
    // Process order safely
  })

Good Example: Transform and Refine

const registrationSchema = z.object({
  email: z.string().email().toLowerCase(),  // Transform to lowercase
  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Password must contain uppercase letter')
    .regex(/[0-9]/, 'Password must contain number'),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  { message: 'Passwords must match', path: ['confirmPassword'] }
)

export const register = createServerFn({ method: 'POST' })
  .validator(registrationSchema)
  .handler(async ({ data }) => {
    // Passwords match, email is lowercase
    // Only password needed (confirmPassword was for validation)
    const hashedPassword = await hashPassword(data.password)
    return await createUser({
      email: data.email,
      password: hashedPassword,
    })
  })

Sharing Schemas Between Client and Server

// lib/schemas/post.ts - Shared validation schema
import { z } from 'zod'

export const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  tags: z.array(z.string()).max(10).optional(),
})

export type CreatePostInput = z.infer<typeof createPostSchema>

// lib/posts.functions.ts - Server function
import { createPostSchema } from './schemas/post'

export const createPost = createServerFn({ method: 'POST' })
  .validator(createPostSchema)
  .handler(async ({ data }) => { /* ... */ })

// components/CreatePostForm.tsx - Client form validation
import { createPostSchema, type CreatePostInput } from '@/lib/schemas/post'

function CreatePostForm() {
  const form = useForm<CreatePostInput>({
    resolver: zodResolver(createPostSchema),
  })
  // Same validation client and server side
}

Context

  • Network boundary = trust boundary - always validate
  • Use .validator() before .handler() in the chain
  • Validation errors return proper HTTP status codes
  • Share schemas between client forms and server functions
  • Strip or ignore fields clients shouldn't control (like role, isAdmin)
  • Consider rate limiting for mutation endpoints