CMS-driven personal portfolio

A production portfolio platform built with Next.js and TypeScript, with CMS-managed content, object storage, containerized services, and automated infrastructure.

A CMS-driven portfolio built with Next.js and TypeScript, designed to separate content from presentation through a flexible section-based architecture. The project combines Payload CMS, dynamic section rendering, S3-compatible media storage, and containerised infrastructure to create a maintainable portfolio that can evolve without hardcoding its content.

CMS-driven personal portfolio thumbnail

Overview

Most developer portfolios are static by design. Content is written straight into components, so every new project, rewritten paragraph or replaced screenshot means opening the codebase, editing JSX, committing and redeploying. Over time the site falls behind the work it is meant to show, simply because updating it is a chore.

I set out to build the opposite: a portfolio that treats content as data. Pages, projects, images and copy all live in Payload CMS, and the Next.js front end renders whatever the CMS gives it. The code is responsible for how things look and behave; the CMS is responsible for what they say. Neither needs to change for the other to move forward.

To make that work, I designed a flexible section-based architecture. Instead of fixed page templates, every page is an ordered list of sections, such as text, image, carousel, gallery and chart, that I can add, remove and reorder from the admin panel. Each section type has a block definition in the CMS and a matching Zod schema and React component in the app, connected by a single renderer.

Behind the interface, the platform is built like a production product rather than a personal page. Media is stored in S3-compatible object storage instead of on the application server. The front end, CMS, database and storage run as containerised services, so every environment behaves the same. Deployment is automated, so shipping a change is a push, not a process.

The result is a portfolio that can evolve without hardcoding its content, and a codebase that stays focused on presentation, performance and maintainability.

Project goals:

  1. Decouple content from code. Every piece of copy and media should be editable without a deployment.
  2. Stay flexible. New page layouts should be possible by combining existing sections, not writing new templates.
  3. Keep it type-safe. Every piece of CMS content is validated against a Zod schema in the app, so malformed data never reaches the page.
  4. Run like production. Containerised services, external object storage and automated deployment from day one.
  5. Stay fast. Server-side rendering and optimised media so flexibility never costs performance.

Content and code, kept apart

The problem. My previous portfolio was a set of hand-built pages. Each project had its own component, its own layout tweaks and its own hardcoded copy. Adding a case study took an evening of work that had nothing to do with writing about the project. Small fixes, like correcting a typo or swapping an image, still meant a full commit-and-deploy cycle. The friction was enough that the site rarely reflected my latest work.

The principle. The fix was to draw a hard line between content and presentation. Content is anything a visitor reads or sees: titles, paragraphs, images, captions, links, chart data. Presentation is how that content is laid out, styled and animated. Content belongs in a CMS where it can be edited freely. Presentation belongs in code where it can be versioned, tested and reviewed.

How it works. Payload CMS is the single source of truth. It stores pages, projects and media, and exposes them through its API. When someone visits a page, Next.js fetches that page's data on the server, including its ordered list of sections, and renders each section with the matching React component. Static generation with revalidation keeps pages fast while still picking up CMS changes without a rebuild.

Why Payload. I chose Payload because it is code-first and written in TypeScript. Collections and fields are defined in code, live in the repository and are reviewed like any other change. Payload and the Next.js app are separate applications that talk only through Payload's API, so each can be developed, deployed and scaled on its own.

Why Zod. Because the CMS and the app are separate, the app can't import types from Payload. Instead, it defines its own Zod schema for every page and section type. Each API response is parsed against those schemas before rendering, and the TypeScript types are inferred from the same schemas with z.infer. One definition gives me both runtime validation and compile-time types, and if a field changes in the CMS, the mismatch is caught with a clear error instead of a broken page.

Why external media storage. Uploaded images and files go to S3-compatible object storage rather than the application server's disk. That keeps containers stateless, so they can be rebuilt or replaced without losing media. It also means storage scales independently of the app and can sit behind a CDN.

Key points:

  • Content edits never require a code change or a redeploy
  • Zod schemas in the app validate every CMS response and supply its TypeScript types
  • Server-side rendering keeps pages fast and search-engine friendly
  • Media lives in object storage, so application containers stay stateless

Pages built from blocks

From templates to sections. Traditional sites give each page type a fixed template: a homepage layout, a project layout, an about layout. That works until a page needs something the template didn't plan for. Instead, every page in this portfolio is an ordered list of sections. A page is not a layout; it is a sequence of building blocks the editor chooses.

The project details section library. The platform ships with five section types, each designed for a different storytelling job:

  • Text for introductions, explanations and conclusions
  • Text and Image for pairing an idea with a diagram, screenshot or photo
  • Text and Carousel for step-by-step walkthroughs and multi-image stories
  • Gallery for visual showcases where the images speak for themselves
  • Text and Chart for results, metrics and data-backed claims

