Skip to main content

← Blog

Rebuilding My Site with Payload, Atlas, and Next.js

How I rebuilt thedanielmark.com by separating content, frontend experience, and delivery. Payload owns the CMS, Atlas owns the product layer, and Next.js owns static generation and caching.

Published
July 9, 2026
Updated
Updated July 9, 2026
Author
Daniel Mark
Reading time
7 min read

I rebuilt thedanielmark.com this year. Not as a migration of the old codebase, but as a fresh site on Atlas with a clearer split of responsibilities.

Payload owns the content. Atlas owns the frontend experience. Next.js owns delivery.

That can sound like three buzzwords in a row. It is not. It is how I stopped arguing about "static versus CMS" and started asking a better question: what should each layer be responsible for?

The win is not CMS versus static. The win is putting each responsibility in the right place.

The previous site was useful reference material. I reused content, URL patterns, metadata behavior, and redirect expectations. I did not reuse the architecture. The old stack was built around a different set of tradeoffs. This one is built around separation.

What I was optimizing for

I was not trying to prove a point about frameworks. I was trying to build a personal site that would stay maintainable for years without turning into a second job.

A few constraints shaped every decision:

  • Real editable content. Posts, projects, media, tags, authors, and site settings should live in a CMS, not in a folder of MDX files I edit by hand.
  • Fast public pages. Anonymous readers should not feel like they are waiting on a database on every page view.
  • Clear boundaries. Admin tooling, public frontend, and delivery logic should not bleed into each other.
  • Low maintenance. I operate this site solo. Every feature I add is a feature I have to support.
  • Room to grow. Tags, authors, redirects, search, and future content types should not require a re-architecture.

I cared about performance, but performance was not the thesis. The thesis was responsibility. Get that right, and performance becomes easier to reason about.

Three layers, three jobs

Payload owns content

Payload CMS is the editorial layer. Posts, Projects, Media, Tags, Authors, and SiteSettings all live there. Content is stored in Postgres and edited through a Lexical rich text editor in the admin.

When I publish a post or update a project, I am changing Payload. That is the source of truth. Nothing else pretends to be.

Atlas owns the experience

Atlas is the frontend platform I have been building: layout, typography, UI primitives, cards, grids, accessibility patterns, and the overall product feel. It is not a theme I picked off a shelf. It is the layer that decides how this site looks and behaves as a product.

The public site should feel intentional. Consistent spacing. Readable type. Components that behave predictably. That work belongs to Atlas, not to Payload and not to individual page files.

Next.js owns delivery

Next.js App Router handles everything between CMS content and the browser. Server components. Static generation and ISR. Route handlers for RSS, sitemap, and robots. Metadata and Open Graph tags. Redirects. Image delivery. Cache invalidation when content changes.

If a reader types a URL and gets HTML back, Next.js owns that path. Payload should not be in the hot path for anonymous traffic on a warm cache.

Three layers. Three jobs. The mess usually starts when those jobs blur.

Keeping readers off Postgres

Postgres stores the content. That does not mean every page view should query it.

Anonymous readers should experience this site like a fast static property. Public routes use ISR with a one-hour revalidation window. CMS reads go through a shared cache wrapper built on unstable_cache. Known slugs are prebuilt at deploy time via generateStaticParams.

The pattern looks like this:

1
export async function getPublishedPosts(limit = 100): Promise<Post[]> {
2
return withCmsCache(
3
["getPublishedPosts", String(limit)],
4
[CMS_CACHE_TAGS.posts],
5
async () => {
6
const payload = await getPayloadClient();
7
const result = await payload.find({
8
collection: "posts",
9
where: { status: { equals: "published" } },
10
sort: "-publishedAt",
11
limit,
12
depth: 2,
13
});
14
return result.docs;
15
},
16
{ fallback: [] }
17
);
18
}
19

Every public CMS query follows the same shape: wrap the Payload call, tag it for invalidation, set a revalidation window. Warm traffic reads from cache. Postgres gets hit on cache miss, after expiry, or when content changes.

Publishing is the other half. When I update a post in the admin, Payload hooks trigger targeted revalidation of the affected paths and cache tags. The homepage, blog index, individual post page, RSS feed, and sitemap all refresh without a full redeploy.

