Skip to main content
A feature is a vertical slice for one domain area of your Single Page App. It lives at modules/<spa>/src/features/<feature>/ and owns its full front-end stack — components, hooks, services, state, styles, translations, and types. Each feature is self-contained: it never imports another feature’s internals. When two features need the same code, that code is promoted to src/shared/ and imported from there. This keeps every domain area independent and easy to reason about, and it lets you grow the app by adding slices rather than tangling existing ones. See SPA overview for the bigger picture and SPA structure for how features sit inside a module.

Why vertical slices

  • One domain per feature. Each folder maps to a single area (settings, billing, profile) and holds everything that area needs — no shared “components” dumping ground spanning concerns.
  • No cross-feature coupling. A feature never reaches into another feature’s files; shared code lives in src/shared/, so dependencies are explicit and one-directional.
  • Clear layering. Backend calls live only in hooks/; services/ stay pure domain logic; store/ holds client state. Each layer has one job.
  • Move-or-delete simplicity. Because a slice is self-contained, you can rename, relocate, or remove a whole domain area by touching one folder.

How it works

Scaffold a feature with spa:feature:create and you get a route file plus a feature folder. The route file wires TanStack Router to the feature’s layouts; the feature folder is organized by layer, each with a single responsibility.
SubfolderPurpose
assets/Images, fonts, and other static files used by the feature.
components/Presentational React components for this domain area.
hooks/React hooks — the only layer that talks to the backend (TanStack Query reads and mutations).
layouts/Page and boundary components (page, skeleton, error, not-found).
services/Pure domain logic — no backend calls, no React; just functions over data.
store/Client state for the feature (UI/local state).
styles/Feature-scoped styles.
translations/Feature-scoped i18n messages (see internationalization).
types/TypeScript types for the feature’s data and props.
utils/Small feature-local helpers.
The layering rule is what keeps slices clean: components and layouts render, hooks/ fetch and mutate against the backend, services/ transform data with no side effects, and store/ holds client state. Nothing outside hooks/ should call the backend, and nothing should reach across into a sibling feature.

What spa:feature:create generates

ooneex spa:feature:create --name=<Name> --module=<spa> scaffolds a complete slice. PascalCase drives component names and kebab-case drives paths, so a feature named Settings produces files under settings/ with Settings* components. It generates:
  • A route file at src/routes/<kebab>.tsx — a TanStack Router file route at path /<kebab>.
  • Four layouts/boundaries in src/features/<kebab>/layouts/: <Name>Layout.tsx (the page component), <Name>SkeletonLayout.tsx (pending/loading boundary), <Name>ErrorLayout.tsx (error boundary), and <Name>NotFoundLayout.tsx (not-found boundary).
  • Two example hooks in src/features/<kebab>/hooks/: useGet<Name>.ts (a TanStack Query READ hook) and useUpdate<Name>.ts (a MUTATION hook).
It also installs @tanstack/react-query if it is missing. The generated route wires the layouts as the route’s component and its boundaries — one place that maps loading, error, and not-found states to the feature’s own components:
import { createFileRoute } from "@tanstack/react-router";
import { SettingsErrorLayout } from "../features/settings/layouts/SettingsErrorLayout";
import { SettingsLayout } from "../features/settings/layouts/SettingsLayout";
import { SettingsNotFoundLayout } from "../features/settings/layouts/SettingsNotFoundLayout";
import { SettingsSkeletonLayout } from "../features/settings/layouts/SettingsSkeletonLayout";

export const Route = createFileRoute("/settings")({
  component: SettingsLayout,
  pendingComponent: SettingsSkeletonLayout,
  errorComponent: SettingsErrorLayout,
  notFoundComponent: SettingsNotFoundLayout,
});
The page layout renders the feature’s content. It accepts optional children and falls back to the router Outlet so nested routes render in place:
import { Outlet } from "@tanstack/react-router";
import type { ReactNode } from "react";

type SettingsLayoutPropsType = { children?: ReactNode };

export const SettingsLayout = ({ children }: SettingsLayoutPropsType): ReactNode => {
  return <section>{children ?? <Outlet />}</section>;
};
The two generated hooks are example starting points — a read hook and a mutation hook — that you replace with your real queries. Data-fetching hooks are covered in depth on data fetching, and the routing model behind the generated route file is covered on routing.

Best practices

  • One domain per feature. Keep each slice scoped to a single area; if a folder starts serving two concerns, split it.
  • Never import another feature’s internals. Cross-feature imports re-couple the slices you worked to separate — treat each feature as a closed box.
  • Promote shared code. When two features need the same component, hook, or type, move it to src/shared/ and import it from there.
  • Keep routes thin. The route file should only wire layouts and boundaries; put real logic in the feature’s layers, not in the route.
  • Backend calls only in hooks. Read and mutate through hooks/; keep services/ pure and store/ for client state, so the data path stays predictable.

CLI command

Scaffold a feature with the generator. It writes the route file, the four layouts, and the two example hooks into the target spa module, and installs @tanstack/react-query if it is missing.
# Interactive: prompts for the feature name and target module
ooneex spa:feature:create

# Provide the name and module
ooneex spa:feature:create --name=Settings --module=dashboard

# Overwrite existing feature files
ooneex spa:feature:create --name=Settings --module=dashboard --override
OptionDescriptionDefault
--nameFeature name. Normalized to PascalCase; a trailing Feature or Layout is stripped.Prompted if omitted
--moduleTarget spa module the feature is generated into.Prompted if omitted
--overrideOverwrite existing feature files without prompting.false
See spa:feature:create for the full command reference.

Use with Claude and Codex

The generator ships a matching spa:feature:create skill. It runs the scaffold and then guides your AI agent through filling in the slice — implementing the layouts, replacing the example hooks with real queries, and adding components, services, and types under the feature folder. Initialize the skills once for your agent:
ooneex claude:init
Then ask Claude in natural language — it maps the request to the generator, runs it, and fills in the slice:
Prompt
Create a settings feature in the dashboard spa.
For example, the prompt above maps to spa:feature:create --name=Settings --module=dashboard, then implements the layouts and turns the example hooks into the real reads and mutations the settings page needs.