Routing & API Endpoints
| Field | Value |
|---|---|
| Type | Agent Reference |
| Source | ~/.copilot/agents/_refs/astro/routing-and-endpoints.md |
| Description | Not specified |
Source Content
Routing & API Endpoints
File-Based Routing
Astro routes map directly to the file system under src/pages/:
src/pages/ index.astro → / about.astro → /about cases/ index.astro → /cases [id].astro → /cases/:id [id]/ edit.astro → /cases/:id/edit api/ cases/ index.ts → GET/POST /api/cases [id].ts → GET/PUT/DELETE /api/cases/:idRules:
.astrofiles render HTML pages (with optional islands)..tsfiles insrc/pages/api/are API endpoints — export named functions for HTTP methods.- Dynamic segments use
[param]syntax. Rest parameters use[...rest]. src/pages/api/files are never rendered as HTML — they’re pure server handlers.
API Endpoints
Every endpoint exports named HTTP method handlers. Request body and response are validated with Zod:
import type { APIRoute } from 'astro'import { z } from 'zod'
const createCaseSchema = z.object({ title: z.string().min(1).max(200), type: z.enum(['civil', 'criminal', 'family', 'probate']), clientId: z.string().uuid(),})
const caseResponseSchema = z.object({ id: z.string().uuid(), title: z.string(), type: z.enum(['civil', 'criminal', 'family', 'probate']), clientId: z.string().uuid(), createdAt: z.string().datetime(),})
export type Case = z.infer<typeof caseResponseSchema>
export const GET: APIRoute = async ({ locals, url }) => { // Auth is handled by middleware — locals.user is guaranteed const { user } = locals
const cases = await db.cases.findMany({ where: { userId: user.id } })
return Response.json(z.array(caseResponseSchema).parse(cases))}
export const POST: APIRoute = async ({ request, locals }) => { const { user } = locals
const body = await request.json() const result = createCaseSchema.safeParse(body)
if (!result.success) { return Response.json( { error: 'Invalid request', issues: result.error.flatten() }, { status: 400 } ) }
const newCase = await db.cases.create({ data: { ...result.data, userId: user.id }, })
return Response.json(caseResponseSchema.parse(newCase), { status: 201 })}Rules:
- Export one function per HTTP method:
GET,POST,PUT,PATCH,DELETE. - Always validate request body with
.safeParse()and return 400 on failure. - Always validate response with
.parse()before returning (strips unexpected fields). - Use
localsfor request-scoped data set by middleware (user, session, etc.). - Return
Response.json()notnew Response(JSON.stringify(...)).
Dynamic Routes
import type { APIRoute } from 'astro'import { z } from 'zod'
const paramsSchema = z.object({ id: z.string().uuid(),})
export const GET: APIRoute = async ({ params, locals }) => { const result = paramsSchema.safeParse(params) if (!result.success) { return Response.json({ error: 'Invalid case ID' }, { status: 400 }) }
const { id } = result.data const { user } = locals
const case_ = await db.cases.findUnique({ where: { id, userId: user.id }, // always scope to the authenticated user })
if (!case_) { return Response.json({ error: 'Not found' }, { status: 404 }) }
return Response.json(caseResponseSchema.parse(case_))}Middleware
Middleware runs before every request. Auth checking belongs here — never inside page components:
import { defineMiddleware, sequence } from 'astro:middleware'import { verifyToken } from '@/lib/auth'
const authMiddleware = defineMiddleware(async (context, next) => { const { pathname } = context.url
// Public routes — skip auth const publicRoutes = ['/', '/login', '/signup', '/api/auth/login'] if (publicRoutes.some((r) => pathname.startsWith(r))) { return next() }
const token = context.cookies.get('session')?.value if (!token) { // API routes get 401; pages get redirect if (pathname.startsWith('/api/')) { return Response.json({ error: 'Unauthorized' }, { status: 401 }) } return context.redirect('/login') }
const user = await verifyToken(token) if (!user) { context.cookies.delete('session') return context.redirect('/login') }
// Attach user to locals for all downstream handlers context.locals.user = user return next()})
export const onRequest = sequence(authMiddleware)Add type declaration for locals:
/// <reference types="astro/client" />
type User = { id: string; email: string; role: 'admin' | 'editor' | 'viewer' }
declare namespace App { interface Locals { user: User }}Astro Actions (Astro 5)
Actions are type-safe server functions called from client components — no API route boilerplate:
import { defineAction } from 'astro:actions'import { z } from 'zod'
export const server = { createCase: defineAction({ accept: 'json', input: z.object({ title: z.string().min(1).max(200), type: z.enum(['civil', 'criminal', 'family', 'probate']), }), handler: async (input, context) => { const { user } = context.locals const newCase = await db.cases.create({ data: { ...input, userId: user.id }, }) return newCase }, }),}// src/components/CreateCaseForm.tsx (React island)import { actions } from 'astro:actions'import { useActionState } from 'react'
function CreateCaseForm() { const [result, submitAction, isPending] = useActionState( async (_prev: unknown, formData: FormData) => { return await actions.createCase({ title: formData.get('title') as string, type: formData.get('type') as 'civil' | 'criminal' | 'family' | 'probate', }) }, null )
return ( <form action={submitAction}> <TextField name="title" label="Case title" /> <Select name="type" label="Case type" options={caseTypeOptions} /> <Button type="submit" disabled={isPending} aria-busy={isPending}> {isPending ? 'Creating…' : 'Create case'} </Button> </form> )}Environment Variables
Use astro:env (Astro 5) for type-safe, schema-validated env vars:
// src/env.ts — declare schemaimport { envField } from 'astro/config'
export const envSchema = { DATABASE_URL: envField.string({ context: 'server', access: 'secret' }), JWT_SECRET: envField.string({ context: 'server', access: 'secret' }), PUBLIC_APP_URL: envField.string({ context: 'client', access: 'public' }),}import { defineConfig, envField } from 'astro/config'
export default defineConfig({ env: { schema: { DATABASE_URL: envField.string({ context: 'server', access: 'secret' }), JWT_SECRET: envField.string({ context: 'server', access: 'secret' }), PUBLIC_APP_URL: envField.string({ context: 'client', access: 'public' }), }, },})// In a server-side fileimport { DATABASE_URL, JWT_SECRET } from 'astro:env/server'// In a client-side fileimport { PUBLIC_APP_URL } from 'astro:env/client'Never use import.meta.env.MY_VAR directly in application code — always go through astro:env so env vars are validated at startup.