Brian Makumi
How I Structure a .NET and Next.js Project for Long-Term Maintainability

How I Structure a .NET and Next.js Project for Long-Term Maintainability

March 29, 2026
·

Brian Makumi

Every developer has a moment where they open a project they wrote six months ago and immediately feel regret. Not because the code is broken, but because it is a maze. Files in unexpected places. Logic scattered across layers that were never meant to hold it. A structure that made sense on day one and became a liability by week four.

I have been in that maze. I have also spent enough time building full-stack products to develop a set of conventions that make projects genuinely easy to navigate, extend, and hand off, whether that means onboarding a collaborator, revisiting the codebase after a long break, or scaling a feature that was originally scoped small.

This post walks through how I structure a .NET and Next.js project from the ground up. Not as a rigid template, but as a set of deliberate decisions with reasoning behind each one. The conventions apply consistently across every project I build, which means every new project starts from a familiar foundation rather than a blank slate.

A good project structure is not something you think about after the project is done. It is a decision you make before you write the first file.

THE TWO CODEBASES, ONE PRODUCT MINDSET

A .NET backend and a Next.js frontend are two separate codebases. They live in separate repositories, they deploy independently, and they have different runtime environments. But they serve one product, and that product has a single source of truth for its data contracts.

The mistake I see most often is treating these two codebases as completely independent concerns. The backend team designs the API around the database schema. The frontend team adapts. The result is a frontend full of transformation logic that should have lived in the API layer, and a backend that has no awareness of what the UI actually needs.

My approach is to design both sides around the same understanding of what the product needs. The Figma file informs both. The API contract is agreed before either side is built. And the folder structures on both sides reflect the same domain language so that moving between them requires no mental context switch.

THE BACKEND: .NET WITH ONION ARCHITECTURE

Every .NET project I build follows onion architecture, organised into four layers. The naming is consistent across all projects so that anyone familiar with one can navigate another immediately.

The folder structure

YourProject.Core/

Entities/

Interfaces/

DTOs/

YourProject.Application/

Services/

Validators/

YourProject.Infrastructure/

Repositories/

Persistence/

DependencyInjection.cs

YourProject.API/

Controllers/

Middleware/

Program.cs

Core: the contract layer

Core holds everything that defines what the system is, with no knowledge of how it is implemented. Entities are plain C# classes. Interfaces define the repository and service contracts. DTOs define what moves across layer boundaries.

Nothing in Core references Entity Framework, a database, or an HTTP library. If you need to import a NuGet package to make something in Core compile, that is a signal that something belongs in a different layer.

Application: the logic layer

Application contains the business logic. Services here orchestrate calls to repositories, apply rules, and return results. They depend only on the interfaces defined in Core, never on Infrastructure implementations directly.

Validators also live here. I use FluentValidation and attach validators to the service layer rather than the controller layer. Validation is a business concern, not an API concern. Keeping it here means it applies regardless of how the service is invoked.

Infrastructure: the implementation layer

Infrastructure is where abstractions become real. Repositories implement the IRepository interfaces from Core. DbContext lives here. Any third party integrations, whether that is email, SMS, payment providers, or cloud storage, are implemented here as well.

One convention I apply consistently: every Infrastructure layer has a DependencyInjection.cs file with a single extension method that registers all Infrastructure services. Program.cs in the API project calls one line: builder.Services.AddInfrastructure(builder.Configuration). The API project never knows what it is registering. That is the point.

Repository pattern: generic base, specific extensions

public interface IRepository<T> where T : class

{

Task<T?> GetByIdAsync(int id);

Task<IEnumerable<T>> GetAllAsync();

Task AddAsync(T entity);

Task UpdateAsync(T entity);

Task DeleteAsync(T entity);

}

Every entity gets a repository interface that extends this generic base. Specific query methods live on the specific interface, not the generic one. This keeps the generic base clean and makes it obvious which queries belong to which domain.

Status fields: lookup tables, not enums

