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:
- Flexibility: Structure your code however you prefer
- Control: Clear overview of all routes in one place
- Freedom: Choose your own architecture (MVC, handlers, services)
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
| Target | Description | Access |
|---|---|---|
body | Request body (JSON) | ctx.body |
query | Query string parameters | ctx.query |
headers | Request headers | ctx.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();