I always say that building software is not that hard until it needs to be:
- Understandable
- Maintainable
- Scalable
That is when things get tricky. ๐
Still, there is always a way forward. The key is understanding the principles that help us build software that stays understandable, maintainable, and scalable over time.
Once those principles are clear, we can think more carefully about architecture, abstraction, coupling, cohesion, debugging, and everything else that shapes a healthy codebase.
In Next.js, achieving good abstractions with low coupling and high cohesion can be challenging. The framework is semi-opinionated and relies heavily on file-based routing. After a lot of trial and error, I found an approach that works well for building Next.js apps with a clean layered architecture.
So, what is a clean layered architecture?
It is an approach inspired by Uncle Bob's clean code ideas. It is not the simplest approach, but it gives us a solid foundation. To understand it, we can start with a few core principles:
Separation of Concerns
Break the software into clear parts so each one focuses on a specific concern.
Single Responsibility Principle
Each module should have one clear responsibility.
Dependency Inversion Principle
High-level modules should not depend directly on low-level modules. Both should depend on abstractions.
There are more principles behind this style, but these three are enough to get started. Based on them, we can organize the application into these layers:
- Presentation Layer
- Repository Layer
- Service Layer
- DTO (Data Transfer Object)

How does this look in a Next.js project?
We can organize the code into four main folders: App, Repositories, Services, and DTOs.
App: all UI components and pages.
Repositories: logic for interacting with the database.
Services: business logic.
DTO: mappers for shaping output data.
For example, let us look at a user registration flow.
The user interacts with the presentation layer. When the user submits the form, they trigger a server action. That server action runs the register user service. The service then calls the necessary methods from the user repository.
Server Action
"use server";
import { createServerAction } from "zsa";
import { redirect } from "next/navigation";
import { registerUser } from "@/services/auth.services";
import { sendVerificationEmail } from "@/services/email.services";
import { registerSchema } from "@/services/validations/auth.schema";
export const registerAction = createServerAction()
.input(registerSchema, { type: "formData" })
.handler(async ({ input }) => {
const { name, email, password } = input;
const user = await registerUser({ name, email, password });
await sendVerificationEmail(user.id, user.email, user.verificationCode);
redirect(`/verify?id=${user.id}`);
});
I recommend using the zsa package to make server actions easier to abstract. It works nicely with Zod for input validation and gives us a cleaner way to handle validated input. The server action should behave like a controller: it receives the request, coordinates the response, and delegates business logic to the service layer.
Presentation Layer
"use client";
import Link from "next/link";
import { useServerAction } from "zsa-react";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { OauthLogin } from "../oauth-login";
import { registerAction } from "./action";
export default function Page() {
const { isPending, executeFormAction, error, isSuccess } = useServerAction(registerAction);
return (
<main className="space-y-6">
<section>
<h3>Register</h3>
<p>Create an account to continue</p>
</section>
<section className="space-y-2">
<form action={executeFormAction}>
<Input name="name" placeholder="Full Name" />
<Input name="email" placeholder="Email" />
<Input name="password" placeholder="Password" type="password" />
<Button disabled={isPending} className="w-full">
Register
</Button>
</form>
<OauthLogin />
{isSuccess && <Alert variant="success">Register success, please verify your email</Alert>}
{error?.fieldErrors?.name && <Alert variant="error">{error?.fieldErrors?.name}</Alert>}
{error?.fieldErrors?.email && <Alert variant="error">{error?.fieldErrors?.email}</Alert>}
{error?.fieldErrors?.password && <Alert variant="error">{error?.fieldErrors?.password}</Alert>}
</section>
<section>
<p>
Have an account?{" "}
<Link href="/login" className="link">
Login
</Link>
</p>
<Link href="/forgot-password" className="link">
Forgot password?
</Link>
</section>
</main>
);
}
With zsa, we can use the useServerAction hook, which gives us useful state like isPending, error, and isSuccess. That makes it much easier to build a better UI and handle errors properly. The presentation layer should stay focused on rendering and user experience. It should not contain business logic.
Register Services
const userRepo = UserRepository.getInstance();
export async function registerUser(args: { name: string; email: string; password: string }) {
const { name, email, password } = args;
// Check Collision
const user = await userRepo.findUserByIdOrEmail(email);
if (user) {
throw new Error("User already exists");
}
// Create User
const hashpassword = await argon.hash(password);
const newUser = await userRepo.createNewUser({ name, email, password: hashpassword });
const verificationCode = await VerificationRepositories.createVerificationCode(newUser.id);
return RegisterUserDTO.fromEntity(newUser, verificationCode);
}
This service focuses on coordinating repository calls:
- Check whether the user already exists.
- Create the new user.
- Return the result through a DTO.
DTO
export class RegisterUserDTO {
public id: string;
public name: string;
public verificationCode: string;
public email: string;
constructor(id: string, name: string, email: string, verificationCode: string) {
this.id = id;
this.name = name;
this.email = email;
this.verificationCode = verificationCode;
}
static fromEntity(entity: TUser, verificationCode: TVerification): RegisterUserDTO {
return new RegisterUserDTO(entity.id, entity.name, entity.email, verificationCode.code);
}
}
The DTO layer is responsible for transforming raw entities into the data shape we actually want to return. This is especially important for avoiding accidental leaks of sensitive information.
Conclusion
- A layered architecture makes the codebase easier to maintain.
- If the returned data needs to change, we can usually update the DTO without touching everything else.
- Business logic becomes safer and easier to manage because it is composed from repository methods.
- Testing becomes simpler because each layer is more isolated.
- The
zsapackage makes server actions easier to manage and gives us more convenient utilities thanuseFormState,useFormStatus, oruseActionState. - Zod keeps input validation straightforward, and debugging becomes easier because errors can be thrown and traced at each layer.