Guides8 min read

Next.js Folder Structure: What Survived Ten Releases

The tree behind a production App Router codebase: route groups, where the private area sits, and the refactor that turned every blog image into a 404 for two days.

Most folder structure articles show a tree that has never been deployed. Here is one that has, through ten releases in seven weeks, an internationalisation pass that moved half the routes, and one refactor that made every image on the blog a 404 for two days.

├── content/          # MDX: articles, docs, changelog
├── docs/             # written for whoever clones the repository
├── e2e/              # Playwright, user journeys
├── prisma/           # schema and migrations
├── scripts/          # maintenance tasks, not runtime code
└── src/
    ├── app/
    │   ├── (admin)/          # route groups: no URL segment
    │   ├── (auth)/           # login, signup, password reset
    │   ├── (dashboard)/
    │   ├── [locale]/
    │   │   ├── (docs)/
    │   │   └── (public)/     # landing, pricing, blog, legal
    │   ├── actions/          # server actions, grouped by domain
    │   └── api/              # webhooks, cron, health, checkout
    ├── components/
    │   ├── admin/ auth/ billing/ blog/ dashboard/ landing/
    │   └── ui/               # generic, no product knowledge
    ├── config/
    ├── hooks/
    ├── i18n/
    ├── lib/                  # server logic, tests alongside
    ├── locales/
    ├── types/
    ├── auth.ts
    └── proxy.ts

Sixty six components, fourteen modules in lib, sixteen test files. Below is why each decision is there, and which ones we would make differently.

Route groups do the organising, folders do not

The App Router gives you one organisational tool that costs nothing: a folder in parentheses is removed from the URL. app/(dashboard)/dashboard/page.tsx still serves /dashboard.

That is worth more than it sounds, because it decouples two things that otherwise fight each other: the shape of your URLs, which belongs to your users, and the shape of your tree, which belongs to you. Without groups, every attempt to tidy the tree changes an address, and every address you like forces a folder you do not.

We use four groups and they map to the four kinds of page in the product: (admin), (auth), (dashboard) and (public). Each carries its own layout, so the admin area gets its own shell and its own guard in one place:

// app/(admin)/layout.tsx
const session = await auth()
if (!session) redirect("/login")
if (session.user.role !== "ADMIN") redirect("/dashboard")

The rule we settled on: a group exists when a set of routes shares a layout or a guard. Not for tidiness alone. A group that wraps nothing is a folder you have to explain.

The private area sits outside the locale segment

This is the decision that took longest and is the least obvious.

When the app gained a [locale] segment, the tempting move was to put everything under it. We deliberately did not. Marketing pages, documentation and the blog live under [locale], because search engines need one address per language. The dashboard, the admin area and the auth pages sit outside it.

Two reasons, one for users and one for whoever maintains the thing.

For users, a private page has no reason to exist at two addresses. Nobody links to /it/dashboard, nobody searches for it, and having it means the same screen has two canonical URLs.

For maintenance, the prefix changes every path comparison in the app. Route protection matches on /dashboard, and the day a prefix appears in front of it, that match silently stops working. We hit exactly that shape of bug elsewhere in the codebase: a component comparing pathname to /docs behaved differently in Italian, where the path is /it/docs, and it was invisible in the default language. The fix was a test that fails if a client component under the localised surface imports usePathname directly, because a comparison on the path asks which page is this, and the answer must not change with the language.

Keeping the private area outside the prefix removes that whole class of bug rather than defending against it.

Components by feature, with one folder by shape

components/ mirrors the product, not the framework: admin, auth, billing, blog, dashboard, docs, landing. When you need the invoice table, you look in billing, and it is there.

The exception is ui, which holds the generic pieces: button, dialog, input, the things with no product knowledge. It is organised by shape because it has no feature to belong to.

The rule that keeps the split from rotting is about direction, not naming: ui never imports from a feature folder. The moment a dialog knows what a subscription is, it is not a ui component any more, it is a billing component that happens to look generic. That single rule has settled every argument we have had about where a file goes.

