Routing

Ecopages uses file-based routing for pages and endpoint files, and Explicit Routes in app.ts.

File-Based Routing

Every file in your src/pages directory corresponds to a route. The path relative to src/pages determines the URL.

Supported File Extensions

Extensions depend on your active integrations:

Each route must come from exactly one file, whatever its parameter names. For example, about.html next to about.tsx, about.tsx next to about/index.tsx, or blog/[slug].tsx next to blog/[id].tsx, is an error. HTML Pages cannot use dynamic segments such as [slug].html.

Index Routes

  • src/pages/index.tsx → /
  • src/pages/blog/index.tsx → /blog

Nested Routes

  • src/pages/about/team.tsx → /about/team
  • src/pages/v1.2.html → /v1.2

A dot in the last segment is part of the route. The server treats a path as a file only when it ends in a known static extension, such as .js, .css, .png, or .html.


Dynamic Routes

Use bracket syntax: [param].

  • src/pages/blog/[slug].tsx → /blog/[slug]
  • src/pages/users/[id]/profile.tsx → /users/[id]/profile

Access params via staticPaths and staticProps on eco.page views, or at request time with cache: 'dynamic'.

Catch-all Routes

  • src/pages/docs/[...slug].tsx → /docs/a, /docs/a/b, /docs/v1.2

A catch-all also matches a path with a dot, such as /docs/v1.2, unless that path ends in a known static extension. A path that does end in one of those extensions is a file, so a catch-all does not render it.

For a Page request to /docs/a/b, params.slug is ['a', 'b']. A single [slug] segment stays a string. Repeated query keys are arrays, so ?tag=docs&tag=api becomes query.tag === ['docs', 'api'] on page props and route info.


Endpoint Files

A file under src/pages whose name ends in .api.ts, .api.tsx, .api.js or .api.jsx is an endpoint file, not a page. Its URL follows the same rules as a page: drop the whole .api.* ending, drop index, keep the brackets. api/ has no special meaning; it is just a folder.

src/pages/health.api.ts                ->  /health
src/pages/api/users/index.api.ts       ->  /api/users
src/pages/api/users/[id].api.ts        ->  /api/users/[id]
src/pages/api/docs/[...slug].api.ts    ->  /api/docs/[...slug]
src/pages/blog/[slug]/comments.api.js  ->  /blog/[slug]/comments
src/pages/users.api.tsx                ->  /users

The .api.* endings are reserved: an Integration cannot register one as a page extension, and the config fails with an error naming the Integration that does. Registering .tsx is fine; users.api.tsx is still an endpoint file, not a .tsx page.

Endpoint files use brackets ([id], [...slug]); :id is for app.ts routes. A request to /api/users/5 gives params.id === '5', and a catch-all param is an array. Two files on the same route fail at startup, whatever the parameter names: users.tsx beside users.api.ts, or blog/[slug].tsx beside blog/[id].api.ts.

See Endpoint Files for method exports, 405 and Allow, schemas, middleware, and what dev and build do with the files.


Routes in app.ts

app.ts routes are defined on your app instance:

import { createApp } from '@ecopages/core/create-app';
 
const app = await createApp();
 
app.get('/api/users/:id', async ({ params, response }) => {
	return response.json({ userId: params.id });
});
 
app.get('/api/files/[...path]', async ({ params, response }) => {
	return response.json({ path: params.path });
});
 
await app.start();

Use app.add(handler) for handlers created with defineApiHandler. See Define Handlers.

An app.ts catch-all keeps each decoded path segment in an array. A request to /api/files/docs/a gives params.path === ['docs', 'a']. A named segment such as :id remains a string. Repeated query keys also stay as arrays on ctx.query; schemas receive the same parsed values before validation.

Route Priority

  1. app.ts routes, then app.static() routes
  2. File-based pages and endpoint files in src/pages, unless the path ends in a known static extension
  3. Static files from public and the build output

An app.ts route registered on an endpoint file's URL keeps the methods it registers, and the file answers the rest. :slug and [slug] name the same URL.

// app.ts
app.get('/blog/:slug/comments', handler); // GET goes here
// POST /blog/:slug/comments still goes to src/pages/blog/[slug]/comments.api.ts

Startup logs a warning naming the app.ts route and the endpoint file.

app.ts routes are matched before any file, so app.get('/api/users/:id') also answers GET /api/users/me when api/users/me.api.ts exists. Startup warns only when both name the same route.


Route Configuration

Set the canonical base URL in eco.config.ts:

import { defineConfig } from '@ecopages/core/config';
import { ecopagesJsxPlugin } from '@ecopages/ecopages-jsx';
 
export default defineConfig({
	rootDir: import.meta.dirname,
	baseUrl: 'https://example.com',
	integrations: [ecopagesJsxPlugin()],
});

404 Page

Create 404.tsx (or the extension of your integration, or 404.html) under src/pages. Unmatched page requests render this template with status 404.

500 Page

Create 500.tsx (or the extension of your integration, or 500.html) under src/pages. When a page render fails in the file-route pipeline, Ecopages renders this template with status 500.

400, 401, 403 and 409 Pages

A Page that throws HttpError.BadRequest(), HttpError.Unauthorized(), HttpError.Forbidden() or HttpError.Conflict() (from @ecopages/core/errors) answers with that status and renders src/pages/400.*, 401.*, 403.* or 409.*. Each follows the precedence below, with app.badRequest(), app.unauthorized(), app.forbidden() and app.conflict() as the app-registered views; app.errorPage(status, view) registers any of the six.

Precedence

  1. src/pages/<status>.* (404.*, 500.*, ...) — highest priority when the file exists.
  2. app.notFound() / app.serverError() — imported views for explicit-route apps without a pages/ error file.
  3. Built-in HTML defaults — basic pages shipped by the framework when neither of the above is configured. They include inline CSS and eco-error-page* class hooks; they do not load your app stylesheets.

Custom error pages (filesystem or app.notFound() / app.serverError()) use your layout and assets. Typical class names are error404, error500, and error500__stack with optional dependencies.stylesheets.

500 Page behavior

Important:

  • Applies to page rendering (matched routes and a failing custom 404). API handlers keep their own errorHandler / JSON response path.
  • If the custom 500.* template or app loader is missing or throws, the response falls back to the built-in HTML error page. The custom page is never retried.
  • In development, the thrown error is passed as page props (message, stack) so the custom 500 page can show the real failure. In production, those props are omitted so stacks are not serialized into HTML. The original error is always logged server-side.
  • Like 404.*, the file is also a normal route at /500, so you can open it directly to preview the design (that direct visit is a 200 and has no error props unless you trigger a render failure).
  • Semantic error templates render outside the normal page-request middleware pipeline. They receive a safe empty pageLocals object instead of request-scoped locals. Do not use cache: 'dynamic', middleware, or requires on 404.* / 500.* templates.
import { eco, type ErrorPageTemplateProps } from '@ecopages/core';
 
export default eco.page<ErrorPageTemplateProps>({
	render: ({ message, stack }) => (
		<div>
			<h1>Something went wrong</h1>
			{message ? <p>{message}</p> : null}
			{stack ? <pre>{stack}</pre> : null}
		</div>
	),
});

Summary

TypeDefinitionUsage
Static pagessrc/pages/*.{ext}Marketing, docs, blogs
Dynamic pagessrc/pages/[slug].{ext}Posts, profiles
Error pagessrc/pages/{400,401,403,404,409,500}.*Error HTML per status
Endpoint filessrc/pages/**/*.api.{ts,tsx,js,jsx}Data, forms, webhooks
app.ts routesapp.get('/api/...')Data, forms