API Routes
API routes in TanStack Start allow you to create REST API endpoints alongside your application routes. They provide a type-safe way to build backend APIs with full access to server-side resources.Creating API Routes
API routes are created using theserver option in route files:
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/users')({
server: {
handlers: {
GET: async ({ request }) => {
const users = await db.users.findAll()
return Response.json(users)
},
},
},
})
HTTP Methods
Support multiple HTTP methods in a single route:export const Route = createFileRoute('/api/posts')({
server: {
handlers: {
GET: async ({ request }) => {
const posts = await db.posts.findAll()
return Response.json(posts)
},
POST: async ({ request }) => {
const body = await request.json()
const post = await db.posts.create(body)
return Response.json(post, { status: 201 })
},
DELETE: async ({ request }) => {
const url = new URL(request.url)
const id = url.searchParams.get('id')
await db.posts.delete(id)
return new Response(null, { status: 204 })
},
},
},
})
Dynamic Routes
Use route parameters in API routes:export const Route = createFileRoute('/api/users/$userId')({
server: {
handlers: {
GET: async ({ params, request }) => {
const user = await db.users.findById(params.userId)
if (!user) {
return Response.json(
{ error: 'User not found' },
{ status: 404 }
)
}
return Response.json(user)
},
PUT: async ({ params, request }) => {
const body = await request.json()
const user = await db.users.update(params.userId, body)
return Response.json(user)
},
DELETE: async ({ params }) => {
await db.users.delete(params.userId)
return new Response(null, { status: 204 })
},
},
},
})
Request Handling
Parsing Request Body
export const Route = createFileRoute('/api/posts')({
server: {
handlers: {
POST: async ({ request }) => {
// JSON body
const data = await request.json()
// FormData
const formData = await request.formData()
const title = formData.get('title')
// Text
const text = await request.text()
// Binary data
const buffer = await request.arrayBuffer()
return Response.json({ success: true })
},
},
},
})
Query Parameters
export const Route = createFileRoute('/api/search')({
server: {
handlers: {
GET: async ({ request }) => {
const url = new URL(request.url)
const query = url.searchParams.get('q')
const limit = parseInt(url.searchParams.get('limit') || '10')
const offset = parseInt(url.searchParams.get('offset') || '0')
const results = await db.posts.search({
query,
limit,
offset,
})
return Response.json(results)
},
},
},
})
Request Headers
export const Route = createFileRoute('/api/protected')({
server: {
handlers: {
GET: async ({ request }) => {
const auth = request.headers.get('Authorization')
const contentType = request.headers.get('Content-Type')
const userAgent = request.headers.get('User-Agent')
if (!auth) {
return new Response('Unauthorized', { status: 401 })
}
return Response.json({ authenticated: true })
},
},
},
})
Response Handling
JSON Responses
export const Route = createFileRoute('/api/data')({
server: {
handlers: {
GET: async () => {
return Response.json(
{ message: 'Success', data: [...] },
{
status: 200,
headers: {
'Cache-Control': 'public, max-age=3600',
},
}
)
},
},
},
})
Custom Headers
export const Route = createFileRoute('/api/file')({
server: {
handlers: {
GET: async () => {
const file = await readFile('path/to/file.pdf')
return new Response(file, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="file.pdf"',
'Cache-Control': 'no-cache',
},
})
},
},
},
})
Streaming Responses
export const Route = createFileRoute('/api/stream')({
server: {
handlers: {
GET: async () => {
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
const data = await fetchChunk(i)
controller.enqueue(
new TextEncoder().encode(JSON.stringify(data) + '\n')
)
await new Promise((r) => setTimeout(r, 1000))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson',
'Cache-Control': 'no-cache',
},
})
},
},
},
})
Middleware with API Routes
Apply middleware to API routes:import { createMiddleware } from '@tanstack/react-start'
const apiAuth = createMiddleware().server(async ({ request, next }) => {
const apiKey = request.headers.get('x-api-key')
if (!apiKey || !(await validateApiKey(apiKey))) {
throw new Response('Invalid API key', { status: 401 })
}
return next()
})
const rateLimiter = createMiddleware().server(async ({ request, next }) => {
const ip = request.headers.get('x-forwarded-for')
if (await isRateLimited(ip)) {
throw new Response('Too many requests', { status: 429 })
}
return next()
})
export const Route = createFileRoute('/api/data')({
server: {
middleware: [rateLimiter, apiAuth],
handlers: {
GET: async ({ request }) => {
const data = await fetchData()
return Response.json(data)
},
},
},
})
Error Handling
Custom Error Responses
export const Route = createFileRoute('/api/users/$userId')({
server: {
handlers: {
GET: async ({ params }) => {
try {
const user = await db.users.findById(params.userId)
return Response.json(user)
} catch (error) {
if (error instanceof NotFoundError) {
return Response.json(
{ error: 'User not found' },
{ status: 404 }
)
}
if (error instanceof ValidationError) {
return Response.json(
{ error: 'Invalid user ID', details: error.details },
{ status: 400 }
)
}
console.error('Unexpected error:', error)
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
},
},
},
})
Error Middleware
const errorHandler = createMiddleware().server(async ({ next }) => {
try {
return await next()
} catch (error) {
console.error('API Error:', error)
return Response.json(
{
error: 'Internal server error',
message: process.env.NODE_ENV === 'development'
? error.message
: 'An error occurred',
},
{ status: 500 }
)
}
})
CORS Support
const corsMiddleware = createMiddleware().server(
async ({ request, next }) => {
const origin = request.headers.get('origin')
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': origin || '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
})
}
const result = await next()
// Add CORS headers to response
result.response.headers.set(
'Access-Control-Allow-Origin',
origin || '*'
)
result.response.headers.set(
'Access-Control-Allow-Credentials',
'true'
)
return result
}
)
export const Route = createFileRoute('/api/public')({
server: {
middleware: [corsMiddleware],
handlers: {
GET: async () => Response.json({ message: 'Hello' }),
},
},
})
Webhooks
Handle webhook endpoints:import crypto from 'crypto'
function verifySignature(payload: string, signature: string, secret: string) {
const hmac = crypto.createHmac('sha256', secret)
const digest = hmac.update(payload).digest('hex')
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(digest)
)
}
export const Route = createFileRoute('/api/webhooks/stripe')({
server: {
handlers: {
POST: async ({ request }) => {
const payload = await request.text()
const signature = request.headers.get('stripe-signature')
if (!verifySignature(payload, signature, process.env.STRIPE_SECRET)) {
return new Response('Invalid signature', { status: 401 })
}
const event = JSON.parse(payload)
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data)
break
case 'payment_intent.failed':
await handlePaymentFailure(event.data)
break
}
return Response.json({ received: true })
},
},
},
})
File Uploads
export const Route = createFileRoute('/api/upload')({
server: {
handlers: {
POST: async ({ request }) => {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return Response.json(
{ error: 'No file provided' },
{ status: 400 }
)
}
// Validate file type
if (!file.type.startsWith('image/')) {
return Response.json(
{ error: 'Only images allowed' },
{ status: 400 }
)
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
return Response.json(
{ error: 'File too large' },
{ status: 400 }
)
}
// Save file
const buffer = await file.arrayBuffer()
const filename = `${Date.now()}-${file.name}`
await fs.writeFile(`/uploads/${filename}`, Buffer.from(buffer))
return Response.json({
filename,
url: `/uploads/${filename}`,
})
},
},
},
})
Pagination
export const Route = createFileRoute('/api/posts')({
server: {
handlers: {
GET: async ({ request }) => {
const url = new URL(request.url)
const page = parseInt(url.searchParams.get('page') || '1')
const limit = parseInt(url.searchParams.get('limit') || '20')
const offset = (page - 1) * limit
const [posts, total] = await Promise.all([
db.posts.findMany({ limit, offset }),
db.posts.count(),
])
return Response.json({
data: posts,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
})
},
},
},
})
REST API Best Practices
Consistent Response Format
type ApiResponse<T> = {
success: boolean
data?: T
error?: string
meta?: {
timestamp: string
version: string
}
}
function apiResponse<T>(data: T, status = 200): Response {
return Response.json(
{
success: status < 400,
data,
meta: {
timestamp: new Date().toISOString(),
version: '1.0',
},
} as ApiResponse<T>,
{ status }
)
}
export const Route = createFileRoute('/api/users')({
server: {
handlers: {
GET: async () => {
const users = await db.users.findAll()
return apiResponse(users)
},
},
},
})
API Versioning
export const Route = createFileRoute('/api/v1/users')({
server: {
handlers: {
GET: async () => {
// Version 1 implementation
return Response.json(await db.users.findAll())
},
},
},
})
export const RouteV2 = createFileRoute('/api/v2/users')({
server: {
handlers: {
GET: async () => {
// Version 2 with different response format
const users = await db.users.findAllWithProfiles()
return Response.json(users)
},
},
},
})
Best Practices
-
Use Appropriate HTTP Methods
- GET for reading data
- POST for creating resources
- PUT/PATCH for updating resources
- DELETE for removing resources
-
Return Proper Status Codes
- 200: Success
- 201: Created
- 204: No Content
- 400: Bad Request
- 401: Unauthorized
- 403: Forbidden
- 404: Not Found
- 500: Server Error
-
Validate Input
- Always validate request data
- Return meaningful error messages
- Use validation libraries
-
Handle Errors Gracefully
- Catch and handle all errors
- Don’t expose internal errors to clients
- Log errors for debugging
-
Secure Your APIs
- Implement authentication
- Use rate limiting
- Validate API keys
- Enable CORS appropriately
-
Document Your APIs
- Use consistent naming
- Document parameters and responses
- Provide examples
-
Performance
- Implement pagination
- Use caching headers
- Optimize database queries
Next Steps
- Learn about Middleware for API routes
- Explore Server Functions
- See Deployment options