Vertical Slices in the Frontend: When the Folder Tells You What the Product Does
It was a one-sentence change: “the user needs to be able to confirm the day.” No new screen, no new flow. A button, one API call, and the screen refreshed afterwards.
Now imagine that sentence landing in a frontend organized the way most of us learned. The button lives in components/. The call lives in services/. The state that holds the confirmed day lives in stores/. The response validation lives in schemas/. One business rule, four global folders, four places where another feature might be hiding, waiting for you to break it without noticing. The PR sprawls everywhere, the reviewer has to assemble the puzzle in their head, and nobody can confidently answer the most basic question: “what else does this affect?”
I’ve seen this movie in more than one codebase. And the problem isn’t that someone organized things badly. It’s that the folder was answering the wrong question. components/, services/ and stores/ tell you what kind of file lives there. They say nothing about what the product does.
In the SaaS I’m building, I decided to flip this from the start. This article is about how it turned out: the structure, the boundary rule, the data flow, the test that prevents shortcuts, and the strange decisions that only make sense with slices. And also about what I still don’t know will hold up.
Table of Contents
- The Folder Was Answering the Wrong Question
- Where the Idea Comes From
- The Anatomy of a Slice
- How Data Flows Inside a Slice
- A Rule the CI Can Fail
- Decisions That Only Make Sense With Slices
- If Your World Is Vue and Nuxt
- The Honest Take
- What I Would Do Today
- References
The Folder Was Answering the Wrong Question
The idea fits in one sentence: organize code by what the product does, not by technical file type. Each business capability becomes a vertical slice that cuts across all layers (data, rules, state, screen, and route) and only exposes itself to the rest of the system through a public API.
Horizontal layers Vertical slices
┌──────────────────────────────┐ ┌──────────┬──────────┬──────────┐
components/ │ contracts · cycles · workday │ │contracts │ cycles │ workday │
services/ │ contracts · cycles · workday │ │ ui │ ui │ ui │
stores/ │ contracts · cycles · workday │ │ model │ model │ model │
schemas/ │ contracts · cycles · workday │ │ services │ services │ services │
└──────────────────────────────┘ │ routes │ routes │ routes │
└──────────┴──────────┴──────────┘
one business change touches one business change stays
four global folders inside one column
With slices, “confirm the day” becomes a change inside one folder. The diff stays close to the domain, the reviewer reads top to bottom, and the question “what else does this affect?” has an answer that fits on one line: whatever the public API of this slice exports.
Where the Idea Comes From
None of this was born in the frontend. Vertical Slice Architecture was popularized by Jimmy Bogard in 2018, in .NET backends, as a reaction to layered architectures (n-tier, Clean, Onion). The complaint is the same as the opening, just with controller, service, repository, and DTO instead of our folders. The proposal: minimize coupling between slices and maximize coupling within a slice.
If you’ve read a bit about architecture, you’ll recognize the whole family:
| Name | Origin | What I borrowed |
|---|---|---|
| Screaming Architecture | Robert C. Martin, 2011 | The folder structure should scream the domain, not the framework |
| Bounded Context (DDD) | Eric Evans, 2003 | Each domain has its own model; the same concept can have two cuts |
| Colocation | Kent C. Dodds | Code that changes together lives together |
| Bulletproof React | React community | Isolated features and single-direction imports |
| Feature-Sliced Design | Frontend methodology | Formal layers app → pages → widgets → features → entities → shared |
| Modular monolith | Systems architecture | Modules with a public API inside a single deploy |
Feature-Sliced Design is the most complete cousin, and I looked at it carefully. But I chose a deliberately simpler version: three levels (app, domains, shared) and one boundary rule. No widgets, no entities, because I didn’t have a problem those layers solved. If someday composition between domains demands an intermediate layer, that will be a new decision with a concrete reason — not a prophecy.
Structure you adopt before you need it is structure you pay for without receiving anything in return.
The Anatomy of a Slice
The application’s src/ has three folders and nothing else:
src/
├── app/ # composition: bootstrap, router, authenticated shell, loading, errors, theme
├── domains/ # business slices — this is where the product lives
└── shared/ # neutral infrastructure with two or more real consumers
And dependencies only flow in one direction:
app ──▶ domains/<x>/index.ts ──▶ shared
│
└──▶ domains/<y>/index.ts (only through the public API)
app/ knows all the domains, but only through each one’s index.ts. One domain can use another, as long as it enters through the front door. And shared/ knows no domain at all. If shared/ imports anything from domains/, it stopped being neutral and became a disguised domain.
Inside, a slice looks like a mini-application:
domains/contracts/
├── index.ts # the public API: metadata + what others can use
├── model/ # Zod schemas, types and pure rules (no React, no I/O)
├── services/ # I/O: real API or a fake with the same contract
├── state/ # Zustand store, only for flow-level client state
├── ui/ # domain screens and components
├── routes/ # routes the domain exports for the app to compose
└── i18n/ # slice i18n dictionaries
Subfolders only exist when they have code. The cycles slice, for example, has no state/, because everything it shows comes from the route loader. An empty folder “to maintain the pattern” is just noise.
Notice the model/ folder too. That’s where pure business rules live, without React and without I/O. If you’ve read React is a View Library, Not an Architecture, that’s exactly the place that passes the CLI test. The slice doesn’t replace that separation. It gives it an address.
The index.ts Is the Only Public API
The most important file in a slice is the shortest one. It says what the domain offers to the rest of the system, and everything not in it is an internal detail:
// domains/account/index.ts
export const accountDomain = { id: 'account', status: 'implemented' } as const;
export { accountRoutes } from './routes/accountRoutes';
export { createSettingsRoutes } from './routes/settingsRoutes';
export { getSessionUser } from './services/sessionUser';
export type { SessionUser, Plan } from './model/sessionUser';
Anyone who needs the logged-in user imports from domains/account, never from domains/account/services/sessionUser. It feels like bureaucracy until the day you reorganize the entire interior of a slice and not a single file outside it changes. It’s the same principle of the stable contract I argued for in CAL: the public API is small and explicit, the implementation can change freely.
The status Field
That status at the top has two possible values: 'implemented' or 'placeholder'. It makes visible, in the code itself, which boundaries already have product and which are only reserved. Every slice goes into a single catalog in domains/index.ts.
Right now there are sixteen slices, nine with product and seven reserved. Why create a folder for something that doesn’t exist yet? Because reserving the name forces the conversation early about who owns each concept, and prevents the first code on that topic from being born in the wrong place just because it was the available one. A placeholder slice is nearly empty. What it carries is the decision.
How Data Flows Inside a Slice
Organizing folders is the easy part. What keeps a slice small is a clear rule for the data path. Mine separates server state from client state:
read write
┌────────┐ loader() ┌────────┐ props ┌────┐ ┌────┐ service() ┌────────┐
│services│ ───────▶ │ routes │ ────▶ │ ui │ ──▶ │ ui │ ────────▶ │services│
└───┬────┘ └────────┘ └────┘ └────┘ └───┬────┘
│ validates against model/ schemas │
▼ ▼
shared/api (Zod + response envelope) router.invalidate() → loader again
Reading happens in the route loader, never in a useEffect inside the screen. Writing calls the service and then router.invalidate(), which makes the router run the loader again. Zustand only holds client state: session, the draft of a multi-step flow, a device timer. Data from the server is never copied into a store.
In practice, the cycle screen route looks like this (the router here is TanStack Router, with code-based routes):
// domains/cycles/routes/cycleRoutes.tsx
import { createRoute, useRouter } from '@tanstack/react-router';
import type { ShellRoute } from '../../../app/shellRoute';
import { getSessionUser } from '../../account';
import { confirmDay, fetchCycleSummary } from '../services/cycleService';
import { CycleScreen } from '../ui/CycleScreen';
export function createCycleRoutes(parent: ShellRoute) {
const cycleRoute = createRoute({
getParentRoute: () => parent,
path: '/cycle',
loader: async () => {
const [summary, user] = await Promise.all([fetchCycleSummary(), getSessionUser()]);
return { summary, user };
},
component: CyclePage,
});
function CyclePage() {
const router = useRouter();
const { summary, user } = cycleRoute.useLoaderData();
return (
<CycleScreen
summary={summary}
user={user}
onConfirmDay={async (date) => {
await confirmDay({ date });
await router.invalidate();
}}
/>
);
}
return cycleRoute;
}
Three things to notice. getSessionUser comes from '../../account', the other domain’s public API, not from an internal file. CycleScreen doesn’t know where the data came from: it receives props and announces when someone confirmed the day — which is CAL in React form. And the most important thing is what doesn’t appear: no manual loading state, no “already fetched?” flag, no store syncing the response. Because reading goes through the router, it knows when the screen is loading, and you can wire the progress bar and skeleton in one place for the entire application.
If you’ve read about CAL, you might notice a tension. There, the composable syncs the API response into the Pinia store. Here, the rule is precisely not to copy server state into a store. For me this isn’t a contradiction — it’s a question about where the source of truth lives. When the route loader is the source, a store holding the same data becomes a second copy that someone will forget to update. The store goes back to being just memory for what belongs to the client.
Who Owns the URL and Who Decides Where It Hangs
Routes are code-based on purpose. Each domain exports its own tree, and app/router.tsx composes them:
// app/router.tsx (excerpt)
import { createContractRoutes } from '../domains/contracts';
import { createCycleRoutes } from '../domains/cycles';
import { accountRoutes, createSettingsRoutes } from '../domains/account';
The domain owns the URL. The app decides where it hangs. Everything behind the login hangs from a pathless layout route — the shell. A new route attached there is born already protected, with session, layout, loading, and error handling, without anyone needing to remember anything.
A Rule the CI Can Fail
Up to here, everything is convention. And I’ve already written about what happens to convention in a team under pressure: it turns into a polite request, and a polite request won’t hold on a Friday at 6 p.m. In the checkout we reorganized, the turning point was teaching the linter to reject the violation. Here the idea is the same, with a different tool.
The boundaries are verified by an architecture.test.ts of about 150 lines in Vitest. It does a few things, all boring on purpose. It blocks horizontal folders (components, services, stores, schemas, and company) at the root of src/. It blocks imports from one domain into another domain’s internal files, because only index.ts is valid. It ensures the import detector catches all three forms: import x from, import '...', and import('...'). It locks down a silent swap of the chosen stack (React, TanStack Router, Zod, Zustand, and the design system theme). And it checks that boundary rules remain written in the README and in the architecture decision record, because documentation that disappears without anyone noticing is also erosion.
The heart of it is simpler than it looks. An abbreviated version:
// src/architecture.test.ts (simplified version)
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const SRC = dirname(fileURLToPath(import.meta.url));
const DOMAINS = join(SRC, 'domains');
const HORIZONTAL = ['components', 'services', 'stores', 'schemas', 'hooks', 'ui'];
const IMPORT_RE = /(?:from\s+|import\s*\(\s*|import\s+)['"]([^'"]+)['"]/g;
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return walk(path);
return /\.(ts|tsx)$/.test(name) ? [path] : [];
});
}
function domainOf(path: string) {
const rel = relative(DOMAINS, path);
return rel.startsWith('..') ? null : rel.split(sep)[0];
}
describe('architecture', () => {
it('has no horizontal layers at the root of src/', () => {
const roots = readdirSync(SRC);
expect(roots.filter((name) => HORIZONTAL.includes(name))).toEqual([]);
});
it('only crosses domain boundaries via the public API', () => {
const violations: string[] = [];
for (const file of walk(SRC)) {
const from = domainOf(file);
for (const [, spec] of readFileSync(file, 'utf8').matchAll(IMPORT_RE)) {
if (!spec.startsWith('.')) continue;
const target = resolve(dirname(file), spec);
const to = domainOf(target);
if (!to || to === from) continue;
const isPublicPort = target === join(DOMAINS, to) || target === join(DOMAINS, to, 'index');
if (!isPublicPort) violations.push(`${relative(SRC, file)} → ${spec}`);
}
}
expect(violations).toEqual([]);
});
});
When someone writes import { confirmDay } from '../../cycles/services/cycleService' from inside another slice, pnpm test fails with the exact path of the violation. No distracted reviewer, no “I’ll fix it later.”
In the evolutionary architecture literature this has a name: fitness function. It’s an automated test that measures whether the system continues to have the architectural property you chose. The term was popularized by the book Building Evolutionary Architectures, by Neal Ford, Rebecca Parsons, and Patrick Kua.
There are ready-made tools for the same role: eslint-plugin-boundaries, which declares elements and dependency rules in ESLint, and dependency-cruiser, which validates the dependency graph against rules. Both are good. I chose the test for pragmatic reasons: no new dependency, runs in the same pnpm test as CI and pre-push, and anyone on the team can read and modify a test file without learning another tool’s configuration. The trade-off is that I maintain the import detector, something the ready-made tools already handle better. That’s why there’s a test just to ensure the detector catches all three import forms. A watchman who can’t see is worse than no watchman, because it creates a false sense of security.
Convention without tooling is still just a request. The difference is that now the request has a red test behind it.
Decisions That Only Make Sense With Slices
The best way to understand an architecture model is to look at the decisions it forces you to make. Some of these, in a project organized by file type, would look like mistakes. With slices, they’re the right path.
Duplicating a Slice Instead of Importing the Neighbor
The activities slice needs contract data. The natural reflex is to import the model from contracts and move on. Instead, activities declares its own slice of the contract, with only the fields it uses:
// domains/activities/model/contractRef.ts
import { z } from 'zod';
// The contract slice THIS slice needs. It is not the contracts domain model.
export const contractRefSchema = z.object({
id: z.string(),
title: z.string(),
status: z.enum(['active', 'paused', 'ended']),
});
export type ContractRef = z.infer<typeof contractRefSchema>;
Yes, this is duplication. And it’s intentional. It’s DDD’s bounded context applied to the frontend: the same concept has different models in different contexts, and one doesn’t break when the other changes. If contracts gains fifteen new fields tomorrow, activities won’t even notice.
One Domain Hosts Another’s Data Until the Other Exists
Customer data (name, approver email, company registration) lives today inside contracts, because customers doesn’t have its own screen yet. The customers boundary exists, but as a placeholder. When it gains product, the data migrates — with a concrete reason. Creating a slice full of code for a domain that has no use yet would be exactly the structure you pay for without receiving anything in return. This is the kind of decision that sometimes requires a conversation: deciding who owns a concept isn’t always obvious, and the placeholder keeps that conversation on record.
Breaking an Import Cycle by Injecting the Parent Route
This one is my favorite, because it’s a problem that only appears when boundaries are real. The shell, which lives in app/, imports account to build the menu. But the Settings routes also belong to account, and they need to hang from the shell. If account imported the shell back, a cycle would form.
The solution was to invert the dependency. account doesn’t import the shell. It exports a function that receives the parent route as a parameter and imports only its type:
// domains/account/routes/settingsRoutes.tsx
import { createRoute } from '@tanstack/react-router';
import type { ShellRoute } from '../../../app/shellRoute'; // type only
import { SettingsScreen } from '../ui/SettingsScreen';
export function createSettingsRoutes(parent: ShellRoute) {
return createRoute({
getParentRoute: () => parent,
path: '/settings',
component: SettingsScreen,
});
}
// app/router.tsx (excerpt)
const routeTree = rootRoute.addChildren([
accountRoutes,
shellRoute.addChildren([
createCycleRoutes(shellRoute),
createSettingsRoutes(shellRoute),
]),
]);
An import type disappears at compile time, so there’s no cycle at runtime. Dependency injection is a fancy name for what is here just “passing as a parameter.” And it works.
The shared/ Folder Demands Proof, Not Prophecy
A utility only moves to shared/ when it meets two conditions: it’s neutral with respect to the business, and it has two real consumers. Not “will have” — has. Until then, duplicating something small inside slices is acceptable.
This goes against the instinct of many developers, including my past self. Sandi Metz summed it up in the classic The Wrong Abstraction: duplication is far cheaper than the wrong abstraction. A function shared too early becomes the place where each new consumer adds a parameter, an if, an exception, until it serves no one well. Untangling that hurts more than merging two similar copies later.
The shared/ folder is the kitchen drawer everyone uses. If you let anything in, six months from now nobody can find the scissors.
A Fake That Looks Like an API
While an endpoint doesn’t exist, the slice’s service is a fake. But not just any fake: it validates input and output against the same Zod schemas from model/ that the real service will use.
// domains/invoices/services/invoiceService.ts
import { invoiceListSchema, type InvoiceList } from '../model/invoice';
import { invoiceFixtures } from './invoiceFixtures';
export async function listInvoices(): Promise<InvoiceList> {
// Fake until the API has this route. Same contract, same validation.
return invoiceListSchema.parse(invoiceFixtures);
}
When the endpoint arrives, the swap only touches services/. The ui/, the routes/, and the tests stay the same. Two of the slices with product today run this way, against a contract fake, until the API gains its routes. This deserves its own article, and it will get one.
If Your World Is Vue and Nuxt
I spend a good part of my days in Vue and Nuxt, so it’s worth translating. Almost nothing here depends on React.
The slice has a direct relative in Nuxt: Nuxt Layers, where each layer can have pages, components, composables, and its own configuration. That was the path we followed in the checkout. Inside each slice, CAL still applies: the page talks to the composable, the composable talks to the store and to the API.
The point to watch is the public API. Nuxt’s auto-import is comfortable precisely because it makes composables and components available anywhere without an explicit import — and that erases the boundary that index.ts draws here. Real slices in a Nuxt project need tooling (linting or a test like the one above) even more than in React, because the framework doesn’t help you see the violation. And there’s a detail: a test that reads import statements can’t see what was never imported. For it to work, slice code needs explicit imports — which means disabling auto-import scanning for your own code (imports.scan: false and components.dirs: [] in nuxt.config).
And the loader? In Nuxt, the closest equivalent is useAsyncData or useFetch in the page, with refresh() (or refreshNuxtData()) after a write. It’s not the same API, but it’s the same idea: reads have an owner, writes ask for a re-read, and the Pinia store holds client state.
One last note: Nuxt 4 also has a shared/ folder, but it serves code shared between the app and the server. It’s a different meaning. Don’t mix the two up when designing your structure.
The Honest Take
I’m suspicious of architecture articles that only tell the good parts. So here’s the full bill:
| What I gained | What I pay |
|---|---|
| An epic’s delivery touches one folder, not four | Discipline of importing only through index.ts (the test helps) |
| An entire slice can be removed or lazy-loaded | Small duplications until two real consumers exist |
| PRs that are easier to review, with the diff close to the domain | Cross-domain flows need explicit orchestration in app/ |
| Tests for schema, state and screen at the limit of capability | Manual route composition in app/router.tsx |
| Folder names tell you what the product does | Deciding who owns a concept sometimes requires a conversation |
Nothing in that table is free. But notice the pattern: almost every cost in the right column is a cost of clarity. You pay to make explicit what used to be implicit. I think it’s a good trade. I don’t think it’s an obvious one.
And then there’s the test that hasn’t happened yet. Every architecture looks great while slices are independent. The product’s core flow (closing, delivery, approval, invoice) crosses four domains, and the orchestration between them will show whether the composition in app/ stays simple or needs a new layer. I might end up needing something like the FSD layers I left out. If that happens, it will be a recorded decision with the reason on the table. And it’ll become an article.
A few things were left open on purpose. If the loader with invalidate() stops being enough and caching and deduplication become real pain, a server state library enters the conversation. If the volume of routes passes the point where manual composition is clear, file-based routing too. SSR or BFF, only with a real requirement. These aren’t promises. They’re doors I know where to find.
Finally, the warning in the other direction, because overengineering is a failure mode just as real as chaos. If your app has three screens and one person working on it, you don’t need sixteen slices, a domain catalog, and an architecture test. You need folders with good names. The structure should grow with the problem, not arrive before it.
What I Would Do Today
If I had to plant one idea, it would be this: the first folder someone opens in your project should tell them what the product does.
For a practical next step, take the last feature you shipped and list every file it touched. If they’re scattered across components/, services/, stores/, and company, try sketching how the same delivery would look inside one slice. You don’t need to migrate anything. That exercise alone shows you where your domain boundaries are and which slice would come first. And if you decide to create that first one, create alongside it the test that protects its public API. One rule, one red test.
In the next post I’ll open the full architecture test: how to write fitness functions with Vitest, what’s worth verifying, and why the test that tests the import detector itself is the most important one.
If this resonated, I write about frontend architecture, technical leadership, and careers. Find me on LinkedIn and GitHub.
References
Vertical slices and domain-driven organization
- Jimmy Bogard, Vertical Slice Architecture
- Robert C. Martin, Screaming Architecture
- Martin Fowler, Bounded Context
- Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (Addison-Wesley, 2003)
In the React ecosystem
- Alan Alickovic, Bulletproof React (see
docs/project-structure.md) - Feature-Sliced Design
- Kent C. Dodds, Colocation
- React (legacy docs), File Structure: grouping by features or routes
- TanStack Router, Code-based routing and Data loading
Abstraction and duplication
- Sandi Metz, The Wrong Abstraction
- Kent C. Dodds, AHA Programming
Enforcing architecture
- Neal Ford, Rebecca Parsons and Patrick Kua, Building Evolutionary Architectures (O’Reilly)
- eslint-plugin-boundaries and dependency-cruiser
- Michael Nygard, Documenting Architecture Decisions
From this blog