1
export const revalidatePostAfterChange: CollectionAfterChangeHook = async ({
2
doc, previousDoc, operation, req,
3
}) => {
4
await triggerRevalidation(
5
buildPublishableRevalidationRequest("post", operation, doc, previousDoc, tagSlugs),
6
req.context
7
);
8
};

Edit in Payload. Invalidate what changed. Readers keep getting fast pages.

Site search is the deliberate exception. /api/search is dynamic and queries Postgres on each request. That is fine. Search is not the default read path, and most visitors never hit it.

Isolating the CMS from the public site

The App Router uses route groups to keep concerns separate.

(frontend) is the public site: blog, projects, about, tags, and the rest. (payload) isolates Payload admin at /admin and the CMS REST API from the public frontend. Explicit route handlers for search, revalidation, and other delivery concerns live under their own API routes outside that boundary.

/admin is the only admin interface. I did not build a custom dashboard. Payload's admin is good enough, and building a parallel CMS UI would have been wasted effort on a personal site.

Access control enforces the boundary at the API level too:

1
export const authenticatedOrPublished: Access = ({ req }) => {
2
if (req.user) return true;
3
return { status: { equals: "published" } };
4
};

Authenticated admin users see everything. Anonymous callers only see published documents. Drafts stay out of public pages, RSS, and sitemap. The frontend helpers filter on status: published, but the access layer is the real guardrail.

What I chose not to bring back

Scope discipline matters on a personal site. Every feature you skip is maintenance you do not owe yourself.

I intentionally did not bring back:

  • Firebase for auth or data
  • Comments and likes
  • Public authentication
  • A custom admin dashboard
  • A NestJS API layer
  • The old Sharp responsive-image pipeline

The previous site had some of these. They made sense in that context. They did not make sense here. I wanted a site I could operate alone, with a clear content model and a fast public surface. Not a platform I have to babysit.

I also removed standalone Terms and Cookies pages for v1. Cookie-related information lives in the Privacy page. /cookies redirects there. That is intentional, not an oversight.

Images without the old pipeline

The old site ran images through a custom Sharp pipeline that generated multiple sizes and formats at build time. It worked, but it was its own subsystem to maintain.

The new approach is simpler. Images live in Payload's Media collection. A typed CmsImage wrapper sits on top of Next.js Image, with variant-driven sizes and quality for different layout contexts. In production, files go to Cloudflare R2. In development, they sit on local disk.

Images are a delivery concern now, not a bespoke build-time factory. Upload in the admin, reference in content, let Next.js handle image delivery through the standard <Image /> path.

Fast where it matters

The homepage currently scores 100 on desktop performance and 97 on mobile performance in PageSpeed Insights, with 100s across accessibility, best practices, and SEO on both form factors. Agentic Browsing scores 2/2. Cumulative Layout Shift is 0 on mobile and desktop.

Those numbers are a measured snapshot after the rebuild, not a promise that every route will always score the same.

That matters because this site now has a real CMS behind it. Postgres stores the content. Payload serves the admin. None of that should leak into the reader experience. Public routes are statically generated or cached, and publishing a post triggers targeted revalidation instead of hoping a cache expires on its own.

The goal was simple: edit like you have a CMS, read like you do not.

Closing principle

Personal sites are easy to undertreat. You either end up with a repo that is secretly your CMS, or a CMS that makes every page feel like it is waiting on a database connection.

I wanted neither. I wanted a real content model, a product-quality frontend, and delivery that treats anonymous readers like they are hitting a static site.

Payload, Atlas, and Next.js each have a clear job in that setup. When those jobs stay separate, the stack debate fades. You stop asking whether a personal site should be static or dynamic. You start asking whether each responsibility is in the right place.

That is the decision I would make again.

Portrait of Daniel Mark

Written by

Daniel Mark

Senior Frontend Engineer

Daniel Mark is a senior frontend engineer and product consultant with 7+ years of experience building resilient product interfaces for Web3, SaaS, and enterprise teams. He writes about frontend architecture, design systems, performance, SEO, and the engineering decisions that turn complex systems into dependable user experiences.