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:
.tsx— Ecopages JSX (default in Installation).kita.tsx— KitaJS.tsxwith React when React owns the route.lit.tsx— Lit.mdx— MDX.html— HTML Pages, no integration needed
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/teamsrc/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 -> /usersThe .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
app.tsroutes, thenapp.static()routes- File-based pages and endpoint files in
src/pages, unless the path ends in a known static extension - Static files from
publicand 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.tsStartup 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
src/pages/<status>.*(404.*,500.*, ...) — highest priority when the file exists.app.notFound()/app.serverError()— imported views for explicit-route apps without apages/error file.- 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
pageLocalsobject instead of request-scopedlocals. Do not usecache: 'dynamic',middleware, orrequireson404.*/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
| Type | Definition | Usage |
|---|---|---|
| Static pages | src/pages/*.{ext} | Marketing, docs, blogs |
| Dynamic pages | src/pages/[slug].{ext} | Posts, profiles |
| Error pages | src/pages/{400,401,403,404,409,500}.* | Error HTML per status |
| Endpoint files | src/pages/**/*.api.{ts,tsx,js,jsx} | Data, forms, webhooks |
app.ts routes | app.get('/api/...') | Data, forms |