Skip to content

Routing & API Endpoints

FieldValue
TypeAgent Reference
Source~/.copilot/agents/_refs/astro/routing-and-endpoints.md
DescriptionNot 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/:id

Rules:

  • .astro files render HTML pages (with optional islands).
  • .ts files in src/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:

src/pages/api/cases/index.ts
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 locals for request-scoped data set by middleware (user, session, etc.).
  • Return Response.json() not new Response(JSON.stringify(...)).

Dynamic Routes

src/pages/api/cases/[id].ts
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:

src/middleware.ts
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:

src/env.d.ts
/// <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:

src/actions/index.ts
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 schema
import { 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' }),
}
astro.config.ts
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 file
import { DATABASE_URL, JWT_SECRET } from 'astro:env/server'
// In a client-side file
import { 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.