We all love Astro for its simplicity and developer experience. But sometimes, its API endpoints feel like a rough edge. Every time I start a new project, I find myself rewriting the same boilerplate: parsing requests manually, writing validation logic by hand, handling errors in a custom way. It gets old fast.
After yet another round of writing endpoints, their methods, and validation from scratch, while trying to recall the best solutions from previous projects, I realized there had to be a better way to structure Astro’s API endpoints in a way that felt closer to familiar development patterns, without repeating this pain every time.
I remembered my good experience with OpenAPI on previous projects. Automatic documentation, type safety, and validation all in one place sounded like exactly what I needed. That is when I discovered Chanfana. A library that brings these benefits to modern routers, handling docs and validation automatically using Zod schemas.
While reading Chanfana’s documentation to figure out how to use it with Astro, I came across Hono, which I’d used before and liked for its simplicity and convenience. I ended up using it as a bridge between Astro’s catch-all endpoints and Chanfana’s OpenAPI features.
The solution turned out to be surprisingly elegant. Instead of creating dozens of separate endpoint files in pages/api/, I now have a single entry point: pages/api/[...path].ts. This catch-all route delegates all API requests to Hono, which then routes them to the appropriate handlers powered by Chanfana.
// src/pages/api/[...path].ts
import { Hono } from 'hono';
import { fromHono } from 'chanfana';
import type { APIRoute } from 'astro';
import { ExampleGetEndpoint } from 'src/api/endpoints/exampleGet';
import { ExamplePostEndpoint } from 'src/api/endpoints/examplePost';
const app = new Hono<{ Bindings: Env }>();
const openapi = fromHono(app, {
docs_url: "/api/docs",
openapi_url: "/api/openapi.json",
});
openapi.get('/api/example', ExampleGetEndpoint);
openapi.post('/api/example', ExamplePostEndpoint);
export const ALL: APIRoute = async ({ request, locals }) => {
const response = await app.fetch(request, locals.runtime.env);
if (!response) {
return new Response("Hono returned nothing", { status: 500 });
}
return response;
};
That's it. This single file:
- Creates a Hono app instance
- Wraps it with Chanfana's OpenAPI layer
- Registers your endpoints
- Exports an ALL handler that Astro calls for any HTTP method
- Forwards requests to Hono, which handles routing and validation
Your actual endpoint logic lives in separate files (like ExampleGetEndpoint), keeping everything organized.
Here’s what a single endpoint looks like with Chanfana:
// src/api/endpoints/exampleGet.ts
import { OpenAPIRoute, Str } from "chanfana";
import { z } from "zod";
export class ExampleGetEndpoint extends OpenAPIRoute {
schema = {
tags: ["Example Endpoints"],
summary: "Get simple data",
description: "A simple GET endpoint for testing API functionality.",
request: {
query: z.object({
name: Str({default: "Guest" }),
}),
},
responses: {
"200": {
description: "Success",
content: {
"application/json": {
schema: z.object({
success: z.boolean(),
message: z.string(),
greeting: z.string(),
}),
},
},
},
},
};
async handle(c: any) {
const data = await this.getValidatedData<typeof this.schema>();
console.log({ "Example Variable": c.env.EXAMPLE_VARIABLE });
return {
success: true,
message: "API is reachable",
greeting: `Hello, ${data.query.name}!`,
};
}
}
Notice how the schema defines everything: query parameters, their types, default values, and the response structure. Chanfana takes care of validation automatically, so TypeScript knows exactly what data.query.name is. No manual type guards, no custom validation logic.
The handle method only receives validated data. If a request doesn’t match the schema, Chanfana returns a 400 error automatically. Your job is just to write the business logic.
All of this also generates interactive API documentation out of the box.
Visit /api/docs and you'll see a full Swagger UI:

Every endpoint, parameter, and response type is automatically documented based on your schemas.
What’s next?
You can also generate TypeScript types for your frontend directly from the OpenAPI schema. This ensures your client code knows exactly what each endpoint expects and returns, with full autocomplete support.
Use openapi-typescript to generate types directly from the OpenAPI schema created from your endpoints. Combine it with openapi-fetch for fully type-safe API calls.
When Does This Make Sense?
This setup works well when you want type safety, automatic validation, and interactive documentation for your API. It’s especially useful for teams or projects where multiple endpoints need to stay consistent and maintainable.
Even if your project is small, having types and validation enforced by the same schema can save time and reduce mistakes. Once you’re managing several routes, this approach quickly pays off.
Try It Yourself
Check out the full working example on GitHub.
Top comments (0)