smolter
Guides

Virtual routes and entry files

The explicit contract between the flat-routing Vite plugin, your source files, and the browser/server builds.

Compatibility

ReactAvailable in this renderer's browser integration.
React SSRAvailable to this renderer's SSR integration; browser event handlers run after hydration.
PreactAvailable in this renderer's browser integration.
Preact SSRAvailable to this renderer's SSR integration; browser event handlers run after hydration.

Who creates virtual:smolter/routes?

This is a Vite-generated module, not a file to create and not an npm package. fileRouter (also called routerPlugin) provides it. applicationPlugins includes that plugin plus the matching React or Preact application integration. The spelling is virtual:smolter/routes, not virtual:smolter/router. Importing it outside a Vite build with the plugin installed will fail.

import { defineConfig } from "vite";
import { applicationPlugins } from "@kasperrt/smolter/preact/vite";

export default defineConfig({
  plugins: applicationPlugins({ directory: "src/routes" }),
});

Choose the renderer

Use matching react/server, react/client and react/document imports with React DOM 19+. Preact uses the corresponding preact paths. Routing hooks and manual loadRouter SSR continue to support React 18. Application middleware, scoped data endpoints and lazy file discovery work with either renderer.

// Server
import { renderApplication } from "@kasperrt/smolter/react/server";
// Browser
import { mountApplication } from "@kasperrt/smolter/react/client";
// Root Layout
import { Document, Meta, Links, Scripts } from "@kasperrt/smolter/react/document";

What does it export?

Its default export is a route array discovered from src/routes. Dots separate URL segments; $name captures a parameter; _index maps to the index path. Route modules export their default view and colocated behavior. No second file-routes directory is needed.

src/routes/_index/route.tsx           → /
src/routes/tasks/route.tsx            → /tasks
src/routes/tasks.$id/route.tsx        → /tasks/:id

// src/routes/tasks.$id/route.tsx
export { loader } from "./loader";
export { action } from "./action";
export { clientLoader } from "./clientLoader";
export { clientAction } from "./clientAction";
export { shouldRevalidate } from "./shouldRevalidate";
export { Task as default } from "./components/Task";

Different browser and server manifests

The SSR import retains server loader/action implementations. The browser import removes their named re-exports and uses the HTTP transport for server handlers. With applicationPlugins, browser route modules load lazily and the initial matched route is loaded before hydration. clientLoader/clientAction stay in the browser and can invoke serverLoader/serverAction. Keep server-only imports inside the separate loader/action modules: arbitrary imports in route.tsx are not automatically classified as server-only.

entry.client.tsx is application-owned

For the matching React or Preact application integration, this file starts hydration. Smolter supplies mountApplication, not the physical file. The Vite application plugin defaults to this path. It can configure application-wide URL policy before mounting. It must not import server handlers.

import routes from "virtual:smolter/routes";
import { mountApplication } from "@kasperrt/smolter/preact/client";
import * as root from "./root";

mountApplication(routes, root);

Optional browser and document modules

Routing hooks and components come from @kasperrt/smolter/preact. Import mountApplication and configureApplication from @kasperrt/smolter/preact/client. Import Document, Meta, Links, Scripts and ScrollRestoration from @kasperrt/smolter/preact/document. These are optional tree-shakable modules; a manually mounted router does not require the document or automatic hydration code. Server handlers belong only in /preact/server.

import { Outlet } from "@kasperrt/smolter/preact";
import { Document, Meta, Scripts } from "@kasperrt/smolter/preact/document";

entry.server.tsx is application-owned

This file connects your server manifest and root layout to Smolter. The application dev plugin loads this path; the production Vite SSR build uses it as its entry. It renders one request at a time and exports the data-request handler consumed by the Node middleware. Express is a separate host, not replaced by this module. These examples use the optional Preact application API; the core React/Preact APIs still support custom renderers and hydration.

import routes from "virtual:smolter/routes";
import { dispatchRoute, renderApplication } from "@kasperrt/smolter/preact/server";
import * as root from "./root";

export const handleDataRequest = (request: Request) =>
  dispatchRoute(request, routes);

