API Endpoints
Catmint supports API endpoints alongside pages using the endpoint.ts file convention. Endpoints use the Web Standards Request and Response APIs and export named functions matching HTTP methods. This guide covers endpoint conventions, HTTP method handlers, typed parameters, JSON responses, file uploads, and CORS configuration.
The endpoint.ts Convention
Place an endpoint.ts file in any directory inside app/. The file path determines the URL, just like page.tsx files:
app/api/endpoint.ts -> /api
app/api/users/endpoint.ts -> /api/users
app/api/users/[id]/endpoint.ts -> /api/users/:id
app/api/posts/[...path]/endpoint.ts -> /api/posts/*
Endpoints and pages can coexist in the same directory. An endpoint.ts handles non-GET requests (or GET requests with an Accept: application/json header), while a page.tsx handles browser navigation.
HTTP Method Handlers
Export named functions matching HTTP methods. Each handler receives a Web Request object and must return a Response:
// app/api/users/endpoint.ts
export async function GET(request: Request) {
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]
return Response.json(users)
}
export async function POST(request: Request) {
const body = await request.json()
// Create user logic...
return Response.json({ id: 3, ...body }, { status: 201 })
}
Supported Method Exports
| Export Name | HTTP Method | Typical Use |
|---|---|---|
GET | GET | Read resources |
POST | POST | Create resources |
PUT | PUT | Replace resources |
DELETE | DELETE | Remove resources |
PATCH | PATCH | Partially update resources |
OPTIONS | OPTIONS | CORS preflight |
HEAD | HEAD | Check resource existence |
ANY | All methods | Catch-all handler for any HTTP method |
If a request uses a method that is not exported, Catmint responds with 405 Method Not Allowed and sets the Allow header automatically.
Typed Route Parameters
Dynamic route parameters are passed as the second argument to handlers inside a params object:
// app/api/users/[id]/endpoint.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const { id } = params
const user = await findUser(id)
if (!user) {
return new Response('User not found', { status: 404 })
}
return Response.json(user)
}
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
const { id } = params
const body = await request.json()
const updated = await updateUser(id, body)
return Response.json(updated)
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
const { id } = params
await deleteUser(id)
return new Response(null, { status: 204 })
}
Accessing Query Parameters
Parse query parameters from the request URL using the standard URL API:
// app/api/search/endpoint.ts
export async function GET(request: Request) {
const url = new URL(request.url)
const query = url.searchParams.get('q') ?? ''
const page = parseInt(url.searchParams.get('page') ?? '1', 10)
const limit = parseInt(url.searchParams.get('limit') ?? '20', 10)
const results = await search(query, { page, limit })
return Response.json({
query,
page,
results,
total: results.length,
})
}
Returning JSON
Use Response.json() to return JSON responses. It automatically sets the Content-Type header to application/json:
// Simple JSON response
return Response.json({ message: 'Success' })
// With status code
return Response.json({ error: 'Not found' }, { status: 404 })
// With custom headers
return Response.json(data, {
status: 200,
headers: {
'Cache-Control': 'public, max-age=60',
'X-Custom-Header': 'value',
},
})
For non-JSON responses, construct a Response directly:
// Plain text
return new Response('Hello, world!', {
headers: { 'Content-Type': 'text/plain' },
})
// HTML
return new Response('<h1>Hello</h1>', {
headers: { 'Content-Type': 'text/html' },
})
// No content
return new Response(null, { status: 204 })
File Uploads
Handle file uploads using the FormData API. The request body is parsed with request.formData():
// app/api/upload/endpoint.ts
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
export async function POST(request: Request) {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return Response.json({ error: 'No file provided' }, { status: 400 })
}
// Validate file type and size
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
return Response.json({ error: 'File too large' }, { status: 413 })
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return Response.json({ error: 'Invalid file type' }, { status: 415 })
}
// Save the file
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
await writeFile(join('uploads', filename), buffer)
return Response.json({
filename,
size: file.size,
type: file.type,
}, { status: 201 })
}
CORS via OPTIONS Handler
To enable cross-origin requests, export an OPTIONS handler that responds to CORS preflight requests, and add CORS headers to your other responses:
// app/api/data/endpoint.ts
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
}
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: corsHeaders,
})
}
export async function GET() {
const data = { message: 'Hello from the API' }
return Response.json(data, {
headers: corsHeaders,
})
}
export async function POST(request: Request) {
const body = await request.json()
return Response.json({ received: body }, {
status: 201,
headers: corsHeaders,
})
}
For application-wide CORS, consider adding CORS headers in a middleware file rather than repeating them in every endpoint. See the Middleware guide for details.
The ANY Catch-All Handler
Export an ANY function to handle all HTTP methods with a single handler. Specific method exports take precedence over ANY:
// app/api/proxy/[...path]/endpoint.ts
export async function ANY(
request: Request,
{ params }: { params: { path: string[] } }
) {
const targetUrl = `https://upstream.example.com/${params.path.join('/')}`
// Forward the request to an upstream service
const response = await fetch(targetUrl, {
method: request.method,
headers: request.headers,
body: request.body,
})
return new Response(response.body, {
status: response.status,
headers: response.headers,
})
}
Complete CRUD Example
// app/api/tasks/endpoint.ts
import { db } from '../../lib/db.server'
export async function GET(request: Request) {
const url = new URL(request.url)
const status = url.searchParams.get('status')
const tasks = await db.task.findMany({
where: status ? { status } : undefined,
orderBy: { createdAt: 'desc' },
})
return Response.json(tasks)
}
export async function POST(request: Request) {
const { title, description } = await request.json()
if (!title) {
return Response.json({ error: 'Title is required' }, { status: 400 })
}
const task = await db.task.create({
data: { title, description, status: 'pending' },
})
return Response.json(task, { status: 201 })
}
// app/api/tasks/[id]/endpoint.ts
import { db } from '../../../lib/db.server'
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const task = await db.task.findUnique({ where: { id: params.id } })
if (!task) {
return Response.json({ error: 'Task not found' }, { status: 404 })
}
return Response.json(task)
}
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
const updates = await request.json()
const task = await db.task.update({
where: { id: params.id },
data: updates,
})
return Response.json(task)
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await db.task.delete({ where: { id: params.id } })
return new Response(null, { status: 204 })
}
Next Steps
- Middleware -- add authentication, logging, and CORS globally
- Caching -- cache endpoint responses with cachedRoute
- Server Functions -- RPC-style calls as an alternative to REST endpoints
