Files
dotfiles/dot_agents/skills/tanstack-start-best-practices/rules/api-routes.md
T
Prad Nukala 6406208b35 shell: fish functions -> ~/.local/bin executables + bash/zsh ports; manage ~/.agents
- 16 cross-shell utilities converted to sh/bash chezmoi executables:
  ai c2p cp-appid extract_agents fab gdc gh-sonr-org-migrate ghg-clone
  ghn gho git-summary launch_omp_livediff_overlay lc-elir mdcp mdp pj
  (ghg-clone not named ghg: unrelated cargo binary owns that name)
- cwd/history helpers stay per-shell: yy c cd clip-last + ghg/ghn/gho
  cd-wrappers in bash/functions/ and zsh/functions/, loaded by rc files
- fish keeps only shell-state functions; converted ones now hit PATH
- cp-appid ignore gating moved to .local/bin path
- dot_agents/skills: 82 skills materialized from dotstate storage so
  dot_claude skill symlinks resolve on fresh bootstrap; dotstate
  profile/symlinks.json released .agents/skills .bashrc .zshrc
2026-08-10 15:15:06 -04:00

6.7 KiB

api-routes: Create Server Routes for External Consumers

Priority: MEDIUM

Explanation

While server functions are ideal for internal RPC, server routes provide traditional REST endpoints for external consumers, webhooks, and integrations. Use server routes when you need standard HTTP semantics, custom response formats, or third-party compatibility.

Bad Example

// Using server functions for webhook endpoints
export const stripeWebhook = createServerFn({ method: 'POST' })
  .handler(async ({ request }) => {
    // Server functions aren't designed for raw request handling
    // No easy access to raw body for signature verification
    // Response format is JSON by default
  })

// Or exposing internal functions to external consumers
export const getUsers = createServerFn()
  .handler(async () => {
    return db.users.findMany()
  })
// No versioning, no standard REST semantics

Good Example: Basic Server Route

// routes/api/users.ts
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'

export const Route = createFileRoute('/api/users')({
  server: {
    handlers: {
      GET: async ({ request }) => {
        const users = await db.users.findMany({
          select: { id: true, name: true, email: true },
        })

        return json(users, {
          headers: {
            'Cache-Control': 'public, max-age=60',
          },
        })
      },

      POST: async ({ request }) => {
        const body = await request.json()

        // Validate input
        const parsed = createUserSchema.safeParse(body)
        if (!parsed.success) {
          return json({ error: parsed.error.flatten() }, { status: 400 })
        }

        const user = await db.users.create({ data: parsed.data })
        return json(user, { status: 201 })
      },
    },
  },
})

Good Example: Webhook Handler

// routes/api/webhooks/stripe.ts
import { createFileRoute } from '@tanstack/react-router'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export const Route = createFileRoute('/api/webhooks/stripe')({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const signature = request.headers.get('stripe-signature')
        if (!signature) {
          return new Response('Missing signature', { status: 400 })
        }

        // Get raw body for signature verification
        const rawBody = await request.text()

        let event: Stripe.Event
        try {
          event = stripe.webhooks.constructEvent(
            rawBody,
            signature,
            process.env.STRIPE_WEBHOOK_SECRET!
          )
        } catch (err) {
          console.error('Webhook signature verification failed:', err)
          return new Response('Invalid signature', { status: 400 })
        }

        // Handle the event
        switch (event.type) {
          case 'checkout.session.completed':
            await handleCheckoutComplete(event.data.object)
            break
          case 'customer.subscription.updated':
            await handleSubscriptionUpdate(event.data.object)
            break
          default:
            console.log(`Unhandled event type: ${event.type}`)
        }

        return new Response('OK', { status: 200 })
      },
    },
  },
})

Good Example: RESTful Resource with Dynamic Params

// routes/api/posts/$postId.ts
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'

export const Route = createFileRoute('/api/posts/$postId')({
  server: {
    handlers: {
      GET: async ({ params }) => {
        const post = await db.posts.findUnique({
          where: { id: params.postId },
        })

        if (!post) {
          return json({ error: 'Post not found' }, { status: 404 })
        }

        return json(post)
      },

      PUT: async ({ request, params }) => {
        const body = await request.json()
        const parsed = updatePostSchema.safeParse(body)

        if (!parsed.success) {
          return json({ error: parsed.error.flatten() }, { status: 400 })
        }

        const post = await db.posts.update({
          where: { id: params.postId },
          data: parsed.data,
        })

        return json(post)
      },

      DELETE: async ({ params }) => {
        await db.posts.delete({ where: { id: params.postId } })
        return new Response(null, { status: 204 })
      },
    },
  },
})

Good Example: With Route-Level Middleware

// routes/api/protected/data.ts
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { apiKeyMiddleware } from '@/lib/middleware'

export const Route = createFileRoute('/api/protected/data')({
  server: {
    // Middleware applies to all handlers in this route
    middleware: [apiKeyMiddleware],
    handlers: {
      GET: async ({ request, context }) => {
        // context.client available from middleware
        const data = await fetchDataForClient(context.client.id)
        return json(data)
      },
    },
  },
})

Good Example: Using createHandlers for Handler-Specific Middleware

// routes/api/admin/users.ts
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'

export const Route = createFileRoute('/api/admin/users')({
  server: {
    middleware: [authMiddleware],  // All handlers require auth
    handlers: (createHandlers) => ({
      GET: createHandlers.GET(async ({ context }) => {
        const users = await db.users.findMany()
        return json(users)
      }),

      // DELETE requires additional admin middleware
      DELETE: createHandlers.DELETE({
        middleware: [adminOnlyMiddleware],
        handler: async ({ request, context }) => {
          const { userId } = await request.json()
          await db.users.delete({ where: { id: userId } })
          return json({ deleted: true })
        },
      }),
    }),
  },
})

Server Functions vs Server Routes

Feature Server Functions Server Routes
Primary use Internal RPC External consumers
Type safety Full end-to-end Manual
Response format JSON (automatic) Any (manual)
Raw request access Limited Full
URL structure Auto-generated Explicit paths
Webhooks Not ideal Designed for

Context

  • Server routes use createFileRoute with a server.handlers property
  • Support all HTTP methods: GET, POST, PUT, PATCH, DELETE, etc.
  • Use json() helper for JSON responses
  • Return Response objects for custom formats
  • Handler receives { request, params } object
  • Ideal for: webhooks, public APIs, file downloads, third-party integrations
  • Consider versioning: /api/v1/users for public APIs