v 0.1.97

Explicit Routing

Explicit routing gives you full control over your application routes. Define handlers, middleware, validation, and render views programmatically in your app.ts file.

Overview

While file-system routing via the pages/ directory works well for many use cases, explicit routing offers:

For architectural patterns and code organization examples, see Routing Patterns.

Basic Routes

Define routes using HTTP method functions on your EcopagesApp instance:

import { EcopagesApp } from '@ecopages/core/adapters/bun/create-app';
import appConfig from './eco.config';
 
const app = new EcopagesApp({ appConfig });
 
app.get('/api/users', async (ctx) => {
	const users = await getUsers();
	return ctx.json(users);
});
 
app.post('/api/users', async (ctx) => {
	const body = await ctx.request.json();
	const user = await createUser(body);
	return ctx.json(user, { status: 201 });
});
 
await app.start();

Rendering Views

The ctx.render() method renders eco.page views from any route handler. The integration (React, Lit, KitaJS) is automatically detected based on the view's file extension at build time—no manual configuration required.

Use dynamic imports for views to enable HMR (hot module replacement) during development:

app.get('/posts/:slug', async (ctx) => {
	const { default: PostView } = await import('./src/views/post-view');
	const post = await getPost(ctx.request.params.slug);
	if (!post) {
		throw HttpError.NotFound('Post not found');
	}
	return ctx.render(PostView, post);
});

Partial Rendering

Use ctx.renderPartial() to render without layout (useful for dynamic fragments):

app.get('/comments', async (ctx) => {
	const comments = await getComments();
	return ctx.renderPartial(CommentList, { comments });
});

Response Helpers

// JSON response
ctx.json(data, { status: 200, headers: {...} })
 
// HTML response
ctx.html('<h1>Hello</h1>', { status: 200 })
 
// Full view with layout
ctx.render(View, props, { status: 200 })
 
// View without layout
ctx.renderPartial(View, props)

Route Groups

Group routes with shared prefix and optional middleware:

app.group('/api/v1', (r) => {
	r.get('/users', listUsers);
	r.post('/users', createUser);
	r.get('/users/:id', getUser);
});
 
app.group('/api/admin', (r) => {
	r.get('/stats', getStats);
	r.post('/config', updateConfig);
}, { middleware: [authMiddleware] });

Routes within a group inherit the prefix and middleware. You can also add per-route middleware:

app.group('/api', (r) => {
	r.get('/public', publicHandler);
	r.get('/protected', protectedHandler, { middleware: [authMiddleware] });
}, { middleware: [loggingMiddleware] });

Middleware

Middleware functions intercept requests before they reach the handler.

Defining Middleware

import type { Middleware } from '@ecopages/core';
 
const authMiddleware: Middleware = async (ctx, next) => {
	const token = ctx.request.headers.get('Authorization');
	if (!token) {
		return ctx.response.status(401).json({ error: 'Unauthorized' });
	}
	return next();
};
 
const logMiddleware: Middleware = async (ctx, next) => {
	console.log(`${ctx.request.method} ${ctx.request.url}`);
	return next();
};

Extending Context with Middleware

Middleware can extend the request context with additional properties (like session data, user info, etc.). Use the BunMiddleware helper type to define middleware that adds properties to the context:

import type { BunMiddleware } from '@ecopages/core/adapters/bun/create-app';
 
// Define middleware that extends context with session data
const authMiddleware: BunMiddleware<{ session: Session }> = async (ctx, next) => {
	const session = await getSession(ctx.request.headers);
	if (!session) {
		return Response.redirect('/login');
	}
	ctx.session = session; // Add session to context
	return next();
};

The context type is automatically inferred from the middleware you provide - no need to manually specify it:

app.group(
	'/admin',
	(r) => {
		r.get('/', async (ctx) => {
			// ctx.session is fully typed automatically!
			console.log(ctx.session.userId);
			return ctx.render(AdminDashboard, { user: ctx.session.user });
		});
 
		r.post('/posts', async (ctx) => {
			const post = await createPost({
				...ctx.body,
				authorId: ctx.session.userId
			});
			return ctx.json(post);
		});
	},
	{ middleware: [authMiddleware] } // Type inferred from here!
);

The extended context properties are fully type-safe across all handlers in the group.

Per-Route Middleware

app.post('/api/posts', createPost, {
	middleware: [authMiddleware, logMiddleware]
});

Request Validation

EcoPages supports Standard Schema for type-safe request validation. Use any compatible library: Zod, Valibot, ArkType, or Effect Schema.

Basic Validation

import { z } from 'zod';
 
app.post('/api/posts', async (ctx) => {
	const { title, content } = ctx.body;
	return ctx.json({ id: 1, title, content });
}, {
	schema: {
		body: z.object({
			title: z.string().min(1),
			content: z.string()
		})
	}
});

Validation Targets

TargetDescriptionAccess
bodyRequest body (JSON)ctx.body
queryQuery string parametersctx.query
headersRequest headersctx.headers

These properties contain parsed data. When schemas are provided, they contain validated and type-safe data. For raw access, use ctx.request.body, ctx.request.url, and ctx.request.headers.

Validation in Route Groups

app.group('/api', (r) => {
	r.post('/users', createUser, {
		schema: {
			body: z.object({
				name: z.string(),
				email: z.string().email()
			})
		}
	});
}, { middleware: [authMiddleware] });

Query Validation

Query parameters are strings by default, but schemas can coerce and validate them:

app.get('/api/posts', async (ctx) => {
	const { page, limit, sortBy } = ctx.query;
	// page: number, limit: number, sortBy: 'date' | 'title' | 'views'
	const posts = await getPosts({ page, limit, sortBy });
	return ctx.json(posts);
}, {
	schema: {
		query: z.object({
			page: z.coerce.number().min(1).default(1),
			limit: z.coerce.number().min(1).max(100).default(20),
			sortBy: z.enum(['date', 'title', 'views']).default('date')
		})
	}
});

