A Folder Structure That Scales for Full Stack SaaS
DEV Community

A Folder Structure That Scales for Full Stack SaaS

Structure by feature, not by file type

The instinct most engineers start with is grouping by technical role: a controllers/ folder, a services/ folder, a dtos/ folder, a models/ folder. It looks tidy in a diagram. It falls apart the moment your app has more than about five features, because every change to one feature touches four different folders and you spend more time navigating than coding.

The alternative is grouping by feature, sometimes called vertical slicing. Everything related to invoices lives under invoices/: the controller, the service, the DTOs, the entity, the tests. Everything related to organizations lives under organizations/. When you add a field to an invoice, you open one folder, not four.

Here's how that looks inside the NestJS API from apps/api, which we set up a few articles back:

apps/api/src/
โ”œโ”€โ”€ modules/
โ”‚   โ”œโ”€โ”€ invoices/
โ”‚   โ”‚   โ”œโ”€โ”€ dto/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ create-invoice.dto.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ update-invoice.dto.ts
โ”‚   โ”‚   โ”œโ”€โ”€ entities/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ invoice.entity.ts
โ”‚   โ”‚   โ”œโ”€โ”€ invoices.controller.ts
โ”‚   โ”‚   โ”œโ”€โ”€ invoices.service.ts
โ”‚   โ”‚   โ”œโ”€โ”€ invoices.module.ts
โ”‚   โ”‚   โ””โ”€โ”€ invoices.service.spec.ts
โ”‚   โ”œโ”€โ”€ organizations/
โ”‚   โ”‚   โ”œโ”€โ”€ dto/
โ”‚   โ”‚   โ”œโ”€โ”€ entities/
โ”‚   โ”‚   โ”œโ”€โ”€ organizations.controller.ts
โ”‚   โ”‚   โ”œโ”€โ”€ organizations.service.ts
โ”‚   โ”‚   โ””โ”€โ”€ organizations.module.ts
โ”‚   โ””โ”€โ”€ auth/
โ”‚       โ”œโ”€โ”€ guards/
โ”‚       โ”œโ”€โ”€ strategies/
โ”‚       โ”œโ”€โ”€ auth.controller.ts
โ”‚       โ”œโ”€โ”€ auth.service.ts
โ”‚       โ””โ”€โ”€ auth.module.ts
โ”œโ”€โ”€ common/
โ”‚   โ”œโ”€โ”€ decorators/
โ”‚   โ”œโ”€โ”€ filters/
โ”‚   โ”œโ”€โ”€ guards/
โ”‚   โ”œโ”€โ”€ interceptors/
โ”‚   โ””โ”€โ”€ pipes/
โ”œโ”€โ”€ config/
โ”‚   โ””โ”€โ”€ configuration.ts
โ”œโ”€โ”€ app.module.ts
โ””โ”€โ”€ main.ts

NestJS already nudges you toward this. A module is a feature boundary by design: it declares its own controllers, providers, and imports, and the framework doesn't care how many other modules exist alongside it. Fighting that and going back to a controllers/ folder means fighting the framework's own conventions for no real benefit.

The common/ folder is deliberately small and deliberately generic. It holds things with no feature owner: a global exception filter, a logging interceptor, a @CurrentUser() decorator. If something belongs to one feature, it goes in that feature's folder even if you think you might reuse it later. Premature sharing creates coupling between features that didn't need to know about each other.

The same idea in Next.js, with the App Router doing half the work

Next.js's App Router structure already forces feature-adjacent thinking because routing is filesystem-based. Route groups, the folders in parentheses, let you organize by area of the product without changing the URL:

apps/web/src/app/
โ”œโ”€โ”€ (marketing)/
โ”‚   โ”œโ”€โ”€ page.tsx
โ”‚   โ”œโ”€โ”€ pricing/
โ”‚   โ”‚   โ””โ”€โ”€ page.tsx
โ”‚   โ””โ”€โ”€ layout.tsx
โ”œโ”€โ”€ (dashboard)/
โ”‚   โ”œโ”€โ”€ layout.tsx
โ”‚   โ”œโ”€โ”€ invoices/
โ”‚   โ”‚   โ”œโ”€โ”€ page.tsx
โ”‚   โ”‚   โ”œโ”€โ”€ [invoiceId]/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ page.tsx
โ”‚   โ”‚   โ””โ”€โ”€ _components/
โ”‚   โ”‚       โ”œโ”€โ”€ invoice-table.tsx
โ”‚   โ”‚       โ””โ”€โ”€ invoice-status-badge.tsx
โ”‚   โ””โ”€โ”€ settings/
โ”‚       โ””โ”€โ”€ page.tsx
โ”œโ”€โ”€ (auth)/
โ”‚   โ”œโ”€โ”€ login/
โ”‚   โ”‚   โ””โ”€โ”€ page.tsx
โ”‚   โ””โ”€โ”€ signup/
โ”‚       โ””โ”€โ”€ page.tsx
โ”œโ”€โ”€ api/
โ”‚   โ””โ”€โ”€ webhooks/
โ”‚       โ””โ”€โ”€ stripe/
โ”‚           โ””โ”€โ”€ route.ts
โ””โ”€โ”€ layout.tsx

The (marketing) group gets its own layout without a header full of app navigation. The (dashboard) group gets an authenticated layout with a sidebar. Neither prefix appears in the URL: /pricing and /invoices both resolve exactly as you'd expect, because parenthesized segments are organizational only.

