Every app is a pile of features: a blog, a checkout, a booking flow, an admin dashboard. The usual advice for building any of them is the same. Create a page, a helper, a component, a data call, and wire them together. It's a good answer, and it works.
I'll use a blog as the running example because everyone knows what one does. Swap in orders, invoices, or bookings and every point below still holds.
The example: a basic blog
For this example, the blog is simple. Visitors can see a list of posts and open a single post. Behind the scenes, an admin can create, edit and delete posts, which are stored in a database. That's five operations: list, show, create, update, delete.
Here's the basic version in a Next.js app:
my-app/├── app/│ ├── blog/│ │ ├── page.tsx # list of posts│ │ └── [slug]/page.tsx # post details│ ├── admin/posts/ # create, edit, delete│ └── api/posts/route.ts├── lib/posts.ts # all the data access└── components/ ├── PostCard.tsx └── PostBody.tsxFor a small project, stop here. Seriously. It works, and the problem only shows up later.
Where it starts to hurt
This structure groups files by type: components here, helpers there, data access somewhere else. That's fine while a feature is two pages and a helper. Then the feature grows. The blog gains tags, comments, authors, drafts, scheduled publishing. Each addition touches components/, lib/, app/, and your schema, so one feature ends up scattered across four or five top-level folders.
The costs are practical:
- Finding things gets slow. To understand how one feature works, you open six folders and piece it together.
- Boundaries disappear.
lib/posts.tsbecomeslib/utils.tsbecomes a grab bag that everything imports. - Deleting a feature is archaeology. Nobody is sure which files belong to it.
- Teams collide. Everyone edits the same shared folders, so merge conflicts pile up.
None of this is a bug. It's what happens when the folder structure describes the technology instead of the business.
Group by domain instead
The more durable approach is to organize around the domain. Everything about the blog lives in one place, and inside it you separate the layers by responsibility:
src/app/blog/├── page.tsx # entry: the list of posts├── components/ # client + server UI├── containers/│ └── PostContainer.tsx # wires the hooks into the UI├── hooks/│ ├── ListPost.hook.ts # useListPost│ ├── ShowPost.hook.ts # useShowPost│ ├── CreatePost.hook.ts # useCreatePost│ ├── UpdatePost.hook.ts # useUpdatePost│ └── DeletePost.hook.ts # useDeletePost├── server/│ ├── services/ # ListPost, ShowPost, CreatePost, UpdatePost, DeletePost│ └── repositories/ # same five, database access only├── schemas/│ ├── CreatePost.schema.ts│ └── UpdatePost.schema.ts└── types/ └── Post.types.tssrc/app/api/blog/├── route.ts # GET list, POST create└── [id]/route.ts # GET show, PUT update, DELETEThe rule of thumb: things that change together live together. Need to change how posts are saved? Open blog/server/. Need to remove the blog entirely? Delete the folder. A new teammate can read one folder and understand one part of the product. The same layout applies to any other feature, such as billing/ or bookings/.
Inside each layer, I go one step further and use one file per action: ListPost, ShowPost, CreatePost, UpdatePost, DeletePost. A service does one thing. A hook does one thing. When someone asks for "publish a post," it becomes a new file instead of another method growing inside an already-large class.
| Operation | What it does |
|---|---|
ListPost | Visitors see all published posts on /blog |
ShowPost | Visitors open one post |
CreatePost | Admin writes a new post |
UpdatePost | Admin edits an existing post |
DeletePost | Admin removes a post |
A request flows through the layers in one direction: page, container, hook, API route, service, repository, database. Each step is one file with one job, so "how does creating a post work?" means reading four files, all inside blog/.
What about shared code?
Not everything belongs to one domain. A date formatter, an auth hook, a layout, or a confirmation dialog may be used by the blog, billing and bookings alike. Copying it into every feature folder defeats the point, so shared code gets a home of its own: top-level folders such as src/components/, src/hooks/ and src/lib/. They sit next to app/, so it's obvious at a glance what is shared and what belongs to a single domain:
src/├── app/│ ├── blog/│ └── billing/├── components/│ └── ui/ # shadcn components, default location├── hooks/ # shared hooks└── lib/ # shared helpersTwo rules keep this from turning back into the problem we started with:
- Start inside the domain. Only move something up to the shared level when a second domain actually needs it.
- Dependencies point one way. Domains can import from shared code, but shared code never imports from a domain, and one domain doesn't reach into another's internals.
Components from UI libraries such as shadcn are the exception to "decide where it goes". They keep their default location (components/ui), because that's where the CLI installs them and where its config expects them. Moving them into a domain makes updates and new installs harder for no benefit. Your domain components simply import from there.
The catch: it's a lot of files
This is the honest downside. A proper domain structure means many small files, and creating them by hand is tedious enough that people skip it and drift back to the basic layout. I've done exactly that.
Laravel developers have it easier, since php artisan make:* generates the boilerplate for you. I wanted the same thing for the JavaScript world, so I built domain-driver.
domain-driver
It's a CLI that scaffolds domain-oriented feature folders. It reads your package.json, detects your stack, and generates only the layers that stack needs. It currently supports Next.js (fullstack and frontend-only), React, Node (Express, Fastify, Hono), NestJS, and TanStack Start.
npx domain-driver make:feature blog/Post -aThat scaffolds every layer for a Post entity inside the blog feature: components, containers, hooks, services, repositories, schemas, types, and the API route handlers if you're on a fullstack stack. Before it writes anything it tells you what it found:
Stack: next-fullstack (detected), root: src/appWhen the basic operations aren't enough, you add a bespoke one as its own files:
domain-driver make:action blog/Post publishPost --returns onePublishing a post gets its own hook, service, repository and route instead of being squeezed into UpdatePost.
A few details I care about:
- It never overwrites existing files, so re-running it is safe.
- Server repositories throw a clear "not implemented" error until you wire in your ORM. Code that fails loudly beats a fake store that looks like it works.
- On NestJS it registers the new classes in your modules for you, using the TypeScript compiler API, and leaves your formatting and comments alone.
domain-driver initwrites guidance for coding agents (AGENTS.md,CLAUDE.md, a Claude Code skill), so tools like Claude Code scaffold with the same structure instead of inventing their own.
So which one should you use?
Use the basic structure when the project is small, short-lived, or you're the only one touching it. It's quicker and perfectly fine.
Move to a domain-first structure when you notice yourself hunting through folders, when a second developer joins, or when you can feel a feature outgrowing its helper file. Better to switch early, while moving things is cheap.
If you try domain-driver, I'd like to hear what breaks. It's open source (MIT) and the roadmap is public: github.com/IsaacHatilima/domain-driver, or npm install -g domain-driver.
- Architecture
- Next.js
- domain-driver
Written by Isaac Hatilima
I build and run products across web, mobile and infrastructure.

