Skip to content
Huy Vo
Work

Freelance · Portfolio + CMS for a marketing executive

Cao Lan Anh

A motion-led portfolio with a Vietnamese CMS the owner runs herself. Publish in the admin, live on her site on the next page load.

Visit portfolio.caolananh.greenlucky.cloud (opens in a new tab)

Screen recording

Role
Sole developer and designer of the build
Year
2026
Platforms
Web · CMS
Stack
  • Next.js 16.4
  • React 19.3
  • Payload CMS 3
  • Neon Postgres
  • GSAP
  • Lenis

A campaign executive needed a portfolio that looked like her work, not a template, and that she could update without a developer. Her campaigns, proposals and videos change every quarter.

My part. Everything: design system, front-end, CMS, migrations, deployment and the test suites.

Outcomes

  • Next load

    from Publish to the live page

    Source: cache-tag test

  • 0

    layout shift (CLS) on mobile

    Source: Lighthouse

  • 240+

    automated admin and web checks

    Source: e2e suites

What I built

Motion with manners

GSAP and Lenis on one ticker: a per-letter hero, a pinned horizontal proposal board that walks one card per swipe on phones, all of it falling back to plain content without JS or with reduced motion.

A CMS she can trust

Payload embedded in the same Next.js app with a branded Vietnamese admin, drafts and autosave, locked slugs with redirects, and patches for three upstream autosave and modal bugs found while testing.

Architecture

NEXT.JS 16.4 APP · VERCELVisitorbrowserOwnerthe clientPublic siteGSAP · LenisTagged cache'use cache'Publish hookafterChangePayload CMSadmin + Local APINeon Postgrescontent · draftsVercel Blobimages · videoGETREADMISSAFTERCHANGEEXPIRE TAGPUBLISHUPLOADS
One Next.js app on Vercel serves the site and embeds Payload CMS. Reads go through a tagged cache and fall through to Payload's Local API on Neon Postgres, with media in Vercel Blob. Publishing expires the matching tags, so the page updates at once.
  • Components:
    • Visitor (browser)
    • Owner (the client)
    • Next.js 16.4 app · Vercel: Public site (GSAP · Lenis), built by me
    • Next.js 16.4 app · Vercel: Tagged cache ('use cache'), built by me
    • Next.js 16.4 app · Vercel: Publish hook (afterChange), built by me
    • Next.js 16.4 app · Vercel: Payload CMS (admin + Local API), built by me
    • Neon Postgres (content · drafts)
    • Vercel Blob (images · video)
  • Connections:
    • Visitor to Public site: GET
    • Public site to Tagged cache: read
    • Tagged cache to Payload CMS: miss
    • Payload CMS to Publish hook: afterChange
    • Publish hook to Tagged cache: expire tag
    • Owner to Payload CMS: publish
    • Payload CMS to Neon Postgres
    • Payload CMS to Vercel Blob: uploads

Key decisions

  1. Payload inside the Next app

    Payload 3 runs inside the same Next.js app on Neon Postgres and Vercel Blob: no second server, $0 a month, her data in her own Postgres.

    Problem, choice and trade-off: Payload inside the Next app
    Problem
    One editor with 13 campaigns had to publish on her own, without a developer and without another server to run.
    Choice
    I compared Payload, Sanity, Strapi, Keystatic/Tina and a custom admin, then embedded Payload 3 in the same Next.js app on Neon Postgres and Vercel Blob, behind a content layer whose static adapter is the rollback switch.
    Why
    There is no second server: the Local API reads Postgres inside 'use cache' functions and publish hooks call revalidateTag in the same process, so there is no webhook secret. It costs $0 a month and the data sits in her own Postgres. Strapi meant a separate app and $35 to $45 a month for preview; a custom admin was 80 to 110 hours.
    Trade-off
    Payload pins Next minors, so Next upgrades stay inside its peer range, and I carry a postinstall patch for three @payloadcms/ui bugs (modal livelock, picker search reset, autosave dropping in-flight edits) that has to be re-checked on every Payload upgrade.
  2. Cache tags expired on publish

    Every read is cached with a tag the publish hook expires, so the site stays static-fast and her change shows on the next load.

    Problem, choice and trade-off: Cache tags expired on publish
    Problem
    The public site should be static-fast, yet what she publishes has to show on the next load, and draft autosaves must never touch the live pages.
    Choice
    Every read in the content layer is 'use cache' with cacheLife('max') and a cacheTag. Payload hooks call revalidateTag(tag, { expire: 0 }) only for published documents, and a static check in the e2e cache suite fails if a cached read has a tag no hook expires.
    Why
    On a site this quiet, expire: 0 costs one blocking regeneration and she sees her change on the next load; 'max' would show her stale content once.
    Trade-off
    Every new read needs a tag and a matching hook, which is why the test exists. For the same reason I said no to a service worker: a cache-first navigation would show the old page once after each publish.
  3. Migrations tested on a copy

    Before a release, migrations run up and down on a read-only copy of production data, so production is never the test target.

    Problem, choice and trade-off: Migrations tested on a copy
    Problem
    Local work runs on SQLite and production on Neon Postgres, and a bad migration would land straight on her live content.
    Choice
    Schema changes go only through committed migrations, generated once per adapter (Postgres against a throwaway database with the Blob adapter on). Before a release that touched existing rows, I copied every table over a read-only connection into PGlite, ran migrate up and down on the copy and compared row counts and content.
    Why
    Production is never the test target, but the dry run still uses its real data. Read-only prod-check scripts then verify the live site and block every write.
    Trade-off
    Two migration trees must stay in sync. One generated without the Blob adapter missed the prefix and _objectKey columns and broke production seeding, so a dummy Blob token is now part of the rule.

Credit. Content and art direction by the client.

Next case

Tanca LMS

HR SaaS · corporate training