C# enums are tempting for status fields. They are easy to write and easy to read. The problem is that they are hard to extend without a migration, and they expose your internal implementation to the database in ways that create friction later.

I use lookup tables instead. A Statuses table holds the valid values. Foreign keys reference it. Adding a new status is a data operation, not a code change. It also means your status values are queryable, filterable, and reportable without magic numbers or string conversions.

THE FRONTEND: NEXT.JS WITH APP ROUTER

The Next.js side follows a structure that separates concerns as cleanly as the backend does. The App Router introduced a more intuitive way to think about routing and server components, and my folder conventions are built around it.

The folder structure

app/

(public)/

page.tsx

(protected)/

dashboard/

page.tsx

layout.tsx

components/

ui/

forms/

layout/

lib/

api/

utils/

types/

hooks/

styles/

public/

Route groups for access control

Route groups in the App Router let you organise routes without affecting the URL structure. I use two consistent groups across all projects: (public) for unauthenticated routes and (protected) for routes that require authentication. Middleware checks the group and redirects accordingly.

This makes access control a structural decision rather than a per-page one. Adding a new protected route means placing it in the right folder. You do not have to remember to add an auth check to every new page.

The lib folder: everything that is not a component

The lib folder holds three things that I keep strictly separated: API call functions, utility functions, and shared TypeScript types.

lib/api contains one file per backend resource. A tasks.ts file holds all functions that call task endpoints. An auth.ts file holds authentication calls. Each function maps to one API endpoint. No component ever constructs a fetch URL directly.

lib/types is the shared type layer. Types here mirror the DTOs from the .NET backend. If the backend changes a response shape, this is the one file on the frontend that needs to update. Everything else infers from it.

No component ever constructs a fetch URL directly. If the API changes, one file changes. That is the entire point of the lib/api layer.

Components: three categories, one rule

Components are organised into three folders with a strict rule about what belongs in each.

  • ui holds stateless, reusable presentation components: buttons, inputs, cards, badges. They receive props and render. They have no knowledge of the application domain.

  • forms holds form components with their validation logic. They use React Hook Form and connect to the lib/api layer directly.

  • layout holds structural components: navigation, sidebar, page wrappers. They are used once or twice across the application.

The rule is that a component in ui should have no imports from lib/api. If it does, it belongs in forms or it belongs in a feature folder, not in ui.

THE CONVENTION THAT TIES BOTH SIDES TOGETHER

The most important convention is not a folder structure. It is a discipline about naming.

Every entity in the .NET backend has a corresponding type in lib/types on the frontend. They share the same name. A Task entity in the backend has a Task type on the frontend. A ProjectStatus lookup table has a ProjectStatus type. When you read code on either side, you are reading the same domain language.

This sounds obvious. In practice, it requires a conscious decision at the start of every project, because the temptation is to let the frontend drift into its own naming conventions as it grows. Resisting that drift is what makes a codebase feel coherent six months in rather than like two different people built two different things that happen to talk to each other.

WHAT THIS STRUCTURE ACTUALLY SOLVES

The payoff from consistent structure is not visible on day one. It shows up at the moments that actually matter.

  • When you return to a project after three months away, you know exactly where to find the thing you need.

  • When a new collaborator joins, onboarding is a folder walkthrough rather than an archaeological expedition.

  • When a feature request changes the data shape, you update the DTO in Core, the type in lib/types, and the compiler tells you everywhere else that needs to change.

  • When a client asks for a new integration, you know it goes in Infrastructure, and the rest of the system does not need to know it exists.

None of this is magic. It is the compounding return on decisions made before the first file was created.

CLOSING THOUGHT

Structure is not bureaucracy. It is the thing that lets you move fast on week twelve the same way you moved fast on week one.

The conventions I have described here are not the only valid approach. But they are consistent, reasoned, and battle-tested across multiple production projects. If you are starting a new .NET and Next.js project and you do not yet have a structure you trust, this is a solid place to begin.