A Folder Structure That Scales for Full Stack SaaS
Your .env files are validated and out of Git by now, thanks to the last article on managing environment variables and secrets. The app boots with the right config in every environment. What it doesn't have yet is a shape that survives more than a handful of features. This is part of the Full Stack S
Your .env files are validated and out of Git by now, thanks to the last article on managing environment variables and secrets. The app boots with the right config in every environment. What it doesn't have yet is a shape that survives more than a handful of features. This is part of the Full Stack SaaS Masterclass, a build-it-for-real series that takes a multi-tenant SaaS from an empty folder to production. Today's topic sounds boring compared to auth or payments, but I've seen more codebases rot from folder structure than from any single bad library choice. Here's the thing about structure: nobody notices it on day one, when you have three routes and two services. Everybody notices it eighteen months in, when adding a field to an invoice means touching twelve files scattered across four unrelated folders. This article is about setting the shape early enough that it never becomes a rewrite. 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. 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. 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. A few structural mistakes are easy to make and expensive to unwind once real code depends on them. The first is 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. The second is 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. The third is 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. The fourth, specific to NestJS, is a 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. 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. Should I structure a NestJS project by feature or by layer? 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? (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? What is a barrel file, and why can it cause circular dependency issues? 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? 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. 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. Portfolio: https://amanksingh.com GitHub: https://github.com/amansingh1501 Product: https://vowerole.com Email: aman97aman@gmail.com
Key Takeaways
- โขYour .env files are validated and out of Git by now, thanks to the last article on managing environment variables and secrets
- โขThis story was reported by Dev.to, covering developments in the dev space.
- โขAI advancements continue to reshape industries โ read the full article on Dev.to for complete coverage.
๐ Continue reading the full article:
Read Full Article on Dev.to โShare this article



