Managing complexity: Shared business logic in Next.js (Part 1: Server-Side Architecture)
A strategic approach to structuring and implementing reusable logic
Let’s say you’re working on a rather complex Next.js application. You’ve got server-side rendering, and client-side interactions all put together to achieve a set of requirements. But as the codebase grows, you reach a point where you begin to notice things becoming redundant. The piece of business logic written in a Route Handler (API route) now finds itself suspiciously repeated in a Server Action. Parts of the error handling in a route begin to look very similar to what was required in another part of the application.
This is the problem that I faced recently when I was working with Next.js, specifically on version 14. But this isn’t Next-specific; it happens whenever you’re writing software that you need to maintain — tomorrow, next week, or the next 6 months. I realized I needed to find a way to share and organize the business logic effectively to maintain sanity while juggling life challenges.
I needed to sleep better.
In this piece, I’ll share my practical approach to this problem, the solutions I’ve found effective so far, and the lessons learned along the way. Regardless of the complexity or scale of the project you’re working on, I hope you’ll find something useful in this chunk of text. Let’s see…
The structure
To tackle this challenge, I’ve put together a project structure that houses the application’s business logic. However, to maintain the scope of this writing, I’ll focus on the folders that are relevant to the topic.
🗂️ /repositories
This folder is the backbone of my data access layer. It contains classes that encapsulate all database operations and external API calls. By centralizing these operations, I’ve found it much easier to maintain consistency and make changes to data access patterns across the application.
For example, I might have a UserRepository
class that handles all user-related database operations:
export class UserRepository implements IUserRepository {
async getById(id: string): Promise<User | null> {
// Database query logic here
}
async updateById(id: string, data: UserUpdateData): Promise<User> {
// Update logic here
}
}
With the data access layer set up, let’s move on to where I implement the core business logic.
🗂️ /actions
The /actions
folder contains server-side functions that implement my application’s business logic. These functions often use the repository classes to perform data mutations, apply business rules, and return the results.
'use server';
import { UserRepository } from '@/repositories/UserRepository';
export async function updateUser(userId: string, userData: UserUpdateData) {
// Auth checks
// Rate limit checks
// Validation and sanitization
const userRepo = new UserRepository();
// Apply business logic
return await userRepo.updateById(userId, userData);
}
import { UserRepository } from '@/repositories/UserRepository';
export async function getUserById(userId: string) {
const userRepo = new UserRepository();
return await userRepo.getById(userId);
}
🗂️ /api
This folder contains all the Route Handlers (API routes). I use them mainly for GET
operations and POST
only for webhooks, and also for interfacing with external APIs.
// app/api/users/[id]/route.ts
import { getUserById } from '@/actions/getUserById';
export async function GET(request: Request, { params }: { params: { id: string } }) {
try {
const result = await getUserById(params.id);
return createSuccessResponse(result);
} catch(error) {
return createErrorResponse(error);
}
}
You might be wondering why I’m not using Server Actions for all operations, including GET
requests. The reason is that I have requirements to expose certain endpoints for external consumption, which isn’t possible with Server Actions at the time of writing.
🗂️ /app
This is where the application core is housed, I limit this folder to only include special files based on Next.js Files Conventions, and have everything else located outside in folders like features
, actions
, repositories
, and shared
to prevent cluttering the main folder.
With the getUserById
function earlier, now I can use it in side my server component <UserPage />
. I can go without using the 'use server'
directive provided getUserById
will/should only be used in server components, otherwise I should be expecting an error for accessing it via client component.
// app/users/[id]/page.tsx
import { getUserById } from '@/actions/getUserById';
export default async function UserPage({ params }) {
const user = await getUserById(params.id);
return <UserProfile user={user} />;
}
This structure allows me to maintain a clean, organized codebase while sticking to Next.js conventions effectively.
Implementing Shared Business Logic
Now that we have a rough idea about how the project looks like, let’s move on to how I actually implemented the shared business logic.
Reusable repository classes
The repository classes serve as the foundation for data access. Here’s how I approached creating these:
- I started with a base repository class that includes common operations.
- For each domain entity, I created a specific repository that extends this base class.
For example, here’s a simplified ContentRepository
.
class ContentRepository extends BaseRepository {
async getContentById(id: string): Promise<Content | null> {
// Implementation using Prisma ORM
}
async createContent(data: CreateContentInput): Promise<Content> {
// Implementation
}
}
By centralizing these operations, I was able to ensure consistent data access patterns across the application.
Action functions
With the repositories in place, I can now create action functions that encapsulate the application's business logic. These functions are the bridge between the data layer and the rest of the application. Here’s an example:
'use server';
export async function createLinkContent(input: CreateLinkContentInput) {
const contentRepo = new ContentRepository();
// Validation logic
const validatedInput = validateCreateLinkContentInput(input);
// Business logic
const content = await contentRepo.createContent({
type: 'link',
...validatedInput
});
// Additional operations (e.g., sending notifications)
await sendContentCreationNotification(content);
return content;
}
These action functions can be used in both Server Actions and API routes, ensuring consistent behavior regardless of how they’re called.
Route Handlers
For the Route Handlers (API Routes), I use the same action functions. Primarily for GET
operations and POST
only for webhooks or external API interfaces. This approach keeps the route handlers thin and focused on HTTP
concerns. Here’s an example:
export async function GET(req: Request, { params }: { params: { id: string } }) {
try {
const result = await getContent(params.id);
return NextResponse.json(result);
} catch (error) {
return createErrorResponse(error);
}
}
export async function POST(req: Request) {
try {
const body = await req.json();
await handleWebhook(body);
return NextResponse.json({ success: true });
} catch (error) {
return createErrorResponse(error);
}
}
By using this structure, I was able to maintain consistency between Server Actions and API routes while keeping the code DRY.
Server Actions in Next.js 14
Next.js 14 introduced Server Actions, a powerful feature that allows us to define and execute server-side operations directly within our components or pages. This feature aligns perfectly with the shared business logic approach, enabling seamless integration of the action functions into the application flow.
Implementing Server Actions with shared logic
Let’s look at how I apply the shared action functions in Server Actions. Here’s an example of creating new link content:
'use client'
import { createLinkContent } from "@/actions/createLinkContent";
import { useForm } from "react-hook-form";
export default function CreateLinkPage() {
const { register, handleSubmit } = useForm();
const onSubmit = async (data) => {
const result = await createLinkContent(data);
if (result.success) {
// Handle success
} else {
// Handle error
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('url')} placeholder="Enter URL" />
<button type="submit">Create Link</button>
</form>
);
}
In this example, I’m calling the createLinkContent
action function directly from the client component (please be reminded that you need 'use server'
directive on top of the action file when accessing it via client components). Next.js handles the serialization of form data and server communication behind the scenes.
Inline Server Actions
It’s worth noting that Next.js 14 also allows us to define Server Actions inline within server components. While this approach might not always align with my shared logic strategy (and I am not really a big fan of it 🙂), it can be useful for straightforward, component-specific actions. Here’s a quick one:
export default function UserProfile({ params }) {
async function updateUserName(formData: FormData) {
'use server';
const newName = formData.get('name');
// Update user name in the database
revalidatePath(`/users/${params.id}`);
}
return (
<form action={updateUserName}>
<input type="text" name="name" />
<button type="submit">Update Name</button>
</form>
);
}
Error Handling and Type Safety
One challenge with Server Actions is ensuring proper error handling and type safety across the client-server boundary. Here’s how I approach this:
import { createErrorResponse } from '@/errors/server/utils';
import { parseInput, type CreateInput } from '@/schemas/link';
export async function createContent(input: CreateInput) {
try {
const parsedInput = parseInput(input);
// Perform content creation logic here
const content = await contentRepository.create(parsedInput);
// ... rest of the function
return { success: true, data: content };
} catch (error) {
if (error instanceof BadRequestError) {
return createErrorResponse(error);
}
return createErrorResponse(new Error('An unexpected error occurred'));
}
}
Custom error classes ⬇️
I’ve implemented a hierarchy of custom error classes, starting with a BaseServerError
:
export class BaseServerError extends Error {
readonly statusCode: HttpStatusCode;
readonly name: ErrorNameValue;
readonly details: string[] = [];
constructor(
name: ErrorNameValue,
statusCode: HttpStatusCode,
message: string,
details: string[] = [],
) {
super(message);
this.name = name;
this.statusCode = statusCode;
this.details = details;
Error.captureStackTrace(this, this.constructor);
}
}
Specific error types️️ ⬇️
I extend this base class for specific error scenarios:
export class BadRequestError extends BaseServerError {
constructor(message: string = 'Bad Request') {
super('BadRequest', 400, message);
}
}
Error response creation ⬇️
The createErrorResponse
function helps create a standardized error structure:
export function createErrorResponse(error: BaseServerError) {
return {
success: false,
error: {
message: error.message,
code: error.name,
details: error.details
}
};
}
Input Validation ⬇️
I use a schema validation library (like Zod or Valibot) to define the expected shape of the input data. The parseInput
function validates the input against this schema and returns a typed result that matches the defined structure, throwing validation errors if the input is invalid.
Type Safety ️️⬇️
By defining the CreateInput
type, I ensure that the function argument is correctly typed, providing better IDE support and catching type-related errors early.
By using TypeScript for input validation and this consistent error-handling approach, I was able to create a robust interface between client and server. This approach not only catches errors early but also provides clear, structured feedback to the client, improving the overall reliability and user experience of the application.
Best Practices
Through my experience with Server Actions, I tend to follow these practices:
- Keep actions focused. Each action should do one thing well.
- Always validate inputs server-side, even if you’re also validating on the client.
- Return structured error responses for straightforward client-side interpretation.
- Leverage TypeScript for type safety between client and server code.
By following these practices and utilizing the shared business logic, Server Actions become a powerful tool for creating a more maintainable JS/TS project.
Route Handlers and API integration
While Server Actions cover many of our needs, there are still scenarios where traditional API routes are necessary, particularly for external API integration and GET
operations.
In Next.js 14, creating API routes that leverage the shared business logic is straightforward. Here’s an example of a GET
route for data retrieval:
// src/app/api/links/[id]/route.ts
import { getContent } from "@/actions/getContent";
import { NextResponse } from "next/server";
export async function GET(req: Request, { params }: { params: { id: string } }) {
try {
const result = await getContent(params.id);
return NextResponse.json(result);
} catch (error) {
return NextResponse.json(
{ error: "Failed to retrieve content" },
{ status: 500 }
);
}
}
This approach ensures consistency between the Server Actions and API routes by using the same underlying action functions.
Ensuring consistency in response handling
To maintain consistency across different entry points, I’ve implemented a standardized response structure:
export interface HttpResponse<T> {
success: boolean;
data: T | null;
error: {
message: string;
code?: string;
} | null;
}
This structure is used consistently in both Server Actions and API routes, making error handling and data processing on the client side more predictable.
Key Takeaways
Through this process of integrating the shared business logic with API routes, I’ve learned several valuable lessons:
- Reuse is crucial: Using the same action functions in both Server Actions and API routes significantly reduces code duplication.
- Consistent error handling improves debugging and client-side integration.
- TypeScript enhances both input validation and response typing, catching errors early and improving API reliability.
- Thinking in layers (separating HTTP concerns from business logic) results in a more modular and maintainable codebase.
By applying these principles, it helps me create a cohesive server-side architecture. This architecture seamlessly integrates with both Server Actions and traditional API routes, all while leveraging the shared business logic.
Outro
Implementing shared business logic in Next.js 14 has proven to be a game-changer in managing complex applications. By structuring my project with dedicated folders for repositories, actions, and API routes, I was able to establish a rather solid foundation for scalable and maintainable code.
The key takeaways from this approach are:
- Centralized data access through repository classes ensures consistency and simplifies updates to data patterns.
- Action functions serve as a bridge between data access and application logic, promoting reusability across Server Actions and API routes.
- Server Actions in Next.js 14 seamlessly integrate with our shared logic, enabling efficient server-side operations directly within components.
- Traditional API routes still have their place, especially for external integrations, and can leverage the same shared logic for consistency.
- A standardized error handling and response structure across all server-side operations improves debugging and client-side integration.
This architecture not only reduces code duplication but also enhances type safety, improves error handling, and creates a more modular codebase. As we’ve seen, whether we’re working with Server Actions or traditional API routes, the shared business logic serves as the backbone, ensuring consistency and maintainability.
In the next part, I’ll move on to integrating this server-side architecture with the client-side using TanStack Query(React Query if you will), which has further enhanced the application’s efficiency and overall user experience.
By adopting these practices, I was able tackle the challenges of growing codebases, reduce redundancy, and indeed, sleep better at night without worrying how to wrestle with the code I wrote last night or last week and focus more on the features that I am about to ship next. See you in the next one(no pun intended)!