This very page is built from those five blocks, assembled entirely in the CMS.

How a section is defined. In Payload, each section type is a block with its own typed fields. A Text and Image block, for example, has a heading, a rich-text body, an image upload, alt text and a layout option for image position. Editors see a clean form for each block; developers see a schema in code with full type safety. Payload's blocks field lets a page hold any mix of these types in any order.

How a section is rendered. On the front end, a single section renderer receives the page's list of blocks. It reads each block's type, looks it up in a component registry and renders the matching React component with that block's data as props. Because the props are generated from the CMS schema, each component knows exactly what fields it will receive. Unknown block types are skipped safely rather than breaking the page.

Extending the system. Adding a new section type is deliberately small and predictable: define the block schema in Payload, regenerate types, build the React component and register it in the renderer. Existing pages are untouched, and the new block becomes available in the editor immediately. This is what keeps the portfolio easy to grow: new layouts come from new combinations, and new capabilities come from new blocks.

Built like a product, not a page

Front end. The site is built with Next.js and TypeScript using the App Router. Pages are rendered on the server, which keeps first loads fast and gives search engines fully formed HTML. Images are served through Next.js image optimisation, resized and converted to modern formats on demand. Styling uses Tailwind CSS, with a small set of design tokens shared across every section component so the page stays visually consistent no matter how it is assembled.

Content layer. Payload CMS runs as its own service, backed by MongoDB. Its admin panel is where all pages, projects and media are managed. Access control keeps the admin private while the public API exposes only published content.

Media storage. Uploads are handled by Payload's storage adapter and written to a S3-compatible object storage Garage. Using the S3 API means the same code works across providers, and switching storage is a configuration change rather than a rewrite.

Containerised services. Every part of the system runs in Docker: the Next.js app, Payload, the database and object storage. Docker Compose defines how they connect, which ports they expose and which volumes they persist. Local development mirrors production closely, so "it works on my machine" stops being a risk. Multi-stage builds keep production images small by shipping only the compiled output and runtime dependencies.

Automated delivery. Deployment runs through GitHub Actions. On every push to main, the pipeline installs dependencies, runs type checks and linting, builds the Docker images, pushes them to GHCR and a manual workflow dispatch deploys them to the hosting provider. Server setup is automated with Ansible: playbooks install Docker, configure the firewall and environment, setup docker networks, and roll out the database, reverse proxy containers, so the whole environment can be rebuilt from scratch with a single command. Secrets are injected through environment variables and never committed to the repository.

The trade-offs behind the build

Flexibility always has a cost. Most of the interesting work in this project was deciding where that cost was worth paying and where to hold the line.

Flexibility vs consistency. A fully free-form page builder makes it easy to create pages that look broken. I kept the section library small and opinionated: each block has a limited set of layout options and shares the same spacing, typography and colour tokens. Editors get freedom to compose pages, but every combination still looks deliberate.

Keeping the CMS and front end in sync. With content defined in one place and rendered in another, the biggest risk is drift: a field renamed in the CMS that the front end still expects. Because Payload and the app are separate applications, I wrote a Zod schema for every section in the app. Each API response is parsed against it, so drift surfaces immediately with a clear error, and the TypeScript types are inferred from the same schemas.

Fresh content without slow pages. Fully dynamic rendering would show CMS changes instantly but make every visit hit the database. Fully static pages would be fast but stale. [Describe your solution, e.g. static generation with on-demand revalidation triggered by a Payload afterChange hook, so pages rebuild only when their content changes.]

Stateless containers. Storing uploads on the container's disk would have been simpler at first, but every redeploy would risk losing media. Moving uploads to S3-compatible storage kept containers disposable and made the deployment pipeline safe to run at any time.

Local vs production parity. [Describe a real issue you hit, e.g. environment variables, networking between containers, or storage URLs differing between MinIO locally and the production bucket, and how you resolved it.]

What it made possible

The result. The portfolio now updates in minutes rather than hours. Adding a case study means filling in a few forms and arranging sections in the admin panel; it goes live without a commit, a code review or a deployment. Corrections that used to wait for a free evening now take seconds. Because the site is easy to update, it actually stays up to date.

The codebase benefits just as much. Components focus purely on presentation, the content model is versioned alongside the code and Zod validation catches content mismatches before they reach the page. The infrastructure is reproducible, so moving hosts or rebuilding the environment is routine rather than risky.

What I learned. Building this taught me as much about systems as about interfaces. Designing a content model forced me to think like an editor: which fields are genuinely needed, which defaults save time and which choices should be taken away. Containerising the stack and automating deployment changed how I think about personal projects; treating a portfolio like a production product made it more reliable and far more enjoyable to maintain.

Let's Connect

Have a question about my portfolio website, or just want to get in touch? I'd love to hear from you.

2026 Jason Kitamirike. All Rights Reserved