API Handlers

Ecopages provides a straightforward way to define API handlers within your application. This allows you to build server-side logic, fetch data, or perform actions directly from your Ecopages project.

Defining Handlers

You define API handlers using methods on your app instance in app.ts, or in an endpoint file, below. Each method corresponds to an HTTP verb (GET, POST, etc.).

// filepath: app.ts
import { createApp } from '@ecopages/core/create-app';
 
const app = await createApp();
 
// Define a GET handler
app.get('/api/greet', async ({ response }) => {
	return response.text('Hello from the API!');
});
 
// Define a POST handler
app.post('/api/submit', async ({ request, response }) => {
	const body = await request.json(); // Assuming JSON body
	console.log('Received data:', body);
	return response.json({ success: true, received: body });
});
 
await app.start();

Endpoint Files

A handler can also live in its own file under src/pages, named *.api.ts (or .api.tsx, .api.js, .api.jsx). The file's path gives the URL, so src/pages/api/greet.api.ts answers /api/greet. It exports one handler per HTTP method, built with the method helpers and no path:

// filepath: src/pages/api/greet.api.ts
import { defineGet } from '@ecopages/core';
 
export const GET = defineGet({
	handler: async ({ response }) => response.text('Hello from the API!'),
});

The handler context below, schemas, middleware and app.onError work the same in both forms. See Endpoint Files for the file rules, examples, and what the build and the server do with the files.

Use an endpoint file when the URL should mirror the folder tree or the handler lives beside a Page; use app.get() / app.add() for groups with a shared prefix and shared middleware, or URLs that end in a static file extension.

Handler Context

Every API handler function receives an ApiHandlerContext object as its argument. This object provides access to essential information and utilities for handling the request and constructing the response.

app.get('/api/example/:id', async ({ request, response, params }) => {
	const { id } = params;
	const userAgent = request.headers.get('user-agent');
	const queryParam = new URL(request.url).searchParams.get('search');
	const body = {
		userId: id,
		agent: userAgent,
		query: queryParam,
	};
 
	return response.status(200).json(body);
});

Catch-all Route Params

A named segment such as :id gives a string. A catch-all keeps each decoded path segment in an array, so /api/files/docs/a matches /api/files/[...path] with params.path equal to ['docs', 'a'].

Repeated query keys are arrays too. For example, ?tag=docs&tag=api gives query.tag as ['docs', 'api']. A single query value remains a string. When a schema is configured, validation receives those same values, so a schema written for a plain string, such as z.string(), answers HTTP 400 when the key is repeated. Accept both shapes with z.union([z.string(), z.array(z.string())]), or declare the key as an array with z.array(z.string()) when it is always a list. WebSocket handlers receive the same shape in search.

Validating Route Params with schema

You can validate and type route parameters through schema.params.

import { z } from 'zod';
 
app.get(
	'/api/users/:id',
	async ({ params, response }) => {
		return response.json({ userId: params.id });
	},
	{
		schema: {
			params: z.object({
				id: z.string().uuid(),
			}),
		},
	},
);

The ApiHandlerContext contains:

  • request: The incoming Fetch Request object.
    • Standard Request properties like headers, method, url, etc.
    • Methods like json(), text(), formData() to parse the request body.
  • params: Route parameters parsed from the path (e.g., :id in /api/users/:id).
  • response: An instance of ApiResponseBuilder, a utility for constructing Response objects fluently.
  • server: The Node http.Server once app.start() has bound it. Typed unknown, and undefined for an app.fetch() call made before app.start(); narrow it before use.
  • body: The request body validated by schema.body and typed from it. Unset without a body schema; read request instead.
  • query: The parsed query parameters. With schema.query, the validated output, typed from the schema.
  • headers: The request headers validated by schema.headers and typed from it. Unset without a headers schema; read request.headers instead.
  • services: An object containing framework services like cache.

Accessing the Server Instance

server is typed unknown, so narrow it with instanceof Server before calling Node APIs such as address():

import { Server } from 'node:http';
 
app.get('/api/hello', async ({ response, server }) => {
	return response.json({
		message: 'Hello world!',
		listeningOn: server instanceof Server ? server.address() : null,
	});
});

See Server API for when server is undefined and how app.fetch() behaves.

The ApiResponseBuilder

