Creating a personal website is an iterative process of aligning expectations with inspirations.
This blog post will give you a glimpse behind the scenes of creating my personal website. I'll walk you through the concept and planning, design and development, content creation, deployment and launch, and share some insights and lessons learned along the way.
Introduction
This website is the result of several years of hard work, dedication, and passion for web development and design. I wanted to create a platform where I could showcase my work, share my thoughts, and connect with others who share my interests. I also wanted to challenge myself and use this as a playground for learning new things along the way.
Concept and Planning
The idea of creating a personal website had been on my mind for quite some time. I wanted a platform where I could showcase my work, share my thoughts, and connect with others who share my interests, as well as to try out new technologies that come to mind.
It was important to me that the website reflected my personality and style, so I spent some time brainstorming ideas and trying to implement different things to see if they matched my expectations. I also considered the features and functionality that I wanted to include, such as:
- a place to showcase my projects, so that people can see what I've been working on and get to know how I solve problems
- a blog to write about some interesting topics and inspire others
- an easily accessible resume for potential employers or collaborators
Design and Development
It started with a fairly simple decision. I wanted to build something in React, the framework I'd just been introduced to during my studies. Next.js followed soon after, popular and well-documented enough that I was never far from an answer when I got stuck, and together they taught me most of what I now consider core web development knowledge.
That first attempt was a basic layout, not much more than a homepage and a project list. I wanted something clean and easy to navigate, so I leaned on a lot of white space, plain typography, and a monochrome palette, which back then meant the default purple color theme that ships with shadcn/ui.
The monochrome look held for a while, but as I wanted the site to say more about who I am and to carry more complex features, plain black and white started feeling flat. I began experimenting with a bolder identity, like a gradient hero card in blue and purple, larger display type, and small cards calling out how I work.
The gradient card idea stuck for a while, and I kept refining it from there. I also swapped in Vercel's Geist font, which fit the more deliberate, slightly technical feel I was going for better than the generic sans I'd started with.
Eventually the gradient card felt like it was doing more decorating than communicating, so I dropped it for something cleaner. The navigation grew a Skills link alongside About, Projects, Writings, and Contact, and the portrait went back to black and white to match the rest of the page.
From monorepo to a single app
For a while this project lived in a Turborepo monorepo, an apps/web app plus shared packages, like ui, eslint-config prettier-config and mdx, each with its own package.json and independent versioning. It felt like the "correct" way to build a software system with reusable patterns for multiple applications, and it taught me a lot about workspace tooling, but it was solving a problem I didn't actually have. There was only ever one consumer of all packages, this website.
So I flattened it. Before, it looked roughly like this:
And after:
packages/ui moved into src/components/ui as a plain subfolder (components.json's ui alias just points there now), and the rest of what used to live in the package, animations, icons, and small helpers like cn.ts, moved into src/animations, src/icons, and src/lib to follow shadcn/ui's documentation. eslint-config and prettier-config disappeared entirely, since the site now runs oxlint and oxfmt instead of ESLint and Prettier, so there's no shared lint or format config to keep in sync across packages, just one root config for each. packages/mdx folded into source.config.ts and src/lib/frontmatter.ts at the app root where I use fumadocs/mdx to replace most of previously custom implemented markdown rendering.
Fewer moving parts means less to explain to future me, and less to keep in sync when something needs to change.
Content Creation
Every post and project is a single MDX file, frontmatter on top, content below, like this one:
With the website structure and design in place, I figured out how to easily manage the content. I decided to use MDX for the blog and project pages as well as for the about page, as it allowed me to write content in Markdown and include React components where needed. The default Markdown styling comes with @tailwindcss/typography, which made it easy to create beautiful and readable content without much effort.
I also wanted to have typesafe frontmatter for all of my pages, so I use valibot schemas to define the shape of the data and validate it before it gets rendered. Invalid frontmatter simply fails the build instead of shipping a broken page. I used to compile the MDX myself, but I eventually gave that up in favor of fumadocs-mdx, which covers GitHub Flavoured Markdown (GFM), heading slugs, the table of contents, and Shiki syntax highlighting out of the box, so I no longer have to maintain a custom remark/rehype pipeline.
source.config.ts passes this schema straight to the writings collection, so fumadocs-mdx validates every post against it at build time, rejecting the build if I typo a field or forget one. The WritingFrontmatter type falls out of the schema for free via v.InferOutput, so there's only one place that defines what a post's frontmatter looks like, not a schema and a hand-written interface I'd have to keep in sync by hand.
Deployment and Launch
GitHub Actions runs three jobs on every push, in order. First a lychee link check over every .md/.mdx file and src/**/*.{ts,tsx}, catching any broken links or cross references. Then lint, format-check, type-check, test, and build. Only if all of that passes, and only on a push to main or a version tag, does the last job build a Docker image and push it to ghcr.io. Vercel is the actual deploy target though, it builds straight from the repo on every push and handles preview deployments for branches, so the Docker image mostly exists as a self-hostable fallback (I'm currently evaluating Coolify on a VPS).
Domain and DNS run through Cloudflare, whose free tier covers everything I need for a personal site, records and SSL certificates included. Once that was wired up, pushing to main was enough to go live.
Conclusion
The website looks nothing like the version I started with, and that's kind of the point. Each redesign came from actually using the site and noticing what bothered me, not from a plan I set out to follow. I'd rather keep it that way than call any version final.