2026

March 21, 2026

React Project Structure: From MANTRA to Modern Frameworks

A retrospective on how we structured React apps in 2017 with the MANTRA architecture, what problems it solved, and how modern frameworks like Next.js and TanStack Start have absorbed those ideas into conventions we now take for granted.

S
Sascha Becker
Author

11 min read

React Project Structure: From MANTRA to Modern Frameworks

React Project Structure: From MANTRA to Modern Frameworks

Back in 2017, I wrote an article called Structure Your React Apps the Mantra Way. It was the era of Create React App, Redux boilerplate, and react-router-dom v4. Next.js existed but was still niche. Folder structures were the wild west.

The article proposed a module-based architecture inspired by Mantra JS, an application specification developed by Kadira during the Meteor era. It was opinionated, structured, and solved real problems that teams were hitting every day. Reading it again almost a decade later, I'm struck by how many of those ideas became mainstream, and how much ceremony modern frameworks have eliminated.

This post is a retrospective. Where we came from, where we are now, and why the journey matters.

The Problem in 2017

React gave you components and a render function. Everything else was your problem. How to fetch data, where to put state, how to wire routes, and, most contentiously, how to organize your files. Dan Abramov famously captured the mood with a dedicated site whose entire advice was: "move files around until it feels right." It wasn't a joke. There simply was no consensus.

The default instinct was to group by file type:

src
components
Games.js
GameTile.js
Header.js
containers
GamesContainer.js
HomeContainer.js
actions
gameActions.js
coreActions.js
reducers
gameReducer.js
coreReducer.js
routes
AppRoutes.js

This looks tidy at first. It falls apart the moment you have twenty features. Need to change how games work? Touch five folders. Want to delete a feature? Good luck finding all the pieces.

As I wrote back then:

The MANTRA Approach

MANTRA flipped the structure from "group by type" to "group by business concern." Each feature became a self-contained module with its own components, containers, actions, reducers, and routes:

modules
core
components
containers
actions
reducers
routes
index.js
games
components
containers
actions
reducers
routes
index.js
contact
components
containers
actions
reducers
routes
index.js

The key rules were simple:

  1. Business concern first. Each module owns everything it needs.
  2. No cascading modules. If a module needs a submodule, create it as a sibling, not a child. As I put it: "you don't cascade modules. If a module should have a submodule create it separately. The advantage is that you see all modules at a glance."
  3. Explicit public API. Each module exposes its parts through an index.js:
js
import * as actions from "./actions";
import reducers from "./reducers";
import routes from "./routes";
export { actions, reducers, routes };
  1. Glue at the top. A root file combined all module reducers, another stitched all routes together:
AppRoutes.js
import { routes as home } from "./modules/home";
import { routes as games } from "./modules/games";
import { routes as team } from "./modules/team";
export default (store) => {
return (
<Switch>
<Application>
{home(store)}
{games(store)}
{team(store)}
</Application>
</Switch>
);
};

The benefits were real. Isolation meant you could swap out a module without breaking others. Import paths stayed short. Working on one feature didn't pollute your mental model with another. As I summarized:

What We Were Really Solving

Looking back, MANTRA was solving four distinct problems that React and its ecosystem left wide open:

  1. Feature isolation. Where does all the code for "games" live? In one place, not scattered across type-based folders.
  2. Route composition. How do we add a new page? By creating a module and registering its routes in one glue file.
  3. State boundaries. Each module owns its reducers and actions. No global soup of unrelated state.
  4. Data flow clarity. The container/component split made it explicit where data came from and where presentation lived.

Every single one of these problems has since been addressed by framework conventions.

The Modern Answer

File-System Routing Replaced Manual Route Wiring

In 2017, every module exported a route function that returned <Route> components, and a top-level file stitched them together. It worked, but adding a new page meant editing at least two files: the module's routes and the central AppRoutes.js.

Next.js App Router, TanStack Start, and React Router (v7, framework mode) all use the file system as the router. Create a file, get a route:

app
(auth)
login
page.tsx
register
page.tsx
layout.tsx
(dashboard)
layout.tsx
games
page.tsx
loading.tsx
_components
GameTile.tsx
actions.ts
orders
page.tsx
_components
OrderTable.tsx
actions.ts
(marketing)
layout.tsx
page.tsx
pricing
page.tsx

The glue file is gone. The module boundary is just a folder. Route registration is implicit.

Server Components Replaced the Container Pattern

The container/presentational split was the dominant React pattern of 2017. Containers connected to Redux, fetched data, and passed it down. Presentational components were "dumb" and only rendered props.

js
// 2017: container connects to Redux and passes data down
class Container extends Component {
componentDidMount() {
this.props.dispatch(coreActions.setMenuIndex(1));
}
render() {
return <Games {...this.props} />;
}
}
export default connect((state) => {
return { mobile: state.core.responsive.mobile };
})(Container);

With React Server Components, the server component is the data layer. No wrapper needed:

tsx
// 2026: server component fetches directly
export default async function GamesPage() {
const games = await getGames();
return <GameList games={games} />;
}

With TanStack Start, a loader on the route definition serves the same purpose:

tsx
// TanStack Start
export const Route = createFileRoute("/games")({
loader: async () => ({ games: await getGames() }),
component: GamesPage,
});

React Router v7 (formerly Remix) uses a similar concept with a named export:

tsx
// React Router v7 (framework mode)
export const loader = async () => {
const games = await getGames();
return { games };
};

The container pattern didn't die because it was wrong. It died because the framework absorbed its responsibility.

