Files
findyourpilot/.agent/skills/tanstack-start-best-practices/rules/err-server-errors.md
2026-03-02 21:16:26 +01:00

4.9 KiB

err-server-errors: Handle Server Function Errors

Priority: MEDIUM

Explanation

Server function errors cross the network boundary. Handle them gracefully with appropriate error types, status codes, and user-friendly messages. Avoid exposing internal details in production.

Bad Example

// Throwing raw errors - exposes internals
export const createUser = createServerFn({ method: 'POST' })
  .validator(createUserSchema)
  .handler(async ({ data }) => {
    const user = await db.users.create({ data })  // May throw DB error
    return user
    // Prisma error with stack trace sent to client
  })

// Generic error handling - no useful info for client
export const getPost = createServerFn()
  .handler(async ({ data }) => {
    try {
      return await fetchPost(data.id)
    } catch (e) {
      throw new Error('Something went wrong')  // Too vague
    }
  })

Good Example: Structured Error Handling

// lib/errors.ts
export class AppError extends Error {
  constructor(
    message: string,
    public code: string,
    public status: number = 400
  ) {
    super(message)
    this.name = 'AppError'
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} not found`, 'NOT_FOUND', 404)
  }
}

export class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') {
    super(message, 'UNAUTHORIZED', 401)
  }
}

export class ValidationError extends AppError {
  constructor(message: string, public fields?: Record<string, string>) {
    super(message, 'VALIDATION_ERROR', 400)
  }
}

Good Example: Server Function with Error Handling

import { createServerFn, notFound } from '@tanstack/react-start'
import { setResponseStatus } from '@tanstack/react-start/server'

export const getPost = createServerFn()
  .validator(z.object({ id: z.string() }))
  .handler(async ({ data }) => {
    const post = await db.posts.findUnique({
      where: { id: data.id },
    })

    if (!post) {
      // Use built-in notFound for 404s
      throw notFound()
    }

    return post
  })

export const createPost = createServerFn({ method: 'POST' })
  .validator(createPostSchema)
  .handler(async ({ data }) => {
    try {
      const post = await db.posts.create({ data })
      return post
    } catch (error) {
      if (error instanceof Prisma.PrismaClientKnownRequestError) {
        if (error.code === 'P2002') {
          // Unique constraint violation
          setResponseStatus(409)
          throw new AppError('A post with this title already exists', 'DUPLICATE', 409)
        }
      }

      // Log full error server-side
      console.error('Failed to create post:', error)

      // Return sanitized error to client
      setResponseStatus(500)
      throw new AppError('Failed to create post', 'INTERNAL_ERROR', 500)
    }
  })

Good Example: Client-Side Error Handling

function CreatePostForm() {
  const [error, setError] = useState<string | null>(null)

  const createMutation = useMutation({
    mutationFn: createPost,
    onError: (error) => {
      if (error instanceof AppError) {
        setError(error.message)
      } else if (error instanceof ValidationError) {
        // Handle field-specific errors
        Object.entries(error.fields ?? {}).forEach(([field, message]) => {
          form.setError(field, { message })
        })
      } else {
        setError('An unexpected error occurred')
      }
    },
    onSuccess: (post) => {
      navigate({ to: '/posts/$postId', params: { postId: post.id } })
    },
  })

  return (
    <form onSubmit={handleSubmit}>
      {error && <Alert variant="error">{error}</Alert>}
      {/* form fields */}
    </form>
  )
}

Good Example: Using Redirects for Auth Errors

export const updateProfile = createServerFn({ method: 'POST' })
  .validator(updateProfileSchema)
  .handler(async ({ data }) => {
    const session = await getSessionData()

    if (!session) {
      // Redirect to login for auth errors
      throw redirect({
        to: '/login',
        search: { redirect: '/settings' },
      })
    }

    return await db.users.update({
      where: { id: session.userId },
      data,
    })
  })

Error Response Best Practices

Scenario HTTP Status Response
Validation failed 400 Field-specific errors
Not authenticated 401 Redirect to login
Not authorized 403 Generic forbidden message
Resource not found 404 Use notFound()
Conflict (duplicate) 409 Specific conflict message
Server error 500 Generic message, log details

Context

  • Use notFound() for 404 errors - integrates with router
  • Use redirect() for auth-related errors
  • Set status codes with setResponseStatus()
  • Log full errors server-side, sanitize for client
  • Create custom error classes for consistent handling
  • Validation errors from .validator() are automatic