Skip to content

Application model ​

Rooted apps are organised as vertical slices. Each feature is one folder that owns its routes, its components, its styles, and any data it needs. There is no top-level routes/, components/, or styles/ tree.

This page explains the layout and the small set of conventions that hold it together.

A slice ​

A slice is a folder that represents one user-facing feature. The folder contains:

  • A _routes.mts file that declares the routes the slice owns.
  • The components those routes render.
  • The CSS for those components.
  • Anything else local to the feature (icons, helpers, types).

The example app's recipes slice looks like this:

src/recipes/
  _routes.mts
  recipe.mts
  recipe.css
  ingredients-list.mts
  ingredients-list.css
  measurements.mts
  recipe-tabs.mts
  recipe-tabs.css
  serving-stepper.mts
  serving-stepper.css
  screen-lock-button.mts
  screen-lock-button.css

_routes.mts is the only file with a special name. The rest is whatever makes sense for the feature.

Why slices ​

Two reasons:

  1. When you delete a feature, you delete one folder.
  2. When you change a feature, the things you need are next to each other.

There is no enforced structure inside a slice. If a slice grows enough that it wants its own data/ or _shared/ subfolder, do that. Rooted does not have an opinion.

The _routes.mts convention ​

Every slice that has routes exposes them from _routes.mts. The Vite plugin scans for these files at build time and writes a generated module that imports every route at once.

A typical _routes.mts:

ts
import { route, token } from '@rooted/router/routes'

export const RecipeRoute = route`/recipe/${token('id', Number)}/`({
  async resolve({ create, tokens }) {
    const { Recipe } = await import('./recipe.mts')
    return create(Recipe, {
      id: tokens.id
    })
  },
})

A few things worth knowing:

  • The await import('./recipe.mts') makes the component lazy. The component module is not loaded until the route is visited for the first time.
  • The route is just a value. You can re-export it from the slice and reference it in other slices to build links (href.for(RecipeRoute, { id })).
  • A route can declare seo for per-page metadata. See SEO.

The route manifest ​

Add the plugin to vite.config.mts:

ts
import { generateRouteManifest } from '@rooted/router/manifest'

export default {
  plugins: [
    generateRouteManifest({
      glob: './src/**/_routes.mts',
      routeManifestPath: './src/_routes.g.mts',
    }),
  ],
}

The plugin writes src/_routes.g.mts with one big appRoutes export. Treat the generated file as build output. Don't edit it, and add it to your .gitignore if you don't want it in source control.

Because it's build output, it doesn't exist yet on a fresh checkout, which upsets TypeScript (and sometimes the TS server). Reference the ambient types once and the import resolves either way:

ts
// src/vite-env.d.ts
/// <reference types="@rooted/router/types" />

You then pass appRoutes to the router(...):

ts
import { router } from '@rooted/router/application'
import { appRoutes } from './_routes.g.mts'

const Router = router({
  home:     HomePage,
  notFound: NotFoundPage,
  ...appRoutes,
})

The app shell ​

The shell is whatever wraps the router. In the example app it's a single component called Application that holds:

  • The page header (with the navigation menu).
  • The <main> element that the router renders into.
  • The footer.
ts
const Application = component({
  name: 'recipe-application',
  styles,
  onMount({ append, element, create }) {
    append(
      element('div', {
        id: 'app',
        children: [
          element('header', { 
						children: create(NavigationMenu) 
					}),
          element('main', { 
						id: 'main-content', 
						children: create(Router) 
					}),
          element('footer', { 
						children: create(Doormat) 
					}),
        ],
      }),
    )
  },
})

Rooted does not generate shells, layouts, or wrappers for you. The shell is just another component. If you want nested layouts, write them as components and use gates to mount them on the right URLs.

Slice-shared code ​

Things that don't belong to any one slice usually go in a _shared/ folder at the source root. The leading underscore keeps it next to the slices alphabetically and signals that it isn't a feature.

src/
  _layout/        # global header/footer pieces
  _shared/        # data loaders, accessibility helpers, content utilities
  _routes.g.mts   # generated by the plugin
  application.mts # the shell + bootstrap
  recipes/
  categories/
  search/
  navigation/

This is a convention, not a requirement. The runtime does not look at folder names.

What rooted does not give you ​

  • A scaffolder. Make folders and files yourself.
  • File-based routing. Routes are declared in code, in _routes.mts.
  • A layout system. Use components and gates.
  • A data layer. Use fetch or whatever data tool you prefer. Pass results to components through the mount context's options.

The framework's job stops at the component model, the router, the store, and the SEO tooling. Everything above that is yours.