Personal Portfolio

Introduction
For a long time, I had been putting off creating a personal portfolio website to showcase my skills, experience, and projects. After exploring different tools, I decided to team up with a few friends—one a designer and the other a frontend developer—to build the site from scratch. Together, I brainstormed the content and design, finally deciding to implement the project using Next.js as the main framework.
Technology and Implementation
Next.js and Hosting on Vercel
After discussing various options, I quickly agreed on using Next.js due to its flexibility and performance. I chose Vercel for hosting, which integrates seamlessly with Next.js, offering fast deployment and scalability.
Initial CMS Plans and Payload CMS Integration
Originally, I planned to use an external CMS, but after researching options and discussing with my frontend developer colleague, I discovered Payload CMS. Its smooth integration with Next.js made it the obvious choice. Payload allowed me to manage content efficiently within my application without relying on third-party services.
Project Setup and Architecture
Excited by the plan, I started working on the project, intending to build a proof of concept. The goal was to set up a clean architecture, ensure error handling using Effect, and integrate Payload CMS. Along the way, I stumbled upon a beautiful template by Chronark, which I decided to adapt for my portfolio site. This template provided an excellent starting point, allowing me to focus on customizing the content and design for my personal brand.
CLEAN implementation
1. Entities Layer: Core Business Logic
The entities layer holds my core business logic. Here, I define my domain models and any associated logic, such as custom error handling and data validation. Let's start by showing how custom errors are handled and how Zod is used for strict schema validation of data models.
Custom Errors
import { BaseError } from './types';
export class BlogNotFoundError extends Error {
constructor(data: BaseError = {}) {
super(JSON.stringify(data));
this.name = 'BlogNotFoundError';
}
}
In this case, I use a custom error (BlogNotFoundError
) that extends JavaScript's built-in Error
class. This custom error allows me to manage specific errors related to my blog functionality, such as when a blog is not found.
Zod-Validated Data Models
export const blogSchema = z.object({
id: z.number(),
title: z.string(),
summary: z.string().optional().nullable(),
slug: z.string(),
author: z.string(),
authorImage: z.object({
url: z.string(),
alt: z.string(),
}).optional().nullable(),
thumbnail: z.object({
url: z.string(),
alt: z.string(),
width: z.number(),
height: z.number(),
}).optional().nullable(),
date: z.string().transform((str) => new Date(str)),
published: z.boolean().optional().nullable(),
type: z.string(),
});
Here, I use Zod to define a schema for my Blog entity. The schema helps ensure that the data models conform to the expected structure. I also use z.infer to infer the types from the schema:
export type Blog = z.infer<typeof blogSchema>;
export type BlogDto = z.input<typeof blogSchema>;
2. Application Layer: Use Cases and Repositories
The application layer contains the use cases that handle business operations and interact with repositories to retrieve and store data. Here's an example of the repository interface and the use case implementation for fetching a blog by its slug.
Repository Interface
import { BlogWithDetailsDto } from '../../entities/models/blog';
export interface IBlogsRepository {
getBlogBySlug(slug: string): Promise<BlogWithDetailsDto>;
}
Use Case
export function getBlogBySlugUseCase(slug: string): Effect.Effect<BlogWithDetails, BlogNotFoundError | ZodParseError> {
const repository = getInjection('IBlogsRepository');
const program = Effect.tryPromise({
async try() {
const blog = await repository.getBlogBySlug(slug);
if (!blog) {
throw new BlogNotFoundError();
}
return blog;
},
catch(error: unknown) {
return new BlogNotFoundError({
originalError: error
})
}
});
const parseBlogEffect = (blog: unknown) =>
Effect.try({
try() {
return blogDetailSchema.parse(blog);
},
catch(_error: unknown) {
return new ZodParseError('BlogWithDetails', {
originalError: _error,
data: blog
});
},
});
return program.pipe(Effect.flatMap(parseBlogEffect));
}
This use case retrieves the blog from the repository, handles any errors (e.g., blog not found), and ensures the data matches the required schema using Zod.
3. Infrastructure Layer: Implementing Repositories
The infrastructure layer contains the actual implementations of the repository interfaces, interacting with external systems or databases.
In this case, I use Payload CMS and its local API to fetch blog data:
@injectable()
export class PayloadBlogsRepository implements IBlogsRepository {
_getPayload() {
return getPayloadHMR({ config });
}
constructor() {}
async getBlogBySlug(slug: string): Promise<BlogWithDetailsDto> {
const payload = await this._getPayload();
const locale = await getSafeLocale();
const blog = await payload.find({
collection: 'blogs',
locale,
where: {
slug: {
equals: slug
}
},
});
return blog.docs?.[0] as BlogWithDetailsDto;
}
}
I use Inversify for dependency injection. The PayloadBlogsRepository
class implements the IBlogsRepository
interface, allowing me to inject the repository when needed.
4. Interface Adapters Layer: Controllers and Presenters
The interface adapters layer bridges the gap between the infrastructure and application layers, formatting data for use in the user interface or API.
Controller Example
function presenter(blog: BlogWithDetails) {
return {
...blog,
gallery: staticImages(blog.gallery),
authorImage: staticImage(blog.authorImage)
}
}
export function getBlogBySlugController(slug: string): Effect.Effect<ReturnType<typeof presenter>, BlogNotFoundError | ZodParseError> {
return Effect.map(
getBlogBySlugUseCase(slug),
(blog) => presenter(blog)
);
}
The controller here maps the blog data to a format suitable for the frontend. The getBlogBySlugController
function ties everything together by calling the getBlogBySlugUseCase
, handling errors, and presenting the result in the appropriate format.
I can now run the controller, ensuring that any parsing errors are caught and logged:
await Effect.runPromise(
getBlogBySlugController(slug)
.pipe(
Effect.catchAll((error) => {
if (error instanceof ZodParseError) {
console.error('Zod validation error:', error);
} else {
console.error('An unexpected error occurred:', error);
}
return Effect.fail(error); // Re-throw the error after logging
})
)
);
Big shoutout to Laza Nikolov for showing in detail how to implement the CLEAN architecture in Next.js.
Why Use Effect?
I use Effect to handle side effects in a functional, composable way. It allows me to manage asynchronous code and error handling more gracefully by separating the execution from the definition of effects, ensuring clearer, more maintainable logic.
Dependency Injection with Inversify
I use Inversify for dependency injection to decouple my application layers. This allows for more flexible and testable code by injecting the appropriate dependencies (such as the repository) where needed without tightly coupling them.
Key Features
- Next.js framework for fast, server-side rendered content.
- Vercel hosting for seamless deployment and performance.
- Supabase database for scalable data storage.
- Payload CMS integration for content management within the Next.js app.
- CLEAN architecture principles for maintainability and scalability.
- Effect for robust error handling throughout the app.
Current Status
The first version of my portfolio site is now live. Although it's still a work in progress and may evolve further, the current iteration serves as a solid foundation to showcase my experience and skills. You can view the site here.
Conclusion
This project provided me with the opportunity to finally build a personal portfolio site using modern tools like Next.js, Vercel, and Payload CMS. Working closely with a designer and frontend developer allowed for rapid development, and the integration of clean architecture ensures that the site can grow and adapt over time.