Skip to main content

Client Routes

evjs uses src/pages as the client-routing source of truth. Application code lives in page files; the framework discovers those files and either builds one framework-owned SPA or one router-free MPA page per file. evjs does not write temporary runtime route files; SPA mode only emits a type declaration such as src/evjs-route-types.d.ts so TypeScript can infer navigation paths from the page tree.

Project Structure

src/
├── api/*.server.ts # Optional server functions
├── layout/
│ └── index.tsx # Optional SPA root layout
└── pages/
├── index.tsx # /
├── about.tsx # /about
├── users/$userId.tsx # /users/$userId
└── posts/index.tsx # /posts

Dynamic route segments use $param filenames. Bracket segments such as [id].tsx or [...slug].tsx are rejected so the file convention stays unambiguous. Catch-all and optional segments are not part of the page route convention yet, so $...slug.tsx, $slug?.tsx, and $.tsx are also rejected. Dynamic param names must be JavaScript identifiers after $, such as $userId.tsx or $team_id.tsx, but reserved object-property names such as $__proto__.tsx, $constructor.tsx, and $prototype.tsx are rejected. $_splat.tsx is also reserved because wildcard routes expose * as _splat. Static route segments must be lowercase and URL-safe: lowercase letters, numbers, ., _, -, or ~; use explicit pages config when a file needs to map to a custom or case-sensitive path. A route path must not repeat a dynamic param name, so teams/$teamId/users/$teamId.tsx is rejected. Dynamic sibling routes that only differ by parameter name are also rejected: users/$id.tsx and users/$userId.tsx both match /users/:param, so keep one canonical name or use explicit pages config.

Route group segments such as (marketing)/about.tsx are not supported; use a real URL segment such as marketing/about.tsx, or use explicit pages config when a file should map to a URL that does not follow the directory shape.

Route discovery treats .tsx, .jsx, .ts, and .js files as possible page modules. Declaration files (.d.ts), test files (*.test.* and *.spec.*), Storybook files (*.story.* and *.stories.*), *.client.* client-only modules, *.server.* server-only modules, hidden dot files/folders, and files without those source extensions are ignored.

Files or folders whose route segment starts with _ are private to src/pages. They can use source extensions, but they are ignored as URL routes. Use them for page-local components, helpers, or drafts that should not become URLs.

Route order is deterministic in both SPA and MPA mode: / comes first, parent routes come before child routes, and static siblings rank before dynamic siblings. For example, src/pages/users/settings.tsx is ordered before src/pages/users/$id.tsx. The resolved route list used by graph and build-plan generation is normalized with the same rule, and duplicate paths, dynamic URL shapes, or route IDs are rejected there too. routing.routes is not a public defineConfig() field; applications should use src/pages discovery or explicit pages config. Runtime route matching also uses specificity, so exact/static routes win over dynamic or wildcard routes even if an external manifest is not already sorted.

Generated route IDs must be unique. evjs derives IDs from URL paths by normalizing separators and punctuation to underscores, so routes such as src/pages/admin/panel.tsx and src/pages/admin_panel.tsx are rejected together because both produce admin_panel. Server-rendered route-derived page IDs use the same rule. When generated IDs collide, rename one route file or move the page to explicit pages config with a unique page id.

Every discovered route file must default-export a React component. If a module under src/pages is not a route page, put it in an underscore-prefixed file or folder, name it *.client.* for client-only code, name it *.server.* for server-only code, or move it outside src/pages. Syntax and default-export errors are reported during route discovery before the bundler runs.

SPA routing is enabled automatically when src/pages exists and the project does not declare explicit app, pages, or remote config. To opt in explicitly or customize discovery:

// ev.config.ts
import { defineConfig } from "@evjs/ev";

export default defineConfig({
routing: {
mode: "spa",
dir: "./src/pages",
mount: "#app",
},
});

For an MPA, use the same page files and switch the output mode:

// ev.config.ts
import { defineConfig } from "@evjs/ev";

export default defineConfig({
routing: {
mode: "mpa",
},
});

In MPA mode every discovered CSR page is emitted as an independent HTML document and client entry. File-route pages that export render = "ssg" emit an independent static HTML document and a server renderer for static generation; by default they do not create a browser page entry. No client router setup is added.

Pages

Each page module exports a default React component. Use the page hooks when page logic needs the current route params, search params, or loader data:

// src/pages/users/$userId.tsx
import { usePageParams, useQuery } from "@evjs/client";
import { getUser } from "../../api/users.server";

export default function UserPage() {
const { userId } = usePageParams();
const { data: user } = useQuery(getUser, userId);
if (!user) return null;
return <h1>{user.name}</h1>;
}

Use page hooks for route data in both SPA and MPA mode. They keep page modules free of framework wrapper types and avoid prop annotations. evjs does not pass params, search, or loaderData as page component props. File routes derive params from $param segments; lower-level explicit manifest routes can also use :param segments, and wildcard * segments are exposed as _splat. Empty param names, reserved object-property names, explicit :_splat params, and duplicate param names are rejected there too. A route path can contain at most one wildcard segment because there is only one _splat value. The same hooks expose those names.

In SPA projects with generated route types, page hooks can take a literal route path for route-specific inference without importing the generated declaration:

import { usePageLoaderData, usePageParams, usePageSearch } from "@evjs/client";

export const validateSearch = (search: Record<string, unknown>) => ({
tab: typeof search.tab === "string" ? search.tab : "overview",
});

export async function loader() {
return { title: "Post" };
}

export default function PostPage() {
const params = usePageParams("/posts/$postId");
const search = usePageSearch("/posts/$postId");
const post = usePageLoaderData("/posts/$postId");
return <h1>{post.title}: {params.postId} ({search.tab})</h1>;
}

In SPA mode, page modules may export page lifecycle hooks that are useful for page logic, such as loader, beforeLoad, validateSearch, pendingComponent, errorComponent, and notFoundComponent. evjs attaches those exports to the framework-managed route. In MPA mode these lifecycle hooks are ignored; use normal component/data logic in the page.

// src/pages/search.tsx
import { usePageSearch } from "@evjs/client";

export const validateSearch = (search: Record<string, unknown>) => ({
q: typeof search.q === "string" ? search.q : "",
});

export default function SearchPage() {
const search = usePageSearch();
const q = typeof search.q === "string" ? search.q : "";
return <h1>Search: {q}</h1>;
}

Layout

For SPA mode, the root layout is optional. It lives beside the route directory: the default src/pages uses src/layout/index.tsx, and a custom routing.dir such as src/app/pages uses src/app/layout/index.tsx. When present, the default export wraps the current page as children, so user code does not need a router outlet component.

If a migration has a shared application shell somewhere else, configure it explicitly with routing.layout: "./src/shell/AppLayout.tsx". Do the same for non-TSX layout modules such as layout/index.jsx or layout/index.js; the auto-discovered convention remains only layout/index.tsx. Set routing.layout: false when the SPA should not consume any framework root layout.

The layout convention is SPA-only and has exactly one root directory entry beside the route directory: use the exact path layout/index.tsx. layout.tsx, layout.jsx, layout.ts, and non-TSX layout/index.* files are not aliases. MPA mode does not accept or consume a framework layout file; share visual wrappers by importing ordinary components from each page, or share the HTML template when only document chrome is common.

The route directory is only for route pages. Do not put files or folders named layout under it; evjs reports that as a convention error instead of turning them into routes. This reserved segment is exact and case-sensitive, but uppercase filenames such as Layout.tsx still fail the lowercase static segment rule in discovered routes. Nested visual wrappers should be normal components imported by the page that needs them. This remains true when routing.layout points at an explicit module or when layout discovery is disabled with routing.layout: false or MPA mode.

// src/layout/index.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<main>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
{children}
</main>
);
}

Navigation can use ordinary anchors or Link from @evjs/client. Route files remain the source of truth, and navigation helpers use the same file-path convention for paths and params.

During ev dev and ev build, SPA routing writes the generated declaration src/evjs-route-types.d.ts for the default src/pages route directory. A custom routing.dir writes the same file name beside that route directory's parent. That file augments the underlying @evjs/client route register used by the @evjs/client Link, useLinkProps, redirect, and related helpers. It is type-only; application code should not import it or write framework router bootstraps manually.

The generated file imports its type helper from @evjs/client/internal/route-types, a generated-only internal subpath. Do not import that internal helper from application source.

The declaration preserves each route's literal ID and path for navigation types. Its internal TypeScript identifiers are de-duplicated automatically, so valid route IDs such as admin-panel and admin_panel cannot generate invalid or duplicate declarations.

Make sure the generated declaration is inside your tsconfig.json include. The default include: ["src"] works for src/pages and custom directories under src, such as src/app/pages. If you place routes outside src, include that route directory's parent as well.

import { Link } from "@evjs/client";

export default function HomePage() {
return (
<Link to="/users/$userId" params={{ userId: "1" }}>
Open user
</Link>
);
}

Rendering Metadata

Page modules can continue to own rendering metadata:

export const render = "ssr";
export const hydrate = "load";
export const prerender = { partial: true } as const;

export default function CampaignPage() {
return <main>Campaign</main>;
}

The build graph reads that metadata from the page module and links it to the discovered route. render and hydrate must be string literals, prerender must be true or an object literal with partial, delivery, or revalidate, prerender.revalidate must be false or a positive integer number of seconds, and rsc must be a boolean literal. Full prerendering (prerender = true or non-partial prerender objects) must declare render = "ssg" or render = "ssr". Partial prerendering must declare render = "ssr".

Use export const rsc = true only for RSC pages that also declare render = "ssr" and omit hydrate or declare hydrate = "none". RSC pages cannot also use partial prerendering yet; choose one rendering model per route or split them into separate routes. rsc = false has no effect and produces a warning; remove it unless you are enabling RSC with true. Export each metadata name only once; duplicate render, hydrate, prerender, or rsc exports are rejected instead of using source order.