We do not have a containers folder, or views, or presentational. Those describe a layering that the App Router already expresses through server and client components, and duplicating it in folder names means two vocabularies for one idea.

Server logic in lib, flat, with tests beside it

lib/ is fourteen files and no subfolders: billing.ts, blog.ts, docs.ts, email.ts, password.ts, prisma.ts, rate-limit.ts, and so on. Each is a subject, each exports functions, and each has its test file next to it: billing.ts and billing.test.ts in the same directory.

Flat is a choice with an expiry date, and knowing when it expires is the useful part. Under roughly twenty files, a flat folder is faster to scan than a nested one. Above that, you start reading the list instead of seeing it, and that is the signal to group by subject rather than the file count reaching a round number.

Tests sitting next to the code they cover is the same idea from a different angle. A test in a mirrored __tests__ tree is a file you have to remember to move. A test next to its module gets renamed with it, moved with it, and deleted with it, and it is visible while you edit the thing it tests. End to end tests are the opposite case: they describe user journeys rather than modules, so they live in e2e/ at the root with their own runner.

Content is data, so it lives outside src

Articles, documentation and the changelog are MDX and markdown files in content/ at the repository root. Not in app/, not in src/.

The reason is who touches them and when. Content is read at build time, it changes on a different rhythm from the code, and adding an article should not require opening the application tree. Keeping it outside src/ also makes an exclusion possible: a directory of files can be shipped or withheld as one unit, which is impossible once the same content lives inside components.

The refactor that made every image a 404

Now the part that a structure article can only tell you if it has actually shipped.

When the localisation work moved public routes under [locale], one route came along that should not have: the one serving the blog cover images. It had been at /blog/covers/x.svg, and after the move the only address that answered was /en/blog/covers/x.svg. Every post linked the old path. Every cover was broken.

Nothing failed. The build was green, the type checker was happy, the tests passed, and the linter had no opinion, because nobody type checks a URL. It was found two days later by opening a page in a browser for an unrelated reason.

Two things came out of it, and they are both structural rather than procedural.

The route moved back out of the prefix, because an asset is not a translated page: /blog/covers/x.svg is the same file in every language, and giving it a locale creates a second address for identical bytes. If the response does not change with the language, the route does not belong under [locale].

And two tests now fix the rule together with the reason it exists, which matters more than it sounds: a test that only asserts the path teaches the next person nothing, and gets deleted by whoever finds it inconvenient.

There is a quieter cousin of the same problem worth knowing about. sitemap.ts is generated at build time, so it reads the environment of the build and not of the running server. A flag set at runtime has no effect on it. Anyone debugging that in development concludes the sitemap is broken, when it is doing exactly what it was told.

What we would do differently

Two folders for the same idea. For a while the blog had content in two places for two audiences, and the only thing telling them apart was a name that differed by four characters. It worked, and it cost a correction in a published document when someone counted the wrong folder. If two directories hold the same kind of file, the difference between them has to be visible in the name, not in a convention you remember.

Deciding the locale boundary after the routes existed. Adding [locale] to a tree that already had thirty routes was the single most expensive move in the codebase, and it is the one thing here that gets cheaper the earlier you do it. Even if you ship one language, knowing which routes would eventually take a prefix, and putting the private ones outside it from the start, costs nothing on day one.

The short version

  • Route groups shape the tree without shaping the URL. Use one when routes share a layout or a guard.
  • Keep the private area outside the locale prefix. A dashboard does not need two addresses, and every path comparison in the app is safer for it.
  • Components by feature, ui by shape, and ui never imports from a feature.
  • Flat lib with tests beside the code, until about twenty files.
  • Content outside src, because it is data.
  • Assets that do not change with the language stay out of [locale]. No compiler will tell you otherwise.

The tree above is not a proposal, it is the repository this site is built from, so you can read the parts this guide summarises.