File Conventions
evjs uses a small set of explicit file markers. A page.* file creates a page
and client route; an api.* file creates an API route. In both trees, the
containing directory determines the URL and groups the related source files.
For the complete matrix, see Project Structure.
Convention roots
| Root | Purpose |
|---|---|
src/pages | File-based pages and client routes. |
src/apis | File-based API routes. |
src/middlewares/middleware.* | Entry for explicitly ordered global middleware. |
| Imported source modules | Server functions that begin with "use server";. |
Page files, API route files, and the global middleware anchor are enabled or
disabled together. Top-level conventions: false turns off all of them; there
are no separate switches. It cannot be combined with a routing declaration.
When conventions are enabled, pages stay under src/pages and API routes stay
under src/apis.
Imported "use server"; modules, SPA-only application.routes
configuration, and modules generated by plugins are not controlled by this
switch.
The directory of each page.* file determines its client URL. routing.mode
builds the same page tree as either an SPA or an MPA.
Global styles
Global styles are ordinary source modules, with no special filename or directory convention. Import them explicitly from the root application layout, a page, or a shared component:
import "./global.css";
Less variables and mixins follow the same rule. Import their module explicitly from each Less module that consumes them:
@import "./tokens.less";
Pages and client routes
A page and its client route share one file marker:
src/pages/**/page.{ts,tsx,js,jsx}
export default defineConfig({
routing: {
mode: "spa",
},
});
src/pages/
├── page.tsx # /
├── page.config.ts # optional build-time config for /
├── home/
│ ├── page.tsx # /home
│ └── components/
│ ├── Hero.tsx
│ └── index.tsx # private source, not another page
└── users/
└── $userId/
├── page.tsx # /users/:userId
├── index.ts
├── model.ts
└── components/Profile.tsx
Rules:
- exactly one supported
page.*variant is allowed in a route directory; - directory segments relative to
src/pagesdetermine the URL; - the containing directory groups source owned by that page;
- every other file, including
index.*, is ordinary page source; - a descendant
page.*intentionally creates a nested page and route; - the same URL pattern cannot be declared by two page files;
- page entries default-export their component.
Page-specific code needs no _ prefix. Here, private means “not discovered as
another route,” not a security boundary.
Discovery is based on explicit filenames. src/pages/home/components/index.tsx
remains ordinary source because it is not named page.*; a
src/pages/home/components/page.tsx would intentionally create
/home/components.
An underscore does not create a private route segment. _components/Card.tsx
is ordinary source because it is not named page.*, while
_private/page.tsx produces an invalid-static-segment diagnostic instead of
being silently ignored. Static URL segments must start with a letter or number.
Page configuration
A page directory can include one optional page.config.ts or
page.config.js. Prefer the TypeScript form:
import { definePageConfig } from "@evjs/ev";
export default definePageConfig({
title: "Home",
meta: {
description: "The application home page.",
keywords: "home,evjs",
viewport: "width=device-width, initial-scale=1",
"theme-color": "#ffffff",
},
render: "csr",
plugins: {
analytics: {
channel: "home",
},
},
});
The module is evaluated synchronously at build time. It default-exports a plain
object containing static JSON data. Framework fields include title, named
meta, render, hydrate, prerender, and rsc; installed plugins that
support page configuration use their ids under plugins. Omitted render
always means CSR, which must omit
hydrate; explicit SSR/SSG pages may select "load" or "none". meta maps
string keys and values only to
<meta name="key" content="value">. It does not provide property, charset,
links, scripts, dynamic metadata, or a general head DSL. Core title/meta are
applied to the page; a plugin decides how its page values affect runtime code.
Page HTML
The application uses top-level index.html by default; routing.html can
select another shared template. In MPA mode, a page's colocated index.html
overrides its HTML template. It never becomes a client entry, and SPA mode does
not treat it as a route. Page title and meta add missing tags and override
the matching template title and meta[name] values; omitted values keep the
template defaults.
Client path segments
Client paths come from route directories:
| Directory segment | Meaning |
|---|---|
users | Static segment. |
$userId | Dynamic :userId segment. |
$...splat | Terminal catch-all. |
(account) | Pathless organization group. |
src/pages/
├── page.tsx # /
├── users/
│ ├── page.tsx # /users
│ └── $userId/
│ └── page.tsx # /users/:userId
├── files/
│ └── $...splat/
│ └── page.tsx # /files/*
└── (account)/
└── settings/
└── page.tsx # /settings
SPA builds a browser route tree. MPA starts from the same pages and creates an
HTML document for each static path. MPA rejects $param, terminal
$...splat, and browser-router-only boundaries; layouts work in both modes.
Server functions
Server functions have no fixed directory. The build follows modules imported by pages, layouts, wrappers, and server code.
A server-function module:
- starts with
"use server";; - exports named function declarations or named
constfunction expressions; - does not default-export;
- does not runtime re-export functions from another module.
"use server";
export async function getUser(userId: string) {
return { id: userId };
}
Use .server.ts or .server.tsx when colocating a server function inside a
page directory so the server boundary is obvious to developers and tooling.
API routes
API routes are discovered from api.* files under the fixed src/apis root.
This convention is separate from the client page.* tree but follows the same
directory-based URL model.
src/apis/
├── api.ts # /
├── api/
│ ├── health/
│ │ └── api.ts # /api/health
│ └── users/
│ ├── api.ts # /api/users
│ ├── schema.ts # private source
│ └── $userId/
│ └── api.ts # /api/users/:userId
└── (internal)/
└── metrics/
└── api.ts # /metrics
Server path segments
| Directory segment | URL meaning |
|---|---|
$userId | Dynamic parameter. |
(internal) | Pathless organization group. |
| ordinary safe name | Static URL segment. |
The api.* filename contributes no URL segment. Catch-all, optional, and
bracket directory syntaxes are not supported. Static directory segments must
start with a lowercase letter or number. Invalid segments are diagnosed only
when their tree contains an api.* file.
Route handler exports
Only src/apis/**/api.{ts,tsx,js,jsx} creates a route, and each route directory
may contain exactly one source-extension variant. The file exports at least one
uppercase HTTP method:
export function GET() {
return Response.json({ ok: true });
}
export async function POST(request: Request) {
const body = await request.json();
return Response.json(body, { status: 201 });
}
Supported methods are the framework's documented uppercase HTTP handlers.
Handlers may be declared locally, imported from route-private modules,
re-exported, or created by a factory. Discovery rejects values that are already
statically known to be non-callable; the generated createRoute() definition
validates every evaluated handler before the server starts. Generators, default
exports, lowercase method names, helper exports, and route-module middleware
exports are invalid in an api.* file. Every other filename is ordinary source
for that route and does not publish an endpoint, regardless of its exports.
Server route conflicts
The build rejects:
- two files for the same normalized URL;
- multiple
api.*source-extension variants in one route directory; - two parameter names for the same dynamic shape, such as
$idand$userId; - unsafe or malformed group/dynamic segments;
- generated route-id collisions;
- route modules that mix unsupported exports into the route contract;
- a server request Route pattern that intersects a URL-owning Page or redirect pattern, or an active framework runtime endpoint.
Static route aliases are compared after exactly one URL decode during conflict
checks. For example, /%75sers and /users claim the same request path, while
double-encoded text remains distinct.
index.ts, route.ts, and foo.get.ts do not create routes.
Server middleware
Global middleware is declared in one composition anchor:
src/middlewares/
├── middleware.ts
├── tracing.ts
└── authentication.ts
src/middlewares/middleware.*default-exports one global middleware or an explicitly ordered non-empty list; TypeScript lists should usesatisfies MiddlewareChain, importing the type from@evjs/ev/middleware.- Other files in
src/middlewaresare ordinary modules imported bymiddleware.*and are not ordered by filename.
The anchor allows .ts, .tsx, .js, or .jsx, with exactly one variant in
src/middlewares. Use flat arrays; reuse chains with spread. Explicit empty array
exports, holes, non-functions, generators, and runtime named exports are rejected.
Computed global chains may resolve to [] when disabled.
An api.* exports only uppercase HTTP methods. Use
withMiddlewares(handler, middlewares) from @evjs/ev/api to compose each
method's policies, importing shared chains from ordinary modules. Automatic
HEAD uses GET's chain; automatic OPTIONS and 405 responses run global middleware.
See API Routes and Middleware.
Generated files
The framework may generate:
.ev/**framework-generated intermediate files and entries;src/route-types.d.tsfrom SPA file routes when supported;src/plugin-types.d.ts, which connects plugin types to the project'sev.config.ts;dist/**build output.
Do not edit or scaffold these files. Keep them ignored.
Alternative routing inputs
File-based routing does not require a route reader or provider. An application
declares routing.mode; the presence of src/pages alone does not enable
client routing.
Explicit SPA route configuration
application cannot be combined with routing. The explicit route
configuration accepts application.routes plus page or
component, nested routes, layout, wrappers, and redirect.
application.pageRoot controls only reference resolution for this explicit
input and does not change the fixed src/pages convention root. It rejects
children; nested declarations use routes. exact: true is accepted only
as a terminal-match assertion; exact: false and nested routes below an exact
route are rejected. Plugin configuration remains page-specific; explicit route
and document objects do not expose plugin configuration. Shared template and
mount values live under application.document. This configuration supports
only SPA. A page reference resolves to one page.* file. An explicit
component ending in index.* or page.* uses its containing directory as
its source root; other component filenames are module-scoped and do not use an
adjacent page.config.ts.
File-based page tree
routing.mode discovers only page.* files. Each page entry lives in the
directory for its URL; page settings live in adjacent page.config.ts files.
Page-specific helpers may use any other filename, including index.*, without
creating another route. Parameters, terminal catch-alls, and pathless groups
use $param, $...splat, and (group) directories. Run ev inspect to
review resolved pages, routes, documents, page configuration, and diagnostics.