Feature structure
Web code is two packages, an app and a product; each feature has core, server, components, hooks and form folders, and one public door that other features must use.
Verified 2026-10-03 · Against a production TanStack Start monorepo of seventeen product apps, enforced by a boundary script and a design-system guard in its gate. The one-door rule is newer than that codebase: its check follows the same shrinking-baseline shape as the guard. Excerpts are adapted, names changed. Blocks titled
examples/web/…are those files in this handbook, which its gate type-checks and tests; the door check isexamples/web/tooling/check-doors.ts.
A product's web code is two packages: the deployable app and the product.
Inside the product, src/features/<feature>/ has five folders by kind of code,
and one entry file that is the feature's only public door.
The tree
web/
app/ the deployable TanStack Start app
routes.ts mounts the product's routes with physical()
src/router.tsx router + one query client per request
src/routes/__root.tsx the root route
src/styles.css theme import + the Tailwind sources
server.ts production server
e2e/ Playwright journeys for this app
product/ the product package — routes and features
src/routes/ file routes; each loader seeds its queries
src/features/<feature>/
index.ts ← the door: the only file another feature imports
core/ types, constants, query keys + query options, pure rules
server/ *.server.ts — createServerFn handlers calling the SDK
components/ this feature's React
hooks/ controllers: mutations with meta.invalidates
form/ form definition, draft, draft → API mapper
src/lib/api/<product>.server.ts the product's server-only SDK
src/manifest.ts what the product declares to the shell
contract/typescript-sdk/ client + zod schemas generated from the service's OpenAPI
shared/web/<package>/ source-only workspace packages: ui, theme, api, forms, …
tooling/ checks and their tests; production code never imports itNot every feature has every folder. A feature with no form has no form/; one
that only reads has no hooks/.
This replaces the domain/ application/ adapters/ server/ ui/ layout and its
four facades. The folders now say what kind of code a file is — a query, a server
function, a component, a hook, a form — which is the question a reader of a web
feature actually asks. The pure rules the hexagon protected still exist: they live
in core/, with no React and no I/O.
Why two packages
- The app is the composition root. It owns the router, the root route, the server and the stylesheet, and may import anything — nothing imports it.
- The product holds routes and features, and may import only itself, its own contract and shared packages. The app mounts the product's route folder:
import {
layout,
physical,
rootRoute,
route,
} from "@tanstack/virtual-file-routes";
export default rootRoute("__root.tsx", [
// better-auth's endpoints, outside the signed-in layout.
route("/api/auth/$", "api/auth/$.ts"),
// A pathless LAYOUT, not `route("/")`: the product mounts its own index at `/`,
// and two routes with that id fail at router construction.
layout("app/route.tsx", [
// App-owned: a route that needs a package the product may not import.
route("/members", "app/members.tsx"),
physical("", "../../../product/src/routes"),
]),
]);
{
"name": "@acme/product-projects",
"exports": {
"./manifest": "./src/manifest.ts",
"./features/*": "./src/features/*",
"./lib/api/projects.server": "./src/lib/api/projects.server.ts",
"./lib/sdk/projects": "./src/lib/sdk/projects/index.ts",
"./ui/*": "./src/ui/*.tsx"
}
}Adapted from a production app. Paths that cross between the two packages are
relative and depth-sensitive: routes.ts resolves from the app's src/routes, so
the product's routes are three levels up.
When not to: a single app with one team and no second deployable keeps one
package with the same src/routes/ and src/features/ inside it.
One public door per feature
Another feature, and every route file, imports this feature only through its
entry file, features/<feature>/index.ts. Everything else in the folder is
internal and may change without asking anyone. Routes are adapters like any other
caller: a route that reaches into components/ or hooks/ couples the URL tree to
the feature's insides, so it uses the door too.
The door re-exports what other features genuinely use — query keys and options, types, a component — and nothing else:
// The feature's one door: routes and other features import it only from here.
// `server/` is never re-exported; core/ and hooks/ reach it by dynamic import.
export { ActivitySection } from "./components/activity-section";
export { ProjectForm } from "./components/project-form";
export { ProjectList, ProjectListSkeleton } from "./components/project-list";
export {
activityQueryOptions,
projectKeys,
projectsQueryOptions,
} from "./core/queries";
- The door never re-exports
server/. Server functions are reached by the feature's own queries and hooks through a dynamic import; a static re-export would put server code in every client bundle that imports the door. - One door, not one per audience. Client/server safety comes from the
*.server.tsfiles and dynamic imports below, so a second entry file is not needed to express it.
The import a door rules out looks like this:
// Before: reaches into another feature's internals.
import { progressKeys } from "../../progress/core/queries";
// After: through the door.
import { progressKeys } from "../../progress";The "before" line is adapted from a production app, where cross-feature imports like it are what the door check starts by recording.
How the door is enforced
A check fails the gate on any import from one feature into another feature's
folder other than its entry file. It resolves relative specifiers
(../../progress/core/queries) and the package's own name
(@acme/product-projects/features/progress/core/queries) to the same file, so
neither spelling slips past.
Violations that predate the check live in a baseline that may only shrink — the same ratchet as the design-system guard:
- the baseline is keyed by file, imported feature and specifier, with a count — no line numbers, so an unrelated edit does not move an entry;
- the check fails on any violation beyond its recorded count, and on any recorded entry that has shrunk, so a fixed import is removed from the baseline in the same change;
- a flag rewrites the baseline with removals only and refuses to add anything; the file on disk is compared with the one at the merge base, so a hand edit cannot grow it either.
When not to: a feature nobody imports needs no door until the first import arrives — the check only looks at imports that cross a feature boundary.
Dependency direction
The rules between packages, enforced by one script rather than by convention:
- a product never imports another product;
- shared packages never import a product or an app;
- a product's web code imports only its own product package, its own contract and shared packages;
- the
@/alias exists only inside apps — product and shared code import by package name or relative path; - apps are composition roots: they depend on products and shared code, and nothing imports them.
for (const imported of importSpecifiers(source)) {
const { specifier } = imported;
const fail = (rule: string) => violations.push({ file, line, specifier, rule });
if (tier === "apps") continue; // apps sit at the top of the dependency direction
// Relative specifiers are resolved to a repo-relative path so `../` escapes
// into another tier are caught, not just package-name imports.
const resolved = specifier.startsWith(".") ? relative(scanRoot, resolve(fileDir, specifier)) : null;
if (specifier.startsWith("@/")) fail("`@/` alias only resolves inside apps");
if (resolved ? resolved.startsWith("apps/") : specifier.startsWith("apps/")) fail("nothing imports apps");
const targetProductDir = resolved?.startsWith("products/") ? resolved.split("/")[1] : null;
const isOwnPkg = ownProductPkg !== null &&
(specifier === ownProductPkg || specifier.startsWith(`${ownProductPkg}/`));
const importsForeignPkg = specifier.startsWith("@acme/product-") && !isOwnPkg;
const importsForeignDir = targetProductDir !== null && targetProductDir !== ownProductDir;
if (importsForeignPkg || importsForeignDir) {
fail(tier === "shared" ? "shared never imports products" : "products never import products");
}
}Adapted from a production repository. The generated route tree is skipped: an app legitimately compiles the product's route files. The script exists because the monorepo tool's own boundary rule (every import resolves inside its own package) is incompatible with mounting routes from another package.
The rules are tested against planted fixtures — a temporary tree with one forbidden import, asserting the exact violation. A rule that matches nothing passes silently and proves nothing.
it("rejects a planted cross-product import", () => {
const root = fixture({
"products/billing/web/product/src/features/invoice/core/query.ts":
'import { project } from "@acme/product-projects";\n',
"products/projects/web/product/src/index.ts": "export const project = true;\n",
});
expect(scanBoundaries(root)).toEqual([
expect.objectContaining({ rule: "products never import products", specifier: "@acme/product-projects" }),
]);
});When not to: a repository with one app and no shared packages has only the feature door to enforce.
Client/server safety — the rules that actually prevent leaks
- Server code lives in
*.server.tsfiles — the feature'sserver/api.server.tsand the product'slib/api/<product>.server.ts. The boundary is visible in the filename, before any import graph is consulted. - Client-reachable code reaches a server function only by dynamic import:
const getTaskServer = () => import("../server/api.server"), called inside aqueryFnormutationFn. A static import from a component, a hook or a door is a leak. See server functions. - The product's SDK is imported only by
*.server.tsfiles. It resolves the viewer's token from the incoming request; nothing else needs it. - No client-reachable code imports server configuration. Server settings carry secrets; the client takes what it is allowed to know from an app-config the root route serves. Allowlist, not filter — see secrets.
When not to: an SPA with no server has no server/ folder; its queries call the
generated client directly. See Vite.
Shared packages
Shared code is source-only workspace packages: exports point straight at
.ts/.tsx sources, there is no build step, and the consuming app's Vite
compiles them. Only code with a second real consumer moves to a shared package.
{
"name": "@acme/api",
"private": true,
"exports": {
"./query-client": "./src/query-client.ts",
"./query-key": "./src/query-key.ts",
"./sdk-runtime": "./src/sdk-runtime.ts",
"./service-sdk": "./src/service-sdk.ts"
},
"dependencies": { "@tanstack/react-query": "5.101.4" }
}"exports": {
"./*": "./src/*.tsx",
"./lib/*": "./src/lib/*.ts",
"./hooks/*": "./src/hooks/*.ts"
}Adapted from a production repository. One export per component: the import
names the file (@acme/ui/empty-state is src/empty-state.tsx), so there is no
barrel to keep in step and no reason to load every component to use one. The
design system is one of these packages, not a folder in a feature — see
the design system.
When not to: a published library consumed outside the repository needs a build and versioned releases; a workspace package between two apps in one repository does not.
Conventions worth keeping
- Tests sit beside the code (
*.test.ts,*.test.tsx, and a route's-<name>-ssr.test.tsx— the leading-keeps it out of the route tree). Production code may not import tests or fixtures. - A product's manifest declares what the shell needs — name, mount path, navigation entries — so the shell never reaches into features.
core/has no React and no I/O. Its rules are unit-tested with literal inputs; that is what the hexagon's domain layer was for.
Traps
- Deep imports between features → the internals become an API nobody agreed to. Go through the door; the check records the old ones and stops new ones.
- A door that re-exports everything → the boundary is decorative. Export what another feature imports today, nothing speculative.
- A shared package that imports a product → instant cycle. Lower layers never import up.
- Route files that fetch directly → the cache is bypassed and the first screen cannot be proven server-rendered. Routes seed queries in loaders.
- Boundary rules nobody tests → a rule that matches nothing passes forever. Plant a violation in a fixture and assert it is caught.
Enforcement
The gate runs, alongside typecheck, lint and test:
- the boundary script (
bun run boundaries) for the dependency direction; - the door check, with its shrinking baseline;
- the design-system guard, same ratchet;
- the SSR contract test for the banned data patterns.
The boundary script and the guard each have tests against planted fixtures; the door check is held to the same.