The underscore folder, _components/, is Next.js's way of saying "not a route." Anything under an underscore-prefixed folder is opted out of routing entirely. That's exactly what you want for components, hooks, or utilities that belong to one route but shouldn't accidentally become one. Colocating invoice-table.tsx next to the invoices/ route it serves means you don't go hunting through a global components/ folder to find what renders a specific page. Only promote a component to a shared location once a second, unrelated route actually needs it.

What earns a place in packages/, and what doesn't

The monorepo article set up apps/ for deployables and packages/ for shared code. Folder structure inside each app is one problem; deciding what crosses that boundary into a shared package is a different one, and it's where I've watched teams overcorrect the most.

A packages/types folder is an easy call. It has zero business logic, just interfaces, and both apps need the exact same shape for a User or an Invoice. A packages/config folder for shared ESLint and TypeScript config is another easy call, since divergence there is pure friction with no upside.

Where it gets tempting, and often wrong, is a packages/utils grab bag. The moment you write a date-formatting helper in the frontend, it's tempting to move it to a shared package "in case the backend needs it too." Resist that until the backend actually needs it. A shared package you maintain speculatively is a tax you pay on every change, since now two apps depend on it and a "small" edit needs a version bump and a rebuild across the graph.

Let duplication happen once or twice. If the same logic shows up a third time in two apps, that's real evidence. Moving it out now pays off a debt instead of prepaying one that might never come due.

// packages/types/src/invoice.ts
export interface Invoice {
  id: string;
  organizationId: string;
  amountCents: number;
  currency: string;
  status: 'draft' | 'sent' | 'paid' | 'void';
  dueDate: string;
}

That's the shape both apps/api and apps/web import. It has no logic to drift, so there's nothing to keep in sync beyond the field list itself, and TypeScript enforces even that for free.

Production pitfalls worth naming early

A few structural mistakes are easy to make and expensive to unwind once real code depends on them.

  • Barrel files everywhere: an index.ts in every folder that re-exports everything inside it. They feel convenient for imports, but they quietly encourage circular dependencies, since it becomes easy for module A to import from module B's barrel, which imports from module A's barrel, without either author noticing. Use barrels sparingly, mainly at package boundaries like packages/types, not inside every feature folder of the API or the app.

  • Letting common/ (or a shared/ folder in the frontend) become a dumping ground. If more than a small fraction of your code lives there, the "shared" folder has quietly become an unstructured second app sitting inside your structured one. Revisit it periodically and move anything with a single obvious owner back into that feature's folder.

  • Inconsistency between the two apps. If apps/api groups by feature but apps/web groups by technical type, every engineer pays a small tax switching mental models depending on which side of the stack they're touching. Picking the same organizing principle on both sides, feature first, keeps the switching cost near zero.

  • A NestJS module that imports half the app because a controller in invoices needs one small piece of logic from organizations. That's usually a sign the boundary is drawn in the wrong place, or that the shared piece belongs in a smaller, more focused shared module rather than a direct cross-import between two big feature modules.

Key takeaways

  • Group code by feature (vertical slices), not by technical layer, on both the NestJS API and the Next.js frontend. It matches how features actually get changed.
  • NestJS modules are a feature boundary by design; let each module own its controller, service, DTOs, and entities.
  • Next.js route groups ((name)) organize by area of the product without changing the URL; underscore folders (_components) opt out of routing for colocated code.
  • Only promote code into packages/ once real duplication shows up twice or more; a speculative shared package is a tax on every future change.
  • Keep common//shared/ folders small and generic; if they grow large, that's a sign features are being drawn in the wrong place.
  • Use the same organizing principle (feature-first) across both apps so engineers don't switch mental models when moving between frontend and backend.

FAQ

Should I structure a NestJS project by feature or by layer?
By feature. Group each feature's controller, service, DTOs, and entity together in one module folder. A controllers/, services/, dtos/ split by technical layer looks organized at first but forces every feature change to touch multiple unrelated folders as the app grows.

What's the difference between packages/ and apps/common / shared?
packages/ is for code shared across separately deployed apps in the monorepo, like packages/types used by both the API and the frontend. A common/ folder inside a single app is for cross-feature code within that one app, like a global exception filter or logging interceptor.

How do Next.js route groups work, and do they affect the URL?
A folder wrapped in parentheses, like (dashboard), groups routes and can hold a shared layout, but the parenthesized name never appears in the URL. (dashboard)/invoices/page.tsx still resolves to /invoices.

When should I move code into a shared package instead of duplicating it?
Once the same logic shows up in two apps for the third time, not the first. Two occurrences are often coincidence; a third is a real pattern. Moving code out too early creates a shared dependency you now have to version and rebuild for every small change.

What is a barrel file, and why can it cause circular dependency issues?
A barrel file is an index.ts that re-exports everything in a folder. Overusing them across feature folders makes it easy for two modules to import from each other's barrels indirectly, creating circular dependencies that are hard to trace back to their source. Keep barrels mainly at package boundaries.

How do I know my folder structure has stopped scaling?
A common signal is a single small feature change touching more than two or three folders, or a common/shared folder that's grown as large as your actual feature folders. Both mean the boundaries need revisiting before the next feature makes it worse.

Further reading

  • Previous: Managing Environment Variables and Secrets
  • Next: A Git Workflow for Small Teams
  • Start of series: Choosing the Right Tech Stack for Your SaaS

About the Author

Hi, I'm Aman Singh - Senior Full Stack Engineer specializing in scalable SaaS products, distributed systems, cloud architecture, and AI-powered applications. I write about System Design, Full Stack Engineering, Distributed Systems, Redis, PostgreSQL, AWS, Node.js, and NestJS.

Comments

No comments yet. Start the discussion.