This skill provides API route patterns and conventions for the fitness app. Use when creating or modifying API routes, handling authentication, validating requests, or implementing error handling.
Provides API route patterns and validation helpers for fitness app endpoints.
/plugin marketplace add josephanson/claude-plugin/plugin install ja@josephansonThis skill inherits all available tools. When active, it can use any tool Claude has access to.
references/bodyValidation.tsThis skill provides comprehensive patterns for building API routes in the fitness application using Nuxt server routes.
Separation of Concerns: All database logic MUST be in /server/database/queries/. Never use useDB() directly in API routes.
Return Data Directly: Don't wrap successful responses in { success: true, data: ... }.
Custom Validation: Always use validation helpers from server/utils/bodyValidation.ts instead of Nuxt's built-in functions.
Every API route follows this pattern:
import { z } from 'zod'
import { queryUserWorkouts } from '~~/server/database/queries/workouts'
import { validateBody, validateParams, validateQuery } from '~~/server/utils/bodyValidation'
// 1. Define validation schemas locally
const paramsSchema = z.object({
id: z.uuid(),
})
const bodySchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
})
const querySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
})
// 2. Define event handler
export default defineEventHandler(async (event) => {
// 3. Authenticate user
const { user } = await requireUserSession(event)
// 4. Validate request data
const { id } = await validateParams(event, paramsSchema)
const body = await validateBody(event, bodySchema)
const query = await validateQuery(event, querySchema)
// 5. Use query functions (never useDB() directly)
const workouts = await queryUserWorkouts(user.id, query.limit)
// 6. Return data directly (no wrapper)
return workouts
})
Use requireUserSession to get the authenticated user:
export default defineEventHandler(async (event) => {
// Throws 401 if not authenticated
const { user, session } = await requireUserSession(event)
// user.id is available for queries
const workouts = await queryUserWorkouts(user.id)
return workouts
})
Key points:
user and session objectsconst { user } = await requireUserSession(event)Always use custom validation helpers from server/utils/bodyValidation.ts:
Validate request body:
const bodySchema = z.object({
name: z.string().min(1).max(100),
weight: z.coerce.number().positive(),
sets: z.coerce.number().int().min(1),
})
export default defineEventHandler(async (event) => {
const body = await validateBody(event, bodySchema)
// body is typed and validated
})
Validate route parameters:
// Route: /api/workouts/[id].ts
const paramsSchema = z.object({
id: z.uuid(),
})
export default defineEventHandler(async (event) => {
const { id } = await validateParams(event, paramsSchema)
// id is validated as UUID
})
Validate query parameters:
// Route: /api/workouts?status=active&limit=20
const querySchema = z.object({
status: z.enum(['active', 'completed']).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
page: z.coerce.number().int().min(1).default(1),
})
export default defineEventHandler(async (event) => {
const query = await validateQuery(event, querySchema)
// query.limit and query.page have defaults
})
Important: Use z.coerce.number() for numeric query/body params as they come as strings.
Return data directly without wrapping:
// ✅ Correct
export default defineEventHandler(async (event) => {
const workouts = await queryUserWorkouts(userId)
return workouts // Return array directly
})
// ❌ Wrong: Don't wrap
export default defineEventHandler(async (event) => {
const workouts = await queryUserWorkouts(userId)
return { success: true, data: workouts } // Don't do this
})
Use createError for error responses:
export default defineEventHandler(async (event) => {
const { id } = await validateParams(event, paramsSchema)
const workout = await queryWorkoutById(id)
if (!workout) {
throw createError({
statusCode: 404,
statusMessage: 'Workout not found'
})
}
return workout
})
Common status codes:
400 - Bad Request (validation errors)401 - Unauthorized (not authenticated)403 - Forbidden (authenticated but not authorized)404 - Not Found409 - Conflict (e.g., duplicate resource)422 - Unprocessable Entity (semantic validation errors)500 - Internal Server Error// server/api/workouts/index.post.ts
import { z } from 'zod'
import { createWorkout } from '~~/server/database/queries/workouts'
import { validateBody } from '~~/server/utils/bodyValidation'
const bodySchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
isPublic: z.boolean().default(false),
exercises: z.array(z.uuid()).min(1),
})
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const body = await validateBody(event, bodySchema)
const workout = await createWorkout({
...body,
userId: user.id,
})
return workout
})
// server/api/workouts/[id].get.ts
import { z } from 'zod'
import { getWorkoutById } from '~~/server/database/queries/workouts'
import { validateParams } from '~~/server/utils/bodyValidation'
const paramsSchema = z.object({
id: z.uuid(),
})
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const { id } = await validateParams(event, paramsSchema)
const workout = await getWorkoutById(id, user.id)
// getWorkoutById throws 404 if not found
return workout
})
// server/api/workouts/index.get.ts
import { z } from 'zod'
import { queryUserWorkouts } from '~~/server/database/queries/workouts'
import { validateQuery } from '~~/server/utils/bodyValidation'
const querySchema = z.object({
status: z.enum(['active', 'completed']).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
})
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const query = await validateQuery(event, querySchema)
const workouts = await queryUserWorkouts(user.id, query)
return workouts
})
// server/api/workouts/[id].patch.ts
import { z } from 'zod'
import { updateWorkout } from '~~/server/database/queries/workouts'
import { validateParams, validateBody } from '~~/server/utils/bodyValidation'
const paramsSchema = z.object({
id: z.uuid(),
})
const bodySchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
isPublic: z.boolean().optional(),
}).refine(
data => Object.keys(data).length > 0,
{ message: 'At least one field must be provided' }
)
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const { id } = await validateParams(event, paramsSchema)
const body = await validateBody(event, bodySchema)
const workout = await updateWorkout(id, user.id, body)
// updateWorkout throws 404 if not found
return workout
})
// server/api/workouts/[id].delete.ts
import { z } from 'zod'
import { deleteWorkout } from '~~/server/database/queries/workouts'
import { validateParams } from '~~/server/utils/bodyValidation'
const paramsSchema = z.object({
id: z.uuid(),
})
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const { id } = await validateParams(event, paramsSchema)
await deleteWorkout(id, user.id)
// deleteWorkout throws 404 if not found
// Return 204 No Content
setResponseStatus(event, 204)
})
Critical: Always use ~~/ for server-side imports:
// ✅ Correct: Server imports with ~~/
import { users } from '~~/server/database/schema/users'
import { getUserById } from '~~/server/database/queries/users'
import { validateBody } from '~~/server/utils/bodyValidation'
// ❌ Wrong: Using ~/
import { users } from '~/server/database/schema/users'
// Nitro will look in wrong directory!
// ✅ Correct: Local schema definition
const paramsSchema = z.object({
id: z.uuid(),
})
// ❌ Wrong: Importing from shared/
import { idParamsSchema } from '~/shared/schemas/params'
Why? Keeps schemas close to usage and allows route-specific customization.
// shared/schemas/workout.ts
export const baseWorkoutSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
})
// API route
import { baseWorkoutSchema } from '~/shared/schemas/workout'
const bodySchema = baseWorkoutSchema.extend({
exercises: z.array(z.uuid()).min(1),
})
requireUserSession for authenticationvalidateBody, validateQuery, validateParams)createError for error responses/server/database/queries/~~/ for server-side importsuseDB() directly in API routesgetRouterParam, readValidatedBody, etc.)shared/ directory{ success: true, data: ... }~/ for server-side importsconst querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const { page, limit } = await validateQuery(event, querySchema)
const offset = (page - 1) * limit
const workouts = await queryUserWorkouts(user.id, { limit, offset })
const total = await countUserWorkouts(user.id)
return {
data: workouts,
meta: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
}
})
const querySchema = z.object({
search: z.string().optional(),
status: z.enum(['active', 'completed']).optional(),
sortBy: z.enum(['name', 'createdAt']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
})
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const query = await validateQuery(event, querySchema)
const workouts = await queryUserWorkouts(user.id, query)
return workouts
})
// Route: /api/workouts/[workoutId]/exercises/[exerciseId].ts
const paramsSchema = z.object({
workoutId: z.uuid(),
exerciseId: z.uuid(),
})
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const { workoutId, exerciseId } = await validateParams(event, paramsSchema)
const exercise = await getWorkoutExercise(workoutId, exerciseId, user.id)
return exercise
})
references/bodyValidation.ts - File needed to handle all validationsActivates when the user asks about AI prompts, needs prompt templates, wants to search for prompts, or mentions prompts.chat. Use for discovering, retrieving, and improving prompts.