Claude-skill-registry api-routes
Generate secure TanStack Start API routes with authentication, rate limiting, validation, and proper error handling. Use when creating API endpoints, REST resources, or backend logic.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/api-routes" ~/.claude/skills/majiayu000-claude-skill-registry-api-routes && rm -rf "$T"
manifest:
skills/data/api-routes/SKILL.mdsafety · automated scan (low risk)
This is a pattern-based risk scan, not a security review. Our crawler flagged:
- references .env files
Always read a skill's source content before installing. Patterns alone don't mean the skill is malicious — but they warrant attention.
source content
API Route Generator
Create production-ready API endpoints following this project's security-first patterns.
Quick Start Template
import { createFileRoute } from '@tanstack/react-router' import { eq } from 'drizzle-orm' import { db } from '@/db' import { tableName } from '@/db/schema' import { errorResponse, requireAuth, simpleErrorResponse, successResponse, } from '@/lib/api' import { checkRateLimit } from '@/lib/rate-limit' export const Route = createFileRoute('/api/resource')({ server: { handlers: { GET: async ({ request }) => { try { // 1. Rate limiting const rateLimit = await checkRateLimit(request, 'api') if (!rateLimit.allowed) { return new Response( JSON.stringify({ error: 'Too many requests' }), { status: 429, headers: { 'Retry-After': String(rateLimit.retryAfter) }, }, ) } // 2. Authentication const auth = await requireAuth(request) if (!auth.success) return auth.response // 3. Business logic const items = await db.select().from(tableName) // 4. Success response return successResponse({ items }) } catch (error) { return errorResponse('Failed to fetch', error) } }, }, }, })
Authentication Patterns
User Authentication (any logged-in user)
const auth = await requireAuth(request) if (!auth.success) return auth.response const user = auth.user // { id, email, role }
Admin Authentication
const auth = await requireAdmin(request) if (!auth.success) return auth.response // Only admins reach here
Optional Authentication (public with user context)
import { validateSession } from '@/lib/auth' const session = await validateSession(request) const userId = session.success ? session.user.id : null // Proceed with or without user
Response Helpers
import { successResponse, simpleErrorResponse, errorResponse } from '@/lib/api' // Success with data (200) return successResponse({ items, total }) // Success with custom status return successResponse({ item }, 201) // Validation/client error (400) return simpleErrorResponse('Email is required') return simpleErrorResponse('Not found', 404) // Server error (logs stack in dev) return errorResponse('Database error', error, 500)
Input Validation
Required Fields
const body = await request.json() const { email, name, password } = body if (!email?.trim()) { return simpleErrorResponse('Email is required') } if (!password || password.length < 8) { return simpleErrorResponse('Password must be at least 8 characters') }
Localized String Validation
type LocalizedString = { en: string; fr?: string; id?: string } if (!name || typeof name !== 'object' || !('en' in name) || !name.en?.trim()) { return simpleErrorResponse('Name must have a non-empty "en" property') }
URL Parameter Validation
// For /api/resource/$resourceId routes const { resourceId } = params if (!resourceId || !isValidUUID(resourceId)) { return simpleErrorResponse('Invalid resource ID', 400) }
CRUD Operations
List with Pagination, Filtering, Sorting
import { and, asc, count, desc, eq, ilike, SQL } from 'drizzle-orm' GET: async ({ request }) => { const auth = await requireAuth(request) if (!auth.success) return auth.response const url = new URL(request.url) // Pagination const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10)) const limit = Math.min( 100, Math.max(1, parseInt(url.searchParams.get('limit') || '10', 10)), ) // Filtering const search = url.searchParams.get('q') || '' const status = url.searchParams.get('status') as 'active' | 'draft' | null // Sorting const sortKey = url.searchParams.get('sort') || 'createdAt' const sortOrder = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc' // Build conditions const conditions: SQL[] = [] if (search) { conditions.push(ilike(tableName.name, `%${search}%`) as SQL) } if (status) { conditions.push(eq(tableName.status, status)) } const whereClause = conditions.length > 0 ? and(...conditions) : undefined // Get total count const [{ total }] = await db .select({ total: count() }) .from(tableName) .where(whereClause) // Get paginated items const sortColumn = { name: tableName.name, status: tableName.status, createdAt: tableName.createdAt, }[sortKey] || tableName.createdAt const offset = (page - 1) * limit const items = await db .select() .from(tableName) .where(whereClause) .orderBy(sortOrder === 'asc' ? asc(sortColumn) : desc(sortColumn)) .limit(limit) .offset(offset) return successResponse({ items, total, page, limit, totalPages: Math.ceil(total / limit), }) }
Create with Transaction
POST: async ({ request }) => { const auth = await requireAdmin(request) if (!auth.success) return auth.response const body = await request.json() // Validate if (!body.name?.en?.trim()) { return simpleErrorResponse('Name is required') } try { const result = await db.transaction(async (tx) => { // Create main record const [item] = await tx .insert(tableName) .values({ name: body.name, status: body.status || 'draft', }) .returning() // Create related records if (body.variants?.length) { await tx.insert(variants).values( body.variants.map((v, i) => ({ itemId: item.id, title: v.title, position: i, })), ) } return item }) return successResponse({ item: result }, 201) } catch (error) { return errorResponse('Failed to create', error) } }
Update (PATCH)
PATCH: async ({ request, params }) => { const auth = await requireAdmin(request) if (!auth.success) return auth.response const { resourceId } = params const body = await request.json() // Check exists const [existing] = await db .select() .from(tableName) .where(eq(tableName.id, resourceId)) .limit(1) if (!existing) { return simpleErrorResponse('Not found', 404) } // Update const [updated] = await db .update(tableName) .set({ ...body, updatedAt: new Date(), }) .where(eq(tableName.id, resourceId)) .returning() return successResponse({ item: updated }) }
Delete
DELETE: async ({ request, params }) => { const auth = await requireAdmin(request) if (!auth.success) return auth.response const { resourceId } = params await db.delete(tableName).where(eq(tableName.id, resourceId)) return successResponse({ deleted: true }) }
Avoiding N+1 Queries
// BAD: N+1 queries const items = await db.select().from(orders) for (const item of items) { const orderItems = await db .select() .from(orderItems) .where(eq(orderItems.orderId, item.id)) item.items = orderItems } // GOOD: Batch with Map const items = await db.select().from(orders) const itemIds = items.map((i) => i.id) // Single query for all related data const allOrderItems = await db.select().from(orderItems) // Build lookup map const itemsByOrderId = new Map<string, typeof allOrderItems>() for (const oi of allOrderItems) { const existing = itemsByOrderId.get(oi.orderId) || [] existing.push(oi) itemsByOrderId.set(oi.orderId, existing) } // Use map const itemsWithData = items.map((item) => ({ ...item, orderItems: itemsByOrderId.get(item.id) || [], }))
Rate Limiting
import { checkRateLimit } from '@/lib/rate-limit' // Available tiers: // 'auth': 5 requests per 15 minutes (login attempts) // 'api': 100 requests per minute (general API) // 'webhook': 50 requests per minute (payment webhooks) const rateLimit = await checkRateLimit(request, 'api') if (!rateLimit.allowed) { return new Response(JSON.stringify({ error: 'Too many requests' }), { status: 429, headers: { 'Retry-After': String(rateLimit.retryAfter) }, }) }
Webhook Handlers
// src/routes/api/webhooks/stripe.ts POST: async ({ request }) => { // Rate limit webhooks const rateLimit = await checkRateLimit(request, 'webhook') if (!rateLimit.allowed) { return new Response('Rate limited', { status: 429 }) } // Verify signature const sig = request.headers.get('stripe-signature') if (!sig) { return new Response('Missing signature', { status: 400 }) } const body = await request.text() try { const event = stripe.webhooks.constructEvent( body, sig, process.env.STRIPE_WEBHOOK_SECRET!, ) switch (event.type) { case 'payment_intent.succeeded': await handlePaymentSuccess(event.data.object) break case 'payment_intent.payment_failed': await handlePaymentFailed(event.data.object) break } return new Response('OK', { status: 200 }) } catch (error) { console.error('Webhook error:', error) return new Response('Webhook error', { status: 400 }) } }
Security Checklist
- Rate limiting applied
- Authentication checked
- Input validated
- SQL injection prevented (using Drizzle parameterized queries)
- Sensitive data not exposed in responses
- Error messages don't leak internals
- CSRF protection for state-changing operations
File Naming
| Route | File |
|---|---|
| |
| |
| |
| |
See Also
- Full CRUD examplesrc/routes/api/products/index.ts
- Single resourcesrc/routes/api/orders/$orderId.ts
- Complex flowsrc/routes/api/checkout/
- Response helperssrc/lib/api.ts
- Rate limitingsrc/lib/rate-limit.ts