export default function handleRequest(request: Request, entry: string) {
  return renderApplication(request, routes, root, entry);
}

TypeScript declaration

Vite resolves the virtual import at runtime. Declare its shape for the editor in an included .d.ts file.

declare module "virtual:smolter/routes" {
  const routes: import("@kasperrt/smolter/preact").AppRoute[];
  export default routes;
}

Manual routes without virtual imports

The generated manifest is optional. Define a browser route table and a separate server table, then import those files directly in your entrypoints. Use serverRoute for browser proxies rather than importing server implementations. If you want the Vite transport with manual tables, fileRouter accepts clientRoutes and serverRoutes; both are required. The optional applicationPlugins integration currently discovers flat files and does not accept those manual-table options.

import { fileRouter } from "@kasperrt/smolter/react/vite";

export default defineConfig({
  plugins: [fileRouter({
    clientRoutes: "src/browser-routes.ts",
    serverRoutes: "src/server-routes.ts",
  })],
});

Root data and static assets

Root loader data is serialized during SSR. Root revalidation after a successful mutation is opt-in: provide loaderPath when mounting, and register that URL in the route manifest. Without loaderPath, Smolter makes no extra root request. The root shouldRevalidate callback can skip that request. Static CSS, fonts, and images should use Vite asset imports and document links, not loader data or an artificial resource route. Vite applies the configured base, including a CDN origin, to emitted asset URLs. Call revalidate({ route: "root" }) to refresh only the root. Give the root an id to change that target name.

import stylesheetUrl from "./index.css?url";

// In the document head:
<link rel="stylesheet" href={stylesheetUrl} />

// Only when root data needs server revalidation:
mountApplication(routes, { ...root, loaderPath: "/session" });

Development updates and HMR

The dev server watches flat route entry files. Adding, removing or renaming a route refreshes discovery in both the browser and SSR module graphs and reloads the browser. Editing a route entry also reloads so changed paths, IDs and handler exports cannot leave an old router running. Colocated components and helpers use normal Vite HMR; state preservation depends on your framework plugin and its accepted update boundaries. Manual clientRoutes/serverRoutes modules use Vite’s import graph, including edits that add or remove entries. Without an accepting HMR boundary, Vite reloads the page. No server restart is needed for route changes. Vite configuration changes still follow Vite’s normal restart behavior. Production deployment manifests are disabled during development.

Literal dots and route identity

Dots separate URL segments. Escape a literal dot as [.]: robots[.]txt.ts or robots[.]txt/route.ts maps to /robots.txt; feeds.rss[.]xml maps to /feeds/rss.xml. File-route paths and identity come from filenames. Do not export id or routePath: the Vite plugin rejects those overrides. Target generated route data with its path and root data with root. Explicit IDs remain available in manually defined route arrays. Other bracket escapes and optional segments are unsupported.

Production tree shaking

Lazy file-route imports select supported browser exports explicitly instead of passing a complete module namespace. Unused helpers can be dropped; CSS and module side effects are preserved. Supported exports include default, clientLoader, clientAction, meta, links, handle, HydrateFallback, ErrorBoundary and shouldRevalidate. Server handlers remain behind server transport. Manual route arrays follow normal bundler rules: avoid spreading arbitrary module namespaces when you want unused exports removed. This does not eagerly load optional routes or disable package side effects.

Typed prefetch capabilities

fileRouter and applicationPlugins infer prefetch usage for both React and Preact. Intent is the default for plain links. Literal viewport selects only the observer; unions and typed spreads retain every possible mode. prefetchData or router.prefetch selects the data cache. Spreads follow JSX override order. Broad values and Link passed to unknown component consumers retain capabilities conservatively. Smolter owns a pinned stable TypeScript 6 compiler-API dependency (typescript-compiler) for build analysis, independent of the application’s TypeScript version (including TypeScript 7). Imported type changes invalidate client and SSR capability modules; changing the capability set reloads existing router instances. Other edits retain normal HMR.

pnpm add -D typescript

<Link prefetch="viewport" prefetchData to="/items">Items</Link>