A property brand your team can run on its own
MintHomes is a marketing site for a residential developer working across three regions, and a self-hosted CMS behind it. This is the record of how it was planned and built: every page, every piece of the content model, and the reasoning behind the parts that were not obvious. Pick how you want to read it.
A Build record
The full account, section by section: what the brief asked for, how each part of the site was planned, what was actually built, and the decisions that were revisited along the way to get right.
- Every page of the site, and what shaped it
- The content model, the page builder and the preview pipeline
- Consent, SEO, motion, resilience and the admin screens
- Deployment, operations and what we would do next
- 22 sections
- 3 diagrams
- ~25 min read
B Workflows
A step-by-step presenter. Walk a flow one step at a time and see who does what, what goes in, what comes out, and the safeguard that stops each step going wrong.
- Build a landing page without touching code
- Preview an unpublished draft, then publish with no delay
- An enquiry's journey from the form to the office inbox
- Add a whole new region, and ship to production
- 6 workflows
- 48 steps
- Keyboard navigation
MintHomes, section by section
A marketing site for a residential developer, and a CMS your own team runs.
- Next.js 16.2
- React 19
- Tailwind v4
- Strapi 5 CE
- PostgreSQL
- One VPS
- Brevo
A brochure site that stopped being a brochure
MintHomes sells residences across three regions. The site has to look like a brand that charges a premium, and it has to be editable by people who will never open a terminal. Those two goals pull against each other: the more a layout is pinned to a design file, the less an editor can safely change.
The resolution was to build the site twice over. Every page renders from a Strapi content model, and every page also has a bundled fallback in code. Where the design is load-bearing — the home hero, the region boards — the layout stays fixed and only the words and pictures are editable. Where your team needs freedom — campaign pages, the blog, new regions — you get a palette of twelve section blocks and a live preview, and the site routes, navigation and sitemap follow whatever you build without a deploy.
The riskiest part of a CMS build is not the CMS. It is every place the code quietly assumes the content will stay the shape it was on the day it was written. Three of the four parts we rebuilt came from exactly that.
What was fixed before a line was written
The design arrived as a Figma board with desktop and mobile frames, a type scale built on a licensed display face, and photography-led sections. Your side had a marketing editor, not a developer. The hosting budget ruled out a managed CMS.
| Design source | A Figma board with named frames. Headings are set in IvyPresto Display, which is licensed through Adobe Fonts and cannot be bundled, so it loads from a kit while body and serif faces are self-hosted by next/font. |
|---|---|
| Who edits | One non-technical marketing editor, occasionally a second. They need to add articles, swap photography, publish campaign pages and open a new region without calling anyone. |
| CMS | Strapi 5 Community Edition, self-hosted. No paid plugins, no Strapi Cloud. PostgreSQL in every environment — never SQLite. |
| Hosting | Both applications on one VPS: nginx terminates TLS in front of the Next.js site and the Strapi API, each run by systemd, with PostgreSQL in a container on loopback. Two applications, one repository, no root package.json. |
| Compliance | Cookie consent that can be demonstrated after the fact, not just a banner. Analytics must not fire before a choice is made. |
| Non-goals | No accounts, no listings search, no payments, no multi-language. Adding them later must not require unpicking what exists. |
Four phases, each one shippable on its own
The site went live in stages rather than as one release. Each phase left something usable, so a delay in a later phase never held back an earlier one. The work falls into these four groups almost exactly.
Phase 1 · The site, in code
Every page built from the Figma board with content held in typed constants
under web/constants/. Fully deployable with no CMS at all. This
phase is why the fallbacks in phase 2 cost almost nothing.
Phase 2 · The content model
Strapi stood up and the constants mirrored into content types. Each getter
in lib/content.ts prefers Strapi and falls back to the constant
it replaced, so the two could run side by side during the switch.
Phase 3 · Editor autonomy
The page builder, draft preview, instant publishing, automatic navigation, and two custom admin screens. This is the phase that decided whether your team would actually use the CMS or email us changes.
Phase 4 · Production and compliance
Contact form delivery, the consent audit trail, consent-gated analytics, SEO, the VPS deployment, backups, and the handover guides your team works from.
Modelling content before the design is settled produces a schema shaped by guesses. Building the pages first meant the content model was derived from markup that already worked, and the fallback layer came free: it is the phase-one code, still in place.
Two applications, one repository, one direction of travel
The frontend never talks to the database. It reads Strapi's REST API, tags every response with a cache key, and writes back to Strapi only for the two things a visitor creates: an enquiry and a consent record. Strapi owns the database and the media library. Nothing reaches production by any route that skips the build.
Reads flow down the middle and are tagged by model, so publishing one article invalidates one tag rather than the whole site. The gold lines are the two paths that only open on a condition: a consent choice, and a publish event.
What lives where
web/ | Next.js 16 App Router. 10 page routes, 4 route handlers, 41 components, around 8,500 lines of TypeScript. Built from web/ and run as a systemd service behind nginx on port 3000. |
|---|---|
cms/ | Strapi 5 CE. 20 content types, 23 shared components, 14 operational scripts, two custom admin screens and two patched admin files. |
| Boundary | One module, lib/strapi.ts, performs every read. It returns null instead of throwing, which is what makes section 19 possible. |
Tokens first, so the design survives the content
The palette lives as CSS custom properties and is exposed to Tailwind v4
through @theme inline, so a colour has exactly one definition and
every utility class resolves to it. The names are the brand's, not generic
scale numbers: verdant, olive, ivory,
ticker.
Two of those tokens exist because the board's own values failed in use. The ticker band's original sand tone carried white 15px serif text at 2.89:1, well under the 4.5:1 that WCAG AA asks for at that size, so it was taken down the same hue to 4.73:1 and confined to that one band. The comment recording that sits next to the token, where the next person will find it.
Three faces, three jobs
IvyPresto Display
Headings, as the board specifies. Licensed through Adobe Fonts, so it loads
from a kit stylesheet and is wired to --font-display. A free
stand-in held the slot until the licence was in place.
Figtree
Body and interface text, self-hosted by next/font with
display: swap, so there is no third-party request in the
critical path for the text people actually read.
Source Serif 4
The secondary serif. Region boards use it at Bold 36/57.6 centred rather than the display face — a deliberate exception from the board, noted in the component so it is not "corrected" later.
Matching the board took a dedicated pass over font weights across every page, after the first build approximated them. Design fidelity degrades silently; it needs its own review step rather than being folded into feature work.
A fixed identity, with an editable body underneath
The home page is the one page where letting an editor rearrange everything would be a liability. The hero and the marquee ticker are the brand's signature, so they are rendered from code on every request and are not part of the page builder.
Everything below them is a switch. If the home page's section zone in the CMS is empty, the site renders the five designed sections — about, statistics, featured projects, recent articles, closing call to action. The moment an editor adds a single section, the built stack replaces the fixed one. Emptying the zone puts the original layout back.
An editor experimenting on a live homepage needs an undo that does not involve us. Making the fallback the empty state means deleting their experiment is the undo, and there is no half-built state where some designed sections survive and others do not.
- Hero and ticker: always code, always first paint, never animated in
- Body: five designed sections, or the editor's own stack, never a mixture
- The ticker band carries its own accessible colour token (section 05)
- Page metadata comes from the CMS where set, from the hero where not
The page that taught us about reduced motion
About is the most content-dense page: core values, a milestone timeline,
leadership profiles, office cards. All four are collection types, ordered by
an explicit order field so an editor controls sequence without
renaming anything.
It is also where a subtle defect surfaced. Scroll reveals were implemented by
branching on framer-motion's reduced-motion hook: render for motion on the
server, disarm in the browser for anyone who had asked for less of it. In
framer-motion 12 that hook returns the real value on the first client render
rather than null, so server and client disagreed about an inline
opacity: 0. React does not patch attribute mismatches, and with
the animation disarmed nothing ever faded the content back in. Measured on
this page: 14 of its 16 sections stayed invisible, permanently, for exactly
the visitors who had asked the site to be gentler.
The fix was to stop branching on the preference in markup at all. One
MotionConfig reducedMotion="user" at the root honours it through
framer-motion's own mechanism — travel is skipped, the fade still plays — so
the markup never depends on a preference and there is no mismatch to leave
behind.
Any accessibility preference read during render is a hydration hazard. Honour it at a provider, in CSS, or in the animation library's own configuration — never by rendering different markup for different people.
From a hardcoded enum to a route your team can create
Regions started the way they usually do: a fixed enum on two Strapi schemas, plus hardcoded lists in five places in the frontend. Adding a fourth region meant a schema change, a code change and a deploy — and even then the new region page could not be linked from the footer without hand-editing the global settings.
It was rebuilt so that the set of valid regions is simply "whichever Region
Pages are published". The enum became a plain string on both schemas, which
needed no data migration because PostgreSQL stores enum values as text
anyway. /projects/[region] now derives its routes, its static
params and its 404s from the published Region Pages. Unknown keys still 404 —
the route did not become permissive, it became data-driven.
| Before | Enum in 2 schemas, region lists in 5 frontend files, a deploy per region, manual nav edits. |
|---|---|
| After | One published Region Page creates the route, the board, the header dropdown entry and the footer column entry. |
| Still enforced | Unknown region keys 404. Regions the repository does not bundle get derived fallback copy rather than crashing when the CMS is unreachable. |
| Proven by | A seed script that adds a fourth region end to end with zero code changes — the demonstration we walked your team through. |
Sorted by date, pinned by hand, filtered without a native select
Articles sort by publish date on their own, paginated at twelve. An editor
occasionally needs one held at the top regardless of date, which is what the
order field is for — a pin, not the sort. Strapi has no
drag-to-reorder for collection entries, so a custom admin screen provides one
(section 20).
The category filter is drawn rather than using a native select.
A native control cannot be styled to the board on every platform, and the
filter sits in the middle of a designed band where a system-styled dropdown is
visibly foreign. The replacement is keyboard operable and announces its state.
Article bodies are Strapi rich text, rendered through a dedicated component that handles images and inline marks. An earlier renderer silently dropped image nodes, which is the behaviour the exhaustive switch in section 13 now makes impossible.
- Twelve per page, sorted by
publishDate, filtered by category orderpins an article above the sort without changing its date- Rich text renders images and inline marks, not just paragraphs
- The seed writes real dates, so a fresh database produces a believable blog
One submission, three outcomes, one of them decisive
The first version of the form was a front-end placeholder: it resolved a 600 millisecond timer and showed a thank-you note, with nothing behind it. Wiring it to real delivery was a phase-four task. The form now does three things per submission: it tells the office inbox, it thanks the sender, and it files the enquiry in the CMS so it can be found later.
Only the first of those decides what the visitor sees. A thank-you email that failed to send is not worth making someone retype their message over, and a CMS that is briefly down must not turn a delivered enquiry into an error on screen. Each step reports its own result into the stored record, so "did that one actually go out?" has an answer on the Incoming emails screen.
What the route defends against
Untrusted input
One Zod schema validates on both sides. The browser uses it for inline validation; the route re-validates with the same rules, because anything arriving at an API is untrusted regardless of what the form allowed.
Bots
A honeypot field, deliberately not rejected by the schema: a schema failure would answer a filled honeypot with a 400 naming the field, which tells whoever wrote the bot exactly what to leave alone next time. These are accepted and silently dropped.
Bursts
A per-IP throttle, documented as modest rather than oversold: the map lives in the site's own Node process, so it resets whenever the service restarts and would not hold across a second instance. If real abuse appears it needs a shared store, not a bigger number.
Configuration errors
Missing credentials are our problem, not the visitor's. The route logs exactly which variable is absent and answers the browser with a plain 503 that names nothing.
The pages nobody demos, which still have to be right
Careers, the privacy policy, the cookies policy and the 404 page are the pages that get built last and reviewed least. They share one legal-page component so their typography cannot drift apart, and their copy is CMS-held so your counsel can revise it without a release.
The 404 page is content-driven too — its eyebrow, heading, body and button label all come from the global settings, with bundled defaults behind them. A 404 is a page a visitor reads while already frustrated, so it was worth the same treatment as the rest.
- Footer legal links stay whole on narrow phones rather than wrapping mid-phrase
- The cookies policy carries a version string the consent record points at (section 16)
- Careers was added as a page and wired into the footer in the same change, so it was never orphaned
- Office cards can be added, edited or temporarily hidden by an editor without deleting them
Twenty types, grouped by who touches them
The model is deliberately boring. Page-shaped singles hold the copy for a specific page; collections hold things there are many of; shared components hold the pieces that repeat. Nothing in it is clever, which is the point — an editor has to be able to predict where a piece of text lives.
| Page singles | home-page, about-page, contact-page, blog-index-page, global — hero copy, section headings and SEO for one page each. |
|---|---|
| Collections | project, region-page, blog-post, core-value, milestone, office, leadership-profile, testimonial, faq, career, author-mint. |
| Free pages | page — a title, a slug, a hero and a dynamic zone. This is the editor's blank canvas (section 13). |
| Private records | enquiry and consent-record. Both are written by the site and read only in the admin; neither is publicly readable. |
| Components | 23 of them across blocks, sections, global, nav and shared, including the SEO component every page type embeds. |
Public read permissions were originally granted from a hardcoded whitelist at
boot. When your team created new content types in the Content-Type
Builder on the live server, those APIs answered 403 to the site — which, from
the editor's side, looks like the feature simply does not exist. The bootstrap
was flipped: every api:: type gets public find and findOne
automatically, and only enquiries and consent records are held back. A content
type your team invents next year works without us.
Twelve blocks, and a build that fails if one is forgotten
A free page is a title, a slug, a hero and a dynamic zone. The zone accepts twelve section blocks — rich text, image, gallery, media-and-text, a statistics row, a card band, a timeline, a call-to-action banner, a project grid, a blog grid — which an editor stacks in any order.
Two details make this reliable rather than fragile.
Adding a block means touching three places: the populate list, the typed union and the renderer. Missing the third is a compile error rather than a missing section on a live page.
The two details
Populate has to be explicit
Strapi's populate=* goes exactly one level deep. It returns the
section objects and leaves the images inside them empty, so a page renders
with every picture missing. Each component is named through the on
syntax, and the two that nest media two levels down — the card band and the
timeline — get an inner populate of their own.
The switch is exhaustive
The renderer assigns the unmatched case to never. Add a block to
the zone without adding a case and the build fails, rather than the page
quietly dropping that section — which is precisely how the blog renderer once
lost its images. An unknown component arriving from a CMS edited ahead of a
deploy renders nothing and leaves a server log line.
Three read paths from one function
Every read passes through one function, and what it does depends on who is
asking. An editor in preview gets unpublished drafts with caching turned off.
A visitor gets published content, tagged so it can be invalidated precisely.
If Strapi cannot be reached at all, the call returns null and the
caller falls back to bundled content (section 19).
The same three outcomes apply to single types and collections. Nothing above the fetch layer knows which path it got.
The preview entry point
Strapi's Preview button opens /api/preview with a shared secret
and a path. The route checks the secret, confirms the path is a route this site
actually serves, enables Draft Mode and redirects. That middle check matters:
redirecting to the raw parameter would turn a valid secret into an open
redirect, and the secret travels in a URL that editors paste to each other.
Confirming the path cannot use the cached route lists alone, because a brand
new draft has no published slug yet. The route queries Strapi directly with
status=draft and no caching, falls back to the cached getters if
the CMS is unreachable, and reads the live region list rather than a fixed one.
Publishing with no delay
Strapi posts to /api/revalidate with a bearer secret when an entry
changes. The route maps the model name to its cache tag and marks just that tag
stale, serving the previous response while the new one is fetched. A body-less
ping refreshes everything. The read layer's own revalidation window defaults to
zero, so even without the webhook nothing is served stale.
The Content Manager hid the entire Preview panel until an entry had been saved, which left editors unsure the feature existed on new pages. Two files are patched so the panel renders a disabled button with a save-first tooltip, and the admin can ask whether a type is previewable before an entry exists. Non-previewable types — testimonials, FAQs, incoming emails — stay hidden exactly as before.
Creating a page should make it reachable
Publishing a page that nothing links to is a quiet failure: the editor believes they shipped it, and no visitor will ever find it. So content-derived links are merged over the curated ones every time global settings are read. Every published Region Page appears in the header's Projects dropdown and the footer's Projects column; every published free page appears in the footer's Navigate column.
The merge is additive and deduplicated by href, so anything an editor already listed by hand — with their own label or position — wins and is never duplicated. Automatic navigation that overwrites a deliberate choice is worse than no automatic navigation.
- Curated entries win on conflict; derived entries fill the gaps
- Social icons fall back to bundled artwork when an entry's media is detached, instead of rendering an image with no source
- The sitemap is built from the same live lists, so a new page is submittable the moment it exists
A banner is the easy half; the record is the point
The browser's own cookie stores the choice and is what actually gates scripts. A separate endpoint records the same choice in the CMS so consent can be demonstrated later: what was chosen, against which version of the policy, and when.
The policy version is a constant that is bumped whenever the cookies policy changes materially. A stored consent from an older version is treated as absent, so visitors are asked again rather than being held to terms they never saw. Four categories are modelled; essential is not offered as a choice, because it cannot be declined and offering it would be dishonest.
What the audit record does and does not keep
| Kept | A random consent id, the action taken, the three category decisions, the policy version, the timestamp, and a truncated user agent. |
|---|---|
| Not kept | The IP address. A salted SHA-256 prefix stands in, which is enough to show two records came from the same source without turning a consent log into a log of who visited. Without the salt the hash would be trivially reversible — there are only four billion addresses. |
| Export | A script dumps the records for a regulator or your counsel without touching the admin. |
Gating the tag manager
Rather than withholding the container until someone consents, the defaults are declared as denied before it loads, and updated when a choice is made. That is Google's own guidance for Consent Mode v2, and it means tags inside the container self-gate — whoever manages the container later can add a tag without it escaping the banner.
Ordering is the part that breaks silently: if the consent code runs out of
order, the defaults do not apply. So the defaults are a plain inline script in
the document head, which runs during parse, and the container loads after it —
ordered by document position rather than by a strategy hint that could drift.
There is deliberately no noscript iframe fallback, which would fire
the container for visitors who could never have consented.
One resolution order, applied everywhere
Every route resolves its metadata the same way: the value an editor set in Strapi, then a value derived from the page, then the site default. That ordering lives in a single builder so a new page cannot invent its own.
Two decisions in it are worth naming. CMS meta titles are written as complete
titles, so the layout's "%s | MintHomes" template would append the brand twice;
the builder emits an absolute title and applies the suffix itself. And the
canonical origin is not taken from the CMS siteUrl field, which
points at a different property — advertising a canonical for a site this
deployment does not serve is worse than having no canonical at all. It comes
from the deployment's own domain, overridable by one environment variable.
- Open Graph and Twitter cards resolve from the same values, including the page's own hero as the fallback image
noIndexonly ever restricts indexing when an editor asks for it; an absent flag never silently deindexes a page- The sitemap and robots rules are generated from live content, including editor-built pages and regions
- Structured data is emitted through a shared JSON-LD component rather than inline per page
Animation that is allowed to not happen
The motion budget is small on purpose: short-travel fades as sections enter the viewport, and inertial wheel scrolling. On photography-led pages the motion should read as the page settling, not as an effect competing with the images. Nothing above the fold animates — starting the hero at zero opacity would delay the largest contentful paint for content that is already in view.
Only the animation features the site actually uses are loaded. Importing the full motion package pulls drag, layout projection and 3D into the first-load bundle of every page, because the reveals are sitewide. The provider loads the DOM feature set only, in strict mode, so a later import cannot quietly pull the full bundle back in.
Two failure modes are handled explicitly. Reduced motion is honoured at the
provider rather than per component, for the hydration reason in section 07. And
a noscript rule undoes the server-rendered inline opacity, so a
visitor whose JavaScript never arrives sees the page rather than a blank one.
Inertial scrolling and CSS scroll-behavior: smooth fight each
other, so the smoothing library disables the CSS rule while it is active, and
the stock stylesheet's height rule is deliberately left out because the layout
depends on a full-height body to keep the footer down on short pages.
The site renders whether or not Strapi answers
The CMS is one self-hosted Node process on one VPS. A restart, a failed deploy
or a brief network problem must not take the marketing site down with it, so
no read ever throws. Every fetch returns null on failure and logs
a warning, and every getter falls back to the bundled content it replaced.
This is not a stub layer written for the purpose. It is the phase-one site — real copy, real images, the layouts that were signed off — left in place. The degradation is invisible to a visitor: they see the content as it stood at the last deploy rather than an error page.
| Unreachable CMS | Bundled constants render. Logged, never thrown. |
|---|---|
| Non-2xx response | Same path as unreachable, with the status in the log line. |
| Missing media | Media URLs resolve from either an object or a one-item array, because Strapi 5 returns both shapes. A project with no image is skipped rather than rendered broken. |
| Unknown region | Derived fallback copy rather than a crash; unknown keys still 404 at the route. |
| Unknown block | Rendered as nothing, logged — a zone edited ahead of a deploy cannot take a page down. |
| Enquiry filing fails | The visitor is already thanked; the failure is loud in the logs and visible on the admin screen. |
Two screens the Content Manager could not give us
Strapi's admin is good at editing one entry. It is not good at two jobs your team does weekly, so both got a purpose-built screen in the admin's own design system rather than a spreadsheet somewhere else.
Blog order
Drag to reorder articles, with a published-state badge on each row so a draft is never pushed live by a reorder. It writes through the Content Manager's own API, so permissions still apply.
Incoming emails
Contact enquiries as a table the office can read down, search, filter by status and export as a spreadsheet. The Content Manager can list them, but it opens each one in an edit form — the wrong shape for records nobody should be editing.
Custom admin pages in Strapi's dev server tripped a React refresh preamble check that took the panel down with a 502 when a schema was saved. The admin entry installs the hooks that check expects before anything else loads. Without it, your team's own use of the Content-Type Builder would have looked like a broken CMS.
One server, two services, one handover
Everything runs on one VPS behind a single nginx reverse proxy with Let's Encrypt TLS. The Next.js site and Strapi are each a systemd service bound to loopback — the site on port 3000, the API on 1337 — PostgreSQL 16 runs in a container on localhost, nginx serves the uploads directory straight from disk, and the database and that directory are backed up daily with a fourteen-day retention.
The runbook is specific about the thing most runbooks get wrong — when a restart
is actually required. Content changes need none. Schema changes made in the
Content-Type Builder write files under cms/src/api/ and need to be
committed, or the next deploy silently deletes your team's work.
What your team was handed
Deployment guide
Server setup end to end, from packages and firewall to TLS, service files, seeding, the revalidation webhook and backups.
CMS workflow guide
The four workflows the marketing team actually performs, written from the admin's screens rather than from the schema.
Content-Type Builder explainer
When your team needs the builder, when you emphatically do not, and what a schema change implies for the repository.
Operational scripts
Fourteen of them: seeding, token minting, consent export, blog tidying, media relinking, and a region seed that proves a new region needs no code.
Two deployment documents exist and they describe different topologies — one running PostgreSQL in a container with both applications on the server, one running PostgreSQL natively with the site hosted elsewhere. The first is how this installation was actually built, and the repository says so rather than quietly merging them into one plausible-looking guide.
What we would keep, and what we would change
Decisions that paid off
- Code first, content model second. The schema was derived from working markup, and the fallback layer came free.
- One fetch function that never throws. Every resilience guarantee in section 19 follows from that one choice.
- Exhaustive rendering. A forgotten block is a failed build, not a missing section you find before we do.
- Derived routes and navigation. Your team opened a fourth region without a deploy, which is the clearest proof the handover worked.
- Permissions as a denylist. Content types your team invents are public to the site from the moment they exist.
What we would change
- The rate limits. An in-process map resets with the service and would not hold across a second instance. Real abuse needs a shared store.
- Caching. Revalidation defaults to zero so editors see changes instantly. With the publish webhook proven in production, a real cache window would cut load without costing immediacy.
- The two deployment guides. They should be one document describing the topology that exists, with the alternative as an appendix.
- Schema drift. Content types created on the live server had to be recreated in the repository by hand. A check that compares deployed schemas against the repository would catch that in CI.
Where it goes next
| Near term | A shared rate-limit store, a cache window behind the webhook, and a schema-drift check in CI. |
|---|---|
| If the brand grows | The region model already supports any number of regions. A second language would need a locale strategy, which the model was kept simple enough to accept. |
| If enquiries grow | The enquiry type is a record, not an inbox. A status workflow and assignment would be a schema change plus one admin screen, not a rebuild. |
How the site is actually operated
Pick a workflow and step through it. Each step names who is acting, what goes in, what comes out, and the safeguard that stops it going wrong. Use the arrow keys.