Colocated Actions Replaced Redux Modules

Each MANTRA module had its own actions/ and reducers/ folders. Action types, action creators, and reducer functions were spread across multiple files per feature. Adding a single user interaction meant touching three or four files.

actionTypes.js
export const MENU_TOGGLE = "MENU_TOGGLE";
export const SET_MENU_INDEX = "SET_MENU_INDEX";
// actions.js
export function toggleMenu(open) {
return { type: TYPES.MENU_TOGGLE, open };
}
// reducer.js
export default function (state = defaultState, action) {
switch (action.type) {
case TYPES.MENU_TOGGLE:
return toggleMenu(state, action);
// ...
}
}

Today, server actions live right next to the page that uses them:

app/games/actions.ts
"use server";
export async function toggleFavorite(gameId: string) {
await db.game.update({ where: { id: gameId }, data: { favorite: true } });
revalidatePath("/games");
}

One file. No action types, no dispatch, no reducer boilerplate. For client state, a small Zustand or Jotai store replaces what used to be an entire Redux module.

The Index.js Pattern Became Unnecessary

MANTRA's index.js per module was the public API: it re-exported actions, reducers, and routes so other modules could import from a clean path. This was good practice for maintaining boundaries.

In a framework with file-system conventions, those boundaries are enforced by the framework itself. Files like page.tsx and layout.tsx each have a known role. There's nothing to re-export because the framework knows where to look.

What Aged Well

Not everything needed replacing. Some of MANTRA's ideas are now accepted wisdom.

Feature-based organization is the default. Whether you call them modules, features, or route segments, the industry settled on "group by business concern." The Next.js App Router is essentially MANTRA's module structure, enforced by convention.

Flat module hierarchies. The rule against cascading modules maps directly to how route groups work in modern frameworks. Deeply nested feature folders are still an antipattern. Keeping things flat and visible at a glance is still good advice.

Colocation. Components, their data logic, their styles, and their tests living next to each other. This was novel advice in 2017. It's table stakes in 2026.

Explicit boundaries between features. Even without index.js re-exports, the principle of keeping modules isolated from each other persists. Whether you enforce it through folder conventions, barrel files, or ESLint import rules, the idea is the same.

What I'd Tell My 2017 Self

The architecture was sound. The instinct to organize by feature, keep modules flat, and maintain clear boundaries was exactly right. The execution just required a lot of manual plumbing that frameworks now handle.

I also wrote an npm package called module-loader to automate the setup overhead. It was the right impulse: the boilerplate was the weakest part. Frameworks eventually came to the same conclusion and eliminated it entirely.

If you're starting a new React project today, you don't need to think about most of this. Pick Next.js, TanStack Start, or React Router. Follow the file conventions. Your project structure is already better than what we spent weeks debating in 2017.

But if you're working on a large SPA without a framework (and there are still good reasons to do so), the MANTRA principles hold up. Group by feature. Keep modules flat. Make boundaries explicit. The names change, the idea doesn't.

MANTRA with Vite (No Framework)

If you're using plain Vite or Vite+ without a meta-framework, there are no file-system conventions to lean on. You're back in CRA territory, and MANTRA's structure translates almost directly, just with modern tools replacing the 2017 equivalents:

src
features
games
components
GameTile.tsx
GameList.tsx
hooks
useGames.ts
api
games.queries.ts
routes.tsx
index.ts
orders
components
OrderTable.tsx
hooks
useOrders.ts
api
orders.queries.ts
routes.tsx
index.ts
shared
components
Layout.tsx
hooks
useAuth.ts

The shape is familiar, but the internals have changed:

  • hooks/ replaces containers/. Custom hooks absorbed the data fetching and state logic that containers used to handle. No more class components wrapping class components.
  • api/ replaces actions/ + reducers/. TanStack Query or SWR replaces Redux for server state, so each feature just has query and mutation definitions instead of action types, action creators, and reducer functions.
  • index.ts still matters. Without framework conventions to enforce boundaries, the barrel file is your public API again, exactly like MANTRA's original index.js.
  • routes.tsx still needs manual wiring. You'll still stitch feature routes together in a root router, just like the old AppRoutes.js. React Router or TanStack Router handle the rendering, but you do the composition.

The ceremony is lighter (no Redux boilerplate, no container/presentational split), but the organizational discipline is still on you. That's the trade-off of going frameworkless: more freedom, more responsibility.

Then and Now

Concern2017 (MANTRA)2026 (Frameworks)
Route registrationManual glue file importing module routesFile-system routing
Data fetchingContainer components + Redux connectServer Components, loaders, server actions
State managementRedux actions + reducers per moduleServer actions + lightweight stores (Zustand, Jotai)
Module boundaryindex.js re-exportsFolder conventions + framework file roles
Feature isolationManual disciplineEnforced by file-system structure
Shared logicCore module with shared actionsShared layouts, middleware, utility folders
Build toolingCreate React App, Webpack configVite, Turbopack, zero-config

The table makes it look like a clean replacement, and in many ways it is. But the mental models behind the 2017 column are what made the 2026 column possible. Framework authors didn't invent feature-based organization. They observed what teams were already doing (often painfully, with manual boilerplate) and turned it into conventions.

Closing Thought

I'm grateful for the MANTRA era. Not because the code was better (it wasn't), but because the thinking was right. We were solving real problems with the tools we had. The fact that those solutions became so mainstream that they disappeared into framework conventions is the best possible outcome.

Every page.tsx you create without thinking about it is a problem someone fought to solve in 2017.


S
Written by
Sascha Becker
More articles