Header Validation

Validate required headers like API keys or webhook signatures:

// API key authentication
app.post('/api/webhooks', async (ctx) => {
	const apiKey = ctx.headers['x-api-key'];
	// apiKey is guaranteed to be a valid UUID
	return ctx.json({ received: true });
}, {
	schema: {
		headers: z.object({
			'x-api-key': z.string().uuid()
		})
	}
});
 
// Webhook signature validation
app.post('/webhooks/stripe', async (ctx) => {
	const signature = ctx.headers['stripe-signature'];
	const isValid = verifyStripeSignature(ctx.body, signature);
	return ctx.json({ verified: isValid });
}, {
	schema: {
		body: z.any(),
		headers: z.object({
			'stripe-signature': z.string().min(1)
		})
	}
});

Validation Errors

Invalid requests automatically receive a 400 response:

{
	"error": "Validation failed",
	"issues": [
		{ "path": ["title"], "message": "String must contain at least 1 character(s)" }
	]
}

Error Handling

HttpError

Throw HttpError for standard HTTP errors:

import { HttpError } from '@ecopages/core/errors';
 
app.get('/posts/:slug', async (ctx) => {
	const post = await getPost(ctx.request.params.slug);
	if (!post) {
		throw HttpError.NotFound('Post not found');
	}
	return ctx.render(PostView, post);
});

Available factory methods:

HttpError.BadRequest(message?, details?)
HttpError.Unauthorized(message?)
HttpError.Forbidden(message?)
HttpError.NotFound(message?)
HttpError.Conflict(message?, details?)
HttpError.InternalServerError(message?)

Global Error Handler

Use app.onError() to centralize error handling across all routes. This is useful for logging, transforming errors into consistent API responses, or handling unexpected exceptions:

app.onError((error, ctx) => {
	if (error instanceof HttpError) {
		return ctx.json(error.toJSON(), { status: error.status });
	}
	console.error('Unhandled error:', error);
	return ctx.json({ error: 'Internal Server Error' }, { status: 500 });
});

Static Generation

Register views for build-time static generation using lazy loaders:

app.static('/posts', () => import('./src/views/post-list'));
app.static('/posts/:slug', () => import('./src/views/post-view'));

The loader pattern () => import('...') enables HMR during development. Views must be the default export and define staticPaths and staticProps:

// src/views/post-view.tsx
import { eco } from '@ecopages/core';
 
const PostView = eco.page<PostViewProps>({
	staticPaths: async () => {
		const posts = await getPosts();
		return posts.map((post) => ({ slug: post.slug }));
	},
 
	staticProps: async ({ params }) => {
		const post = await getPost(params.slug);
		return { title: post.title, content: post.content };
	},
 
	render: (props) => <article>...</article>
});
 
export default PostView;

Views Outside pages/

Define eco.page views anywhere in your codebase. Views used with app.static() or rendered via dynamic import must be the default export:

// src/views/post-view.tsx
import { eco } from '@ecopages/core';
import { MainLayout } from '@/layouts/main-layout';
 
const PostView = eco.page<PostViewProps>({
	layout: MainLayout,
 
	metadata: ({ title, authorName }) => ({
		title: `${title} | My Blog`,
		description: `By ${authorName}`
	}),
 
	dependencies: {
		stylesheets: ['/styles/post.css']
	},
 
	render: ({ title, content }) => (
		<article>
			<h1>{title}</h1>
			<div>{content}</div>
		</article>
	)
});
 
export default PostView;

Coexistence with File-System Routing

Explicit routes take precedence over file-system routes:

const app = new EcopagesApp({ appConfig });
 
// Explicit routes (higher priority)
app.get('/posts', posts.index);
app.get('/posts/:slug', posts.show);
 
// File-system routes still work
// /about -> pages/about.tsx
// /contact -> pages/contact.tsx
 
await app.start();

Complete Example

import { EcopagesApp } from '@ecopages/core/adapters/bun/create-app';
import { HttpError } from '@ecopages/core/errors';
import { z } from 'zod';
import appConfig from './eco.config';
import { PostView, PostListView } from './src/views';
import type { BunMiddleware } from '@ecopages/core/adapters/bun/create-app';
 
const app = new EcopagesApp({ appConfig });
 
// Middleware that extends context
const authMiddleware: BunMiddleware<{ session: Session }> = async (ctx, next) => {
	const session = await getSession(ctx.request.headers);
	if (!session) throw HttpError.Unauthorized();
	ctx.session = session;
	return next();
};
 
// Static routes (use loader pattern for HMR support)
app.static('/posts', () => import('./src/views/post-list'));
app.static('/posts/:slug', () => import('./src/views/post-view'));
 
// Public API
app.group('/api/v1', (r) => {
	r.get('/posts', async (ctx) => {
		const posts = await getPosts();
		return ctx.json(posts);
	});
});
 
// Protected API with validation and typed context
app.group('/api/v1/admin', (r) => {
	r.post('/posts', async (ctx) => {
		const { title, content } = ctx.body;
		const post = await createPost({
			title,
			content,
			authorId: ctx.session.userId // Fully typed from middleware!
		});
		return ctx.json(post, { status: 201 });
	}, {
		schema: {
			body: z.object({
				title: z.string().min(1),
				content: z.string()
			})
		}
	});
}, { middleware: [authMiddleware] });
 
// Global error handler
app.onError((error, ctx) => {
	if (error instanceof HttpError) {
		return ctx.json(error.toJSON(), { status: error.status });
	}
	return ctx.json({ error: 'Internal Server Error' }, { status: 500 });
});
 
await app.start();