---
title: "Explicit Routing"
description: "Take full control of application routes with explicit server-side routing."
order: 4
---

# 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](./routing-patterns).

## Basic Routes

Define routes using HTTP method functions on your app instance:

```typescript
import { createApp } from '@ecopages/core/create-app';
import appConfig from './eco.config';

const app = await createApp({ 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:

```typescript
app.get('/posts/:slug', async (ctx) => {
	const { default: PostView } = await import('./src/views/post-view');
	const post = await getPost(ctx.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):

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

### Response Helpers

```typescript
// 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:

```typescript
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:

```typescript
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

```typescript
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:

```typescript
import type { EcoMiddleware } from '@ecopages/core';

const authMiddleware: EcoMiddleware<{ 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:

```typescript
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

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

## Request Validation

EcoPages supports [Standard Schema](https://github.com/standard-schema/standard-schema) for type-safe request validation. Use any compatible library: Zod, Valibot, ArkType, or Effect Schema.

### Basic Validation

```typescript
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` |
| `params` | Route path parameters | `ctx.params` |

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`.

### Path Params Validation

```typescript
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

```typescript
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:

```typescript
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:

```typescript
// 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:

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

## Error Handling

### HttpError

Throw `HttpError` for standard HTTP errors:

```typescript
import { HttpError } from '@ecopages/core/errors';

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:

```typescript
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:

```typescript
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:

```typescript
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`:

```typescript
// 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 rendered via dynamic import must be the default export:

```typescript
// 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:

```typescript
const app = await createApp({ 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

```typescript
import { createApp } from '@ecopages/core/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 { EcoMiddleware } from '@ecopages/core';

const app = await createApp({ appConfig });

const authMiddleware: EcoMiddleware<{ 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();
```