Instead of manually creating new Response(...) objects, Ecopages provides the ApiResponseBuilder via context.response. This utility simplifies response creation with a fluent API.

Basic Usage

app.get('/api/data', async ({ response }) => {
	const data = { message: 'Here is your data' };
	// Automatically sets Content-Type to application/json
	return response.json(data);
});
 
app.get('/api/plain', async ({ response }) => {
	// Automatically sets Content-Type to text/plain
	return response.text('Plain text response.');
});
 
app.get('/api/html-page', async ({ response }) => {
	// Automatically sets Content-Type to text/html
	return response.html('<h1>Hello HTML</h1>');
});

Chaining Methods

You can chain methods to customize the response status and headers before sending the body.

app.post('/api/create', async ({ response }) => {
	// ... creation logic ...
	const newItem = { id: 123, name: 'New Item' };
 
	return response
		.status(201) // Set status to 201 Created
		.headers({ 'X-Custom-Header': 'CreatedValue' }) // Add custom headers
		.json(newItem); // Send JSON body
});

Available Methods

  • .status(code: number): Sets the HTTP status code (e.g., response.status(404)).
  • .headers(headersInit: HeadersInit): Adds or merges headers (e.g., response.headers({ 'Cache-Control': 'no-cache' })).
  • .json(data: any): Sends a JSON response. Sets Content-Type: application/json.
  • .text(data: string): Sends a plain text response. Sets Content-Type: text/plain.
  • .html(data: string): Sends an HTML response. Sets Content-Type: text/html.
  • .redirect(url: string, explicitStatus?: number): Sends a redirect response. Sets the Location header. Defaults to status 302 if not set via .status() or explicitStatus.
  • .error(data: string | object, explicitStatus?: number): Sends an error response. Defaults to status 500. If data is an object, sends JSON; otherwise, sends text.
app.get('/api/old-path', async ({ response }) => {
	// Permanent redirect (301)
	return response.status(301).redirect('/api/new-path');
});
 
app.get('/api/find/:id', async ({ params, response }) => {
	const { id } = params;
	const item = // ... find item logic ...
 
	if (!item) {
		// Send a 404 error with a JSON body
		return response.error({ message: `Item ${id} not found` }, 404);
	}
 
	return response.json(item);
});

Using the ApiResponseBuilder makes your API handler code cleaner, more readable, and less prone to errors compared to manually constructing Response objects.

WebSocket Handlers

Register WebSocket routes on the app before calling start(). Ecopages provides a runtime-agnostic WebSocket API (see WebSockets).

import { createApp } from '@ecopages/core/create-app';
 
const app = await createApp();
 
app.websocket('/ws/echo', {
	onMessage(socket, message) {
		if (message.kind === 'text') {
			socket.send(message.text);
		}
	},
});
 
await app.start();

Route Groups

Use app.group() to organize related routes under a shared prefix with optional middleware. This is useful for sections like admin panels or authenticated APIs.

import { createApp } from '@ecopages/core/create-app';
import * as admin from './handlers/admin';
import { authMiddleware } from './middleware/auth';
 
const app = await createApp();
 
app.group(
	'/admin',
	(r) => {
		r.get('/', admin.list);
		r.get('/new', admin.newPost);
		r.post('/posts', admin.createPost);
		r.get('/posts/:id', admin.editPost);
		r.post('/posts/:id', admin.updatePost);
		r.post('/posts/:id/delete', admin.deletePost);
		r.post('/upload', admin.uploadImage);
	},
	{
		middleware: [authMiddleware],
	},
);
 
await app.start();

The group registration:

  • Prepends the prefix to each route (/ becomes /admin, /posts/:id becomes /admin/posts/:id)
  • Applies middleware to all routes in the group
  • The middleware context is available in all handlers

Extracted handlers

For handlers in separate files, use defineGet, definePost, and the other method helpers. They wrap defineApiHandler and fix the HTTP verb for you. Register prebuilt handlers with app.add(handler).

Use inline app.get(path, handler) and app.post(path, handler) only when defining routes directly in app.ts, as shown above.

Optional response shortcuts — json, html, and redirect from @ecopages/core — share the same body emission path as context.response.json() and friends.

See Define Handlers for defineApiHandler, defineGroupHandler, schemas, middleware, and grouped routes.