← Back to blog

Structuring a Next.js app for long-term maintenance

Feb 12, 2025 · 12 min read · Technology

Next.js gives you file-system routing, streaming, and a clear split between server and client components—but none of that helps if folders sprawl and every feature imports from everywhere else. This post is how I keep an App Router codebase small enough to hold in my head, even after months away from it.

Why structure matters

Maintenance is not “finding the right abstraction once.” It is being able to answer three questions quickly: where does a page live, where does its data come from, and where do I put UI that is shared versus specific to a feature? If those answers depend on tribal knowledge, every new route increases coordination cost. Good structure makes the default path obvious and pushes edge cases toward explicit names.

I treat the repository as a set of boundaries: the framework boundary (Next’s app/ directory), the domain boundary (features that map to user journeys), and the UI boundary (design-system primitives versus composed screens). When those boundaries blur, imports become a graph instead of a tree—and refactors get expensive.

The App Router contract

The App Router’s contract is simple: folders under app/ define URLs; layout.tsx wraps nested segments; page.tsx is the leaf UI; loading.tsx and error.tsx define suspense and failure boundaries for that segment. That is already a maintenance checklist: if something is missing, the framework cannot help you compose it consistently.

I keep one conceptual responsibility per segment. A route segment should answer “what screen is this?” not “everything the product needs.” When a folder grows two unrelated screens, I split segments or promote shared pieces upward into a parent layout or a shared module outside app/.

Routes, layouts, and boundaries

Layouts are the right place for chrome that repeats: navigation, theme providers that must wrap client subtrees, and context that should not remount on every navigation within the segment. Pages stay thin: compose sections, pass serializable props, and avoid burying data fetching five levels deep in presentational components.

  • Colocate errors. Use route-level error.tsx so failures stay close to the UI they affect instead of bubbling to a single global boundary.
  • Colocate loading. Pair loading.tsx with the slowest child of that segment so skeletons match the final layout.
  • Avoid mega-layouts. If a layout imports half the app, it becomes a second entry point and a merge conflict magnet. Push feature-specific wiring into the page or a feature module.

Shared UI vs feature folders

I keep a small components/ui (or similar) directory for primitives: buttons, inputs, typography, and anything that behaves like design tokens with behavior. Anything that knows about business rules—billing states, roles, product copy—lives next to the feature it serves, typically under something like features/<name> or inside the route folder if it is not reused.

The rule of thumb: if deleting a feature should delete the folder without leaving orphan files, the structure is working. If you need a global search to find all references to “invoice,” names and folders are too generic.

Data fetching boundaries

Server Components default to fetching on the server: lean on that for reads that do not need client interactivity. I keep network details in a thin layer—lib/api, repositories, or server-only helpers—so pages import intent (“getProjectForUser”) instead of raw URLs. Client Components get data via props or short-lived client hooks only when interaction demands it.

Caching and revalidation policies belong next to the fetch or in a single module per domain. Scattering fetch calls with different cache options across dozens of files makes behavior impossible to reason about during incidents.

Maintenance habits

Structure decays without habits. Before merging a feature, I ask: did we add a new pattern or follow an existing one? If we added a pattern, is it documented in a short README in the folder or in a single “architecture note” file? New teammates should not need a tour to find where API types live or how errors are shaped.

Periodic cleanup matters: delete unused routes after migrations, collapse duplicate loading states, and tighten imports so feature folders do not import from sibling features through relative paths—prefer a well-named internal API or shared kernel module when sharing is real.

Conclusion

Long-term maintenance is mostly consistency: predictable folders, clear server versus client boundaries, and colocated failure and loading states. Next.js already encodes a strong default through the App Router—your job is to resist the slow drift toward “everything imports everything.” Keep primitives small, features self-contained, and data access named. The reward is not elegance for its own sake; it is shipping the next feature without relearning the whole repo first.