Define Handlers
Ecopages provides helpers for defining type-safe API handlers outside your main app file: defineApiHandler for individual routes, method-specific helpers such as defineGet and definePost, and defineGroupHandler for related routes that share a prefix and middleware.
These helpers type the handler context from the route's middleware and request schema. Params are typed by schema.params, not by the path string.
Why Use Define Helpers
A handler passed inline to app.get() takes its context type from that call. Extracted to a separate file as a plain function, it loses that type:
// handlers/blog.ts
export const getPost = async ({ params }) => { // Error: Binding element 'params' implicitly has an 'any' type
const { id } = params;
};The defineApiHandler, method helpers, and defineGroupHandler give the handler its context type at definition time: the context its middleware adds, and body, query, headers and params typed from the route's schema.
Method helpers
For common HTTP verbs, use defineGet, definePost, definePut, defineDelete, definePatch, defineOptions, defineHead, or defineQuery. Each one is defineApiHandler with the matching method; path may be left out only in an endpoint file.
import { defineGet, definePost } from '@ecopages/core';
export const list = defineGet({
path: '/api/posts',
handler: async ({ response }) => {
return response.json(await getPosts());
},
});
export const create = definePost({
path: '/api/posts',
handler: async ({ request, response }) => {
const body = await request.json();
return response.status(201).json(await createPost(body));
},
});Use defineApiHandler when you want an explicit method field or an uncommon registration shape.
The QUERY method
defineQuery (and app.query(), and api.query in a group) handles the HTTP QUERY method: a safe, idempotent read whose request body carries the query, such as a JSON filter too large or too sensitive for the URL. It takes the same schema and middleware as definePost, and a schema.body is validated the same way, including the 400 and 415 answers.
import { defineQuery } from '@ecopages/core';
import { z } from 'zod';
export const search = defineQuery({
path: '/api/posts/search',
schema: { body: z.object({ tag: z.string(), limit: z.number().max(50).default(10) }) },
handler: async ({ body, response }) =>
response.headers({ 'Accept-Query': 'application/json' }).json(await findPosts(body)),
});In an endpoint file, export it under its method name and leave out path, like the other helpers:
// src/pages/api/posts/search.api.ts
import { defineQuery } from '@ecopages/core';
export const QUERY = defineQuery({
handler: async ({ request, response }) => response.json(await findPosts(await request.json())),
});QUERY here is the HTTP method. It is unrelated to the query field of the handler context, which holds the URL's search parameters and is available on every method. Ecopages does not cache QUERY responses and does not add Accept-Query or Vary for you; set them on the response when you want them.
Endpoint files
In an endpoint file under src/pages (*.api.ts, .api.tsx, .api.js or .api.jsx), export each method helper under its method name and leave out path: the file's location gives the URL. schema and middleware work per method, as they do here.
// src/pages/api/posts.api.ts
import { defineGet } from '@ecopages/core';
export const GET = defineGet({
handler: async ({ response }) => response.json(await getPosts()),
});Setting path in an endpoint file is an error. See Endpoint Files for the full guide.
defineApiHandler
Use defineApiHandler to create a self-contained handler with its path, method, and optional middleware or schema.
Basic Usage
// handlers/blog.ts
import { defineGet, defineApiHandler } from '@ecopages/core';
export const list = defineGet({
path: '/api/posts',
handler: async ({ response }) => {
const posts = await getPosts();
return response.json(posts);
},
});
export const detail = defineApiHandler({
path: '/api/posts/:id',
method: 'GET',
handler: async ({ params, response }) => {
const post = await getPost(params.id);
return response.json(post);
},
});Registration
Register prebuilt handlers with app.add(). Use app.get(path, handler) only for inline routes in app.ts.
// app.ts
import { createApp } from '@ecopages/core/create-app';
import * as blog from './handlers/blog';
const app = await createApp();
app.add(blog.list);
app.add(blog.detail);
await app.start();With Request Schema
Define a schema to get typed access to request body, query parameters, and headers:
import { definePost } from '@ecopages/core';
import { z } from 'zod';
const createPostSchema = {
body: z.object({
title: z.string().min(1),
content: z.string(),
}),
query: z.object({
draft: z.string().optional(),
}),
};
export const createPost = definePost({
path: '/api/posts',
schema: createPostSchema,
handler: async ({ body, query, response }) => {
const post = await createPost(body, query.draft === 'true');
return response.status(201).json(post);
},
});Repeated query keys (?draft=true&draft=false) reach the schema as arrays, so z.string() answers HTTP 400 for them. Use z.union([z.string(), z.array(z.string())]) to accept both shapes.
When a route declares schema.body, core parses application/json and text/plain bodies and validates the parsed value. Malformed JSON returns HTTP 400. A request with no body and no Content-Type, or an empty body with either supported type, passes undefined to the body schema: required schemas return HTTP 400, while optional schemas such as z.object({ title: z.string() }).optional() accept it. Unsupported content types and nonempty bodies without a Content-Type return HTTP 415.
With Middleware
Attach route-specific middleware that extends the handler context:
import { defineApiHandler } from '@ecopages/core';
import { rateLimitMiddleware } from './middleware/rate-limit';
export const sensitiveEndpoint = defineApiHandler({
path: '/api/sensitive',
method: 'POST',
middleware: [rateLimitMiddleware],
handler: async ({ response, rateLimitRemaining }) => {
// rateLimitRemaining is typed from the middleware
return response.json({ remaining: rateLimitRemaining });
},
});List middleware before handler. TypeScript infers a list that holds an inline middleware function only after the arguments before it, so a handler written before such a list sees none of the list's contexts, constants included. A list of EcoMiddleware constants works in either order.
defineGroupHandler
Use defineGroupHandler when you have multiple routes that share a URL prefix and middleware. This is ideal for authenticated sections or resource-based APIs.
Basic Usage
// handlers/admin.ts
import { defineGroupHandler } from '@ecopages/core';
import { authMiddleware } from './middleware/auth';
export const adminGroup = defineGroupHandler({
prefix: '/admin',
middleware: [authMiddleware],
routes: (api) => [
api.get({
path: '/',
handler: async (ctx) => {
return ctx.response.json({ user: ctx.session.user });
},
}),
api.get({
path: '/posts/:id',
handler: async (ctx) => {
const post = await getPost(ctx.params.id);
return ctx.response.json(post);
},
}),
api.delete({
path: '/posts/:id',
handler: async (ctx) => {
await deletePost(ctx.params.id);
return ctx.response.status(204).text('');
},
}),
],
});Every route in the group, api.get and its siblings included, receives the context the group middleware adds, so ctx.session above is typed.
The routes callback also accepts the callable form api({ path, method, handler }) when you need an uncommon method shape. Prefer api.get and api.post for standard verbs.
Registration
Register the entire group with app.group():
// app.ts
import { createApp } from '@ecopages/core/create-app';
import { adminGroup } from './handlers/admin';
const app = await createApp();
app.group(adminGroup);
await app.start();The group registration:
- Prepends the prefix to each route path (
/becomes/admin,/posts/:idbecomes/admin/posts/:id) - Applies group middleware before route-specific middleware
- Preserves all type inference
With Route-Level Schema
Individual routes within a group can define their own schema:
import { z } from 'zod';
const createPostSchema = {
body: z.object({
title: z.string().min(1),
content: z.string(),
}),
};
export const adminGroup = defineGroupHandler({
prefix: '/admin',
middleware: [authMiddleware],
routes: (api) => [
api.post({
path: '/posts',
schema: createPostSchema,
handler: async (ctx) => {
const post = await createPost(ctx.body, ctx.session.user.id);
return ctx.response.status(201).json(post);
},
}),
],
});json / html / redirect
Import json, html, and redirect from @ecopages/core when a handler returns a body without using context.response:
import { defineGet, json, html, redirect } from '@ecopages/core';
export const health = defineGet({
path: '/api/health',
handler: async () => json({ ok: true }),
});
export const landing = defineGet({
path: '/landing',
handler: async () => html('<p>hi</p>'),
});
export const loginRedirect = defineGet({
path: '/login',
handler: async () => redirect('/sign-in', 303),
});These helpers share the same body emission path as context.response.json() and context.response.html().
Context Typing
Params
The path string selects the route's URL; it does not type params. Without a schema, params is PageParams, a record of strings with string arrays for catch-all segments, so any key type-checks. Declare schema.params to type and validate Params:
import { defineApiHandler } from '@ecopages/core';
import { z } from 'zod';
defineApiHandler({
path: '/api/users/:userId/posts/:postId',
method: 'GET',
schema: {
params: z.object({ userId: z.string(), postId: z.string() }),
},
handler: async ({ params, response }) => {
// params is { userId: string; postId: string }
const { userId, postId } = params;
return response.json({ userId, postId });
},
});Params that fail the schema get a 400 response before the handler runs. A field the schema omits keeps its untyped shape: with only schema.body, params is still PageParams and query is still a record of strings and string arrays.
Middleware Context
Middleware can extend the handler context with additional properties:
// middleware/auth.ts
import type { ApiHandlerContext, EcoMiddleware } from '@ecopages/core';
export type AuthenticatedContext = ApiHandlerContext & {
session: { user: User; token: string };
};
export const authMiddleware: EcoMiddleware<AuthenticatedContext> = async (ctx, next) => {
const token = ctx.request.headers.get('Authorization') ?? '';
const user = await validateToken(token);
if (!user) {
return ctx.response.status(401).json({ error: 'Unauthorized' });
}
ctx.session = { user, token };
return next();
};When used with defineGroupHandler, all routes receive the extended context automatically.
Complete Example
// handlers/blog.ts
import { defineGet, defineGroupHandler } from '@ecopages/core';
import { authMiddleware } from '../middleware/auth';
import { z } from 'zod';
const createPostSchema = {
body: z.object({
title: z.string().min(1),
content: z.string(),
}),
};
const updatePostSchema = {
params: z.object({ id: z.string() }),
body: z.object({
title: z.string().min(1).optional(),
content: z.string().optional(),
}),
};
// Public routes
export const list = defineGet({
path: '/api/posts',
handler: async ({ response }) => {
const posts = await getPublicPosts();
return response.json(posts);
},
});
export const detail = defineGet({
path: '/api/posts/:id',
handler: async ({ params, response }) => {
const post = await getPost(params.id);
if (!post) return response.status(404).json({ error: 'Not found' });
return response.json(post);
},
});
// Protected routes
export const protectedGroup = defineGroupHandler({
prefix: '/api/posts',
middleware: [authMiddleware],
routes: (api) => [
api.post({
path: '/',
schema: createPostSchema,
handler: async (ctx) => {
const post = await createPost(ctx.body, ctx.session.user.id);
return ctx.response.status(201).json(post);
},
}),
api.put({
path: '/:id',
schema: updatePostSchema,
handler: async (ctx) => {
const post = await updatePost(ctx.params.id, ctx.body, ctx.session.user.id);
return ctx.response.json(post);
},
}),
api.delete({
path: '/:id',
handler: async (ctx) => {
await deletePost(ctx.params.id, ctx.session.user.id);
return ctx.response.status(204).text('');
},
}),
],
});// app.ts
import { createApp } from '@ecopages/core/create-app';
import * as blog from './handlers/blog';
const app = await createApp();
// Public
app.add(blog.list);
app.add(blog.detail);
// Protected
app.group(blog.protectedGroup);
await app.start();Handler Object Shape
For reference, here is the shape of objects produced by the define helpers:
interface ApiHandler<TPath, TRequest, TServer> {
path: TPath;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD' | 'QUERY';
handler: (context: ApiHandlerContext<TRequest, TServer>) => Promise<Response> | Response;
middleware?: MiddlewareList;
schema?: RouteSchema;
}
interface GroupHandler<TPrefix> {
prefix: TPrefix;
middleware?: MiddlewareList;
routes: ApiHandler[];
}MiddlewareList is a read-only list of middleware functions, whatever context each one declares; app.get() and the other route options use the same type.
A method helper called without path returns path: ''; that handler is an endpoint-file export, and app.add() and app.group() reject it.
Pass an ApiHandler to app.add() and a GroupHandler to app.group().