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 app instance:

import { createApp } from '@ecopages/core/create-app';
 
const app = await createApp();
 
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

Route handlers render eco.page views with ctx.render(View, props). Import the view: an Integration-owned file becomes a view reference, so development reloads on save and production reads the built module.

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

Error pages without pages/

Explicit-route apps can register not-found and server-error views on the app instance. Filesystem src/pages/404.* and 500.* still win when they exist.

import NotFoundView from './src/views/not-found.kita';
import ServerErrorView from './src/views/server-error.kita';
 
app.notFound(NotFoundView)
app.serverError(ServerErrorView)

When neither a pages error file nor an app loader is configured, Ecopages serves built-in HTML defaults with inline CSS only (not your app Tailwind or PostCSS pipeline). Those pages expose stable hooks for documentation and debugging:

HookUsage
eco-error-pageRoot <body> class on built-in pages
eco-error-page--not-found / eco-error-page--server-errorStatus modifier
eco-error-page__title, eco-error-page__message, eco-error-page__stackText regions
eco-error-page__copyDevelopment-only copy button on the default 500 page (icon + label; adds eco-error-page__copy--copied after a successful copy)
eco-error-page__copy-icon, eco-error-page__copy-labelClipboard / check icons and label text inside the copy button

To match your site chrome, register eco.page views with app.notFound() / app.serverError() and style them like any other view. Common conventions:

  • Wrapper: error404 or error500
  • Stack trace: error500__stack
  • Actions row: error500__actions

Use your layout (for shared header/footer), Tailwind utility classes, and/or dependencies.stylesheets for a dedicated CSS file. The playground/explicit-routes app registers styled views under src/views/.

In development, the built-in 500 page includes a Copy error button with escaped message and stack text. Custom 500 views receive the same props from the runtime when a render failure occurs.

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 EcoMiddleware helper type to define middleware that adds properties to the context:

import type { ApiHandlerContext, EcoMiddleware } from '@ecopages/core';
 
const authMiddleware: EcoMiddleware<ApiHandlerContext & { 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:

import AdminDashboard from './src/views/admin-dashboard.tsx';
 
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({ 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]
});

In app.get() and its siblings the handler comes before options, so declare every middleware in the list as a Middleware or EcoMiddleware constant. One inline function in the middleware list makes the handler lose the context of every middleware in that list, constants included; the inline function itself is typed with the base context.

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
paramsRoute path parametersctx.params

Each target the schema declares holds the validated data, typed from the schema. A target the schema omits keeps its unvalidated value: ctx.query holds the parsed query string and ctx.params the matched Params, while ctx.body and ctx.headers are unset. For raw access, use ctx.request.body, ctx.request.url, and ctx.request.headers.

Path Params Validation

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

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, and a key repeated in the URL (?tag=a&tag=b) is an array of its values. 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')
		})
	}
});

A schema written for a single value, such as z.string() or z.coerce.number(), answers HTTP 400 when the client repeats that key, because the handler receives an array. Use z.union([z.string(), z.array(z.string())]) to accept both shapes, or z.array(z.string()) for a key that is always a list.

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';
import PostView from './src/views/post-view.tsx';
 
app.get('/posts/:slug', async (ctx) => {
	const post = await getPost(ctx.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. It receives errors thrown by API handlers and their middleware, and unexpected errors that escape request handling. Page render failures render your error page instead and do not reach onError.

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 imported views for build-time static generation:

import PostListView from './src/views/post-list.kita';
import PostView from './src/views/post-view.kita';
 
app.static('/posts', PostListView);
app.static('/posts/:slug', PostView);

Import the view and pass it to app.static(). Development reloads it through the app module loader; production reads the built module. A full explicit view uses the same route pipeline as a filesystem Page: every layout, default metadata, robots meta, and that view's own page client plan. The props passed to the render are available to the view's metadata, and the render does not run the view's staticProps. Handler renders (ctx.render) also pass the request's locals, route params and query to the view. Planning a dynamic explicit route resolves the renderer without calling staticProps; staticProps runs once with the slug's params before the view renders. 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 {
			paths: posts.map((post) => ({ params: { slug: post.slug } })),
		};
	},
 
	staticProps: async ({ params }) => {
		const post = await getPost(params.slug);
		return { props: { 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 ctx.render() 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: ({ props }) => ({
		title: `${props.title} | My Blog`,
		description: `By ${props.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 = await createApp();
 
// 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();

An app.get() on an endpoint file's URL answers GET and the file answers its other methods; startup warns. See Routing.

Complete Example

import { createApp } from '@ecopages/core/create-app';
import { HttpError } from '@ecopages/core/errors';
import { z } from 'zod';
import { PostView, PostListView } from './src/views';
import type { ApiHandlerContext, EcoMiddleware } from '@ecopages/core';
 
const app = await createApp();
 
const authMiddleware: EcoMiddleware<ApiHandlerContext & { session: Session }> = async (ctx, next) => {
	const session = await getSession(ctx.request.headers);
	if (!session) throw HttpError.Unauthorized();
	ctx.session = session;
	return next();
};
 
app.static('/posts', PostListView);
app.static('/posts/:slug', PostView);
 
// 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();