Skip to main content

Project Structure

evjs applications should use page routes as the default client boundary. For documentation and new applications, use one complete structure and delete folders that the app does not need yet.

my-evjs-app/
├── ev.config.ts # framework config
├── index.html # shared HTML template with <div id="app">
├── package.json
├── .gitignore # ignores evjs generated artifacts
├── public/ # copied static files
├── tsconfig.json
└── src/
├── server.ts # framework/server entry
├── styles.css # global CSS / Tailwind entry
├── layout/
│ └── index.tsx # optional SPA root layout
├── pages/ # page routes
│ ├── index.tsx # /
│ ├── dashboard.tsx # /dashboard
│ ├── campaign.tsx # /campaign
│ ├── insights.tsx # /insights
│ └── users/$userId.tsx # /users/$userId
├── api/
│ ├── operators.server.ts # "use server" functions
│ └── health.routes.ts # Request/Response route handlers
├── components/ # reusable UI
├── features/ # domain modules
│ └── operations/
│ ├── components/
│ ├── hooks/
│ ├── model.ts
│ └── types.ts
├── lib/ # browser-safe shared helpers
└── hooks/ # app-wide React hooks

This shape covers the complete framework surface:

  • ev.config.ts customizes routing mode, server paths, remotes, plugins, or explicit page outputs only when defaults are not enough.
  • pages/ is the client route source of truth. SPA mode maps it to a framework-owned app entry; MPA mode maps it to independent page entries.
  • layout/index.tsx is only the optional SPA root layout beside the route directory. The default src/pages uses src/layout/index.tsx; custom routing.dir values use the parent of that route directory. This convention requires the exact layout/index.tsx path unless routing.layout points to another SPA layout source module. Use routing.layout for custom locations or non-TSX layout modules such as layout/index.jsx. Explicit layout modules must use .ts, .tsx, .js, or .jsx; declaration, test, spec, story, client-only, and server-only files are not accepted. Set routing.layout: false to disable SPA root layout discovery. MPA pages should import shared components directly or share HTML templates when they need common chrome.
  • <routing-dir-parent>/evjs-route-types.d.ts is generated in SPA mode for type-safe navigation. The default src/pages writes src/evjs-route-types.d.ts; routing.dir: "./src/app/pages" writes src/app/evjs-route-types.d.ts. MPA mode removes stale generated route type files. The generated declaration uses the generated-only @evjs/client/internal/route-types helper and augments the client runtime navigation types. Keep generated route types ignored and do not import them from application code.
  • .evjs/ is the generated framework working directory for dev/build metadata. Keep it ignored and out of templates, scaffolds, and application imports.
  • Rendering metadata lives with page modules.
  • api/*.server.ts contains server functions.
  • api/*.routes.ts contains standard HTTP route handlers.
  • server.ts composes @evjs/server routes, middleware, and framework rendering.
  • features/ keeps domain logic out of route/page files.

Convention Matrix

Use this table as the source of truth when creating files. Only a few paths are framework conventions; the rest are ordinary project organization.

Quick rules:

  • Route files live under the configured routing.dir and use .ts, .tsx, .js, or .jsx.
  • Directory roots use index.*; dynamic segments use $param; static segments stay lowercase and URL-safe.
  • Route groups such as (marketing) are not supported. Dynamic param names must be safe identifiers; reserved object-property names and $_splat are rejected.
  • _-prefixed files and folders are private helpers, not URL routes.
  • Dot-prefixed files/folders, .d.ts, test/spec, Storybook, *.client.*, and *.server.* files under the route directory are ignored so colocated support files do not become routes.
  • SPA root layout auto-discovery uses the exact layout/index.tsx path beside the route directory. Use routing.layout for custom SPA layout modules. MPA routes do not consume framework layouts.
  • If an output cannot follow the directory shape, use explicit pages config instead of hand-writing routing.routes.

Migration rules stay explicit rather than adding alternate filename dialects:

  • Rename bracket dynamic routes such as [id].tsx to $id.tsx.
  • Replace route groups such as (marketing)/about.tsx with a real URL segment, move the grouping outside routing.dir, or use explicit pages config.
  • Model nested layouts as ordinary components imported by the page that needs them; only one SPA root layout is discovered by the framework.
  • Use explicit pages config for catch-all, optional, case-sensitive, or other custom URL shapes.
File or folderFramework meaningUse it forDo not use it for
src/pages/**/*.{tsx,jsx,ts,js}SPA/MPA page route discoveryThin page components with optional literal rendering metadataShared helpers, tests, route groups, bracket routes, catch-all routes, or hand-written SPA router/bootstrap code
Route paths, dynamic URL shapes, and generated route IDs under src/pagesRoute collision checks before graph/build-plan generationOne page module per URL path, one parameter naming choice per dynamic URL shape, and unique generated route IDsParallel users.tsx/users/index.tsx, users/$id.tsx/users/$userId.tsx, or admin/panel.tsx/admin_panel.tsx routes
src/pages/_* and src/pages/**/_*Ignored private route modulesColocated helper components, utilities, fixtures, and page-local implementation detailsURL routes, SPA root layouts, or generated files
src/pages/.* and src/pages/**/.*Ignored hidden route modulesLocal scratch files or tool metadata that should stay invisible to route discoveryURL routes, generated route types, or source modules that should be imported by pages
src/pages/**/*.d.ts, src/pages/**/*.{test,spec,story,stories}.*, src/pages/**/*.{client,server}.*Ignored route support modulesType declarations, tests, Storybook stories, client-only modules, and server-only modules colocated with pagesRoute pages or files that should become URLs
src/layout/index.tsx or <routing-dir-parent>/layout/index.tsxOptional SPA root layoutOne SPA shell around discovered page routesMPA shared chrome or arbitrary nested layouts
<routing-dir-parent>/evjs-route-types.d.tsGenerated SPA navigation typesEditor and type-checker supportManual edits, imports from app code, template/scaffold source, or MPA mode
.evjs/Generated framework working directoryLocal dev/build metadataCommitted source, template/scaffold source, or application imports
src/api/*.server.tsRecommended server-function boundaryFiles that start with "use server"; and export named callable server functionsClient imports that should run with server: false, default exports, or runtime re-exports
src/api/*.routes.tsRecommended server-route boundarycreateRoute() handlers using Web Request/ResponseServer functions or multiple files for the same URL shape
src/server.tsFramework server entrycreateApp({ routes, middlewares, framework }) and deployment runtime glueBrowser code or per-page client bootstrap
Remote entry modules referenced by remote.entries.*.appRemote build public entriesDefault React components or explicit init/mount/hydrate/unmount lifecyclesHost remotes config or server-only implementation details
src/features, src/components, src/lib, src/hooksNo direct framework conventionDomain code, reusable UI, browser-safe helpers, and React hooksFiles that depend on route discovery by filename

Do not mix ownership models in one app unless you need the lower-level API:

  • Use src/pages plus routing for normal SPA/MPA page routes.
  • Use explicit pages config only when the output cannot be expressed by src/pages.
  • Use top-level entry/html only for a manually bootstrapped single browser app.
  • Use remotes in host apps and remote only in a package that emits evjs-remote.json.

Matching Config

The matching ev.config.ts can stay small:

import { defineConfig } from "@evjs/ev";

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

server: {
entry: "./src/server.ts",
rsc: true,
},

remotes: {
crm: {
manifest: "https://assets.example.com/crm/evjs-remote.json",
activeWhen: ["/crm/*"],
},
},
});

Use routing: { mode: "mpa" } when every route should be emitted as its own HTML document without SPA router setup or framework layouts. Use the lower-level pages config only for page outputs that do not map cleanly to src/pages.

Page Modules

Each discovered file under src/pages default-exports a React component. Dynamic segments use $param, and index.tsx maps to the directory root. Bracket route segments such as [id].tsx are rejected; catch-all and optional segments such as $...slug.tsx or $slug?.tsx are not part of the convention yet. Dynamic param names must be JavaScript identifiers after $, and static route segments must use lowercase URL-safe letters, numbers, ., _, -, or ~. Reserved object-property names such as $__proto__.tsx, $constructor.tsx, and $prototype.tsx are rejected as dynamic params. $_splat.tsx is also reserved because wildcard routes expose * as _splat. Dynamic siblings cannot differ only by parameter name, so choose one of $id.tsx or $userId.tsx for the same URL shape. A single route path also cannot repeat the same dynamic param name, so teams/$teamId/users/$teamId.tsx is rejected. Flat route files and directory index route files cannot claim the same URL path, so choose either users.tsx or users/index.tsx for /users. Route group segments such as (marketing) are not supported. Route discovery considers .tsx, .jsx, .ts, and .js files, but ignores declarations, test/spec files, hidden dot paths, Storybook *.story.* / *.stories.* files, *.client.* client-only modules, *.server.* server-only modules, non-source files, and _-prefixed private route segments. Put non-route helpers in _-prefixed files/folders or outside src/pages. SPA and MPA share one deterministic route order: / first, parent routes before children, and static siblings before dynamic siblings. For example, users/settings.tsx ranks before users/$id.tsx. Static siblings use locale-independent code-point ordering, so a-b.tsx, a.b.tsx, a0.tsx, a_c.tsx, aa.tsx, and a~d.tsx keep that same order on every machine. Route examples and config should use / separators; filesystem \ separators are normalized before route parsing so paths and generated route IDs stay the same across operating systems. The resolved route list used by graph and build-plan generation follows the same rules, so duplicate paths, dynamic URL shapes, route IDs, empty dynamic params, reserved dynamic params, duplicate dynamic params, explicit :_splat params, whitespace, query strings, or hashes are rejected there too. Explicit wildcard routes can contain at most one * segment because page hooks expose one _splat value. Generated route IDs also come from URL paths and normalize separators and punctuation to underscores, so admin/panel.tsx and admin_panel.tsx both produce admin_panel and cannot exist together. Rendering metadata belongs with the page component. Syntax and default-export errors are reported during route discovery before the bundler runs:

Route Filename Examples

FileResultNotes
src/pages/index.tsx/Directory root route.
src/pages/docs/index.tsx/docsNested directory root route.
src/pages/users/$userId.tsx/users/$userIdDynamic segment; the param name must be a JavaScript identifier.
src/pages/users/settings.tsx/users/settingsStatic sibling; it ranks before users/$userId.tsx.
src/pages/_helpers/format.tsIgnored_-prefixed files and folders are private to src/pages.
src/pages/.draft.tsxIgnoredDot-prefixed files and folders are hidden from route discovery.
src/pages/profile.test.tsxIgnoredTest/spec files can be colocated with a page without becoming routes.
src/pages/profile.stories.tsxIgnoredStorybook files are never route pages.
src/pages/ClientCard.client.tsxIgnoredClient-only modules can be colocated for RSC/client references without becoming URL routes.
src/pages/users.server.tsIgnoredServer-only modules are not page routes; imported server functions are still handled by the server-function transform.
src/pages/users/[id].tsxRejectedBracket route syntax is not supported; use $id.tsx.
src/pages/files/$...path.tsxRejectedCatch-all segments are not part of the convention.
src/pages/users/$__proto__.tsxRejectedReserved object-property names are not safe route param names.
src/pages/docs/$_splat.tsxRejected_splat is reserved for wildcard route params.
src/pages/teams/$teamId/users/$teamId.tsxRejectedDynamic param names must be unique within one route path.
src/pages/users.tsx beside src/pages/users/index.tsxRejectedBoth map to /users; keep one page module per URL path.
src/pages/layout.tsxRejectedSPA root layout lives at src/layout/index.tsx, outside src/pages.
src/pages/admin_panel.tsx beside src/pages/admin/panel.tsxRejectedBoth generate the same route id admin_panel.
// src/pages/campaign.tsx
import { Suspense } from "react";
import { OfferRegion } from "./OfferRegion";
import { OfferSkeleton } from "./OfferSkeleton";

export const render = "ssr";
export const hydrate = "none";
export const prerender = {
partial: true,
delivery: "stream",
} as const;

export default function Campaign() {
return (
<main>
<Suspense fallback={<OfferSkeleton />}>
<OfferRegion />
</Suspense>
</main>
);
}

Page files should stay thin. Read params/search, export page-local loader or rendering metadata, and compose components from features/ or components/. Business logic belongs in domain modules. Rendering metadata is literal-only: render and hydrate are string literals, prerender is true or an object literal with partial, delivery, or revalidate, prerender.revalidate is false or a positive integer number of seconds, and rsc is a boolean literal for RSC pages. Malformed page modules are reported during graph analysis with the file path and parser message before the bundler runs; malformed PPR region modules are reported the same way when region metadata is read.

Server Boundary

Put server-only code under src/api/ by default.

// src/api/operators.server.ts
"use server";

export async function listOperators() {
return [{ id: "ada", name: "Ada Lovelace" }];
}
// src/api/health.routes.ts
import { createRoute } from "@evjs/server";

export const healthRoute = createRoute("/api/health", {
GET: async () => Response.json({ ok: true }),
});

Mount routes and framework rendering in src/server.ts:

import { createApp, requestLogger } from "@evjs/server";
import { createReactFrameworkServer } from "@evjs/server/react";
import { healthRoute } from "./api/health.routes";

const framework = createReactFrameworkServer();

const app = createApp({
middlewares: [requestLogger()],
routes: [healthRoute],
framework,
});

export default { fetch: app.fetch };

Remote Builds

Host applications consume remotes through remotes. A package that is itself a remote app declares remote in its config and can reuse the same src/ organization:

export default defineConfig({
remote: {
name: "crm",
baseUrl: "https://assets.example.com/crm/",
entries: {
default: {
app: "./src/remote.tsx",
activeWhen: ["/crm/*"],
},
},
},
});

Remote modules can default-export React components. Do not mix a default React component with explicit mount, hydrate, or unmount exports; use init with a default component for setup, or export lifecycle functions without default for advanced cases. Lifecycle remote modules must export mount() or hydrate() because init() / unmount() alone cannot render a remote entry. Remote builds require a build-identifier remote.name and at least one build-identifier entry with a non-empty app module path. Keep host remotes and build-time remote separate: host apps consume remote manifests, while remote packages emit evjs-remote.json. When hosting that manifest, serve it with Content-Type: application/json optionally followed by content-type parameters.

Naming Guidance

  • pages/ is the page route source folder and can include SSR/PPR/RSC components.
  • api/ is the server boundary.
  • features/ owns business domains.
  • components/ owns generic UI.
  • lib/ contains browser-safe shared helpers.
  • Keep server secrets and Node-only APIs in api/ or modules imported only by server-only code.