DEV Community

Cover image for One Markdown File, 15 Landing Pages: Separate Your Copy From Your Design
Hsin Yen Chung
Hsin Yen Chung

Posted on

One Markdown File, 15 Landing Pages: Separate Your Copy From Your Design

Our homepage went through fifteen complete redesigns last week. Not Figma mockups — fifteen deployed, pixel-complete pages. A quiet dark "review suite." A blueprint spec sheet. A research paper. A sticker poster with a yellow highlighter headline.

They all say exactly the same thing, because all fifteen read their copy from one markdown file.

Here's the whole thing in 60 seconds:

Don't take my word for it — the winner is live at workupai.com, and the fourteen runners-up are still deployed at their version URLs. Compare the blueprint spec sheet, the research paper, and the original against the sticker-poster homepage: same words, different worlds.

This post is about the pattern that made that possible, why it killed our longest-running meeting, and how you can steal it. (It's a follow-up to how our designer ships front-end changes without writing code — same philosophy, applied to our own landing page. The page sells Spuree, our review-and-memory layer for AI-generated work, but it lives at workupai.com — we give marketing experiments their own domain so we can rebuild them freely, behind their own infra, without ever touching the product site. Spuree becomes relevant again at the very end.)


The problem: copy debates and design debates in the same room

Every landing-page review I've ever sat in mixes two arguments that have nothing to do with each other:

  • "The headline should mention the team, not just the agent" (a copy problem)
  • "It feels too corporate, can it be more playful?" (a design problem)

When both live in the same React components, every change is entangled. You can't compare two designs fairly because they drifted to slightly different words. You can't fix a sentence without someone re-opening the color conversation.

So we physically separated them.

The workflow in 30 seconds

  1. All copy lives in content/vme.md — one markdown file, plain key: value lines. Anyone can edit it.
  2. Each design is one self-contained pagesrc/app/v1/page.tsxv15/page.tsx. Every page reads the same file at build time.
  3. A floating "versions" menu on every page lets anyone click between all fifteen designs.
  4. Feedback comes as rankings ("I like v7 > v9 > v8"), and the next batch of designs mines the winners' DNA and drops the losers'.
  5. Shipping the winner is one line — the root page re-exports the chosen version.

No CMS. No feature flags. Not a single client component. The whole thing is a Next.js static export.

The stack

Layer Choice
Framework Next.js (App Router), React Server Components only — we wrote zero client components
Styling Tailwind CSS v4 — each version gets its own scoped design tokens in globals.css
Fonts next/font for Google fonts (a different display face per design) + our brand OTFs via @font-face
Copy One markdown file + a ~120-line parser (fs.readFileSync at build time)
Output output: "export" — static files in out/, no server to run
Hosting S3 (private bucket) + CloudFront (Origin Access Control, HTTP/2+3)
DNS / TLS Route 53 + ACM (DNS-validated, auto-renews)
IaC Terraform/OpenTofu — the whole infra/ folder is nine small .tf files
Analytics Plausible — one script tag, enabled at build time via env var
The agent Claude Code — generated the variants, the parser, the infra, and most of this post's material

The deliberate constraint: everything renders at build time. Every page is fully readable with JavaScript disabled — the only JS on the wire is Next's own runtime, because we wrote no client components. It's also why sixteen routes cost nothing to host: this site's AWS bill rounds to pocket change.

The core trick: copy is data

The copy file looks like this:

## hero

kicker: The review and memory layer for AI-generated creative
headline: AI made drafts cheap. _Feedback is still scattered across your team._
subheadline: Creative Engine pulls client notes into one shared, searchable place...
cta-primary: See the review loop

## problem

headline: The AI workflow breaks right after generation.
items:

- Drafts get dumped into Slack, email, or Telegram.
- Feedback scatters across meeting notes, chat threads, and someone's memory.
Enter fullscreen mode Exit fullscreen mode

A ~120-line parser turns it into { section: { key: value | values[] } } with fs.readFileSync in a Server Component — build-time only, nothing shipped to the client:

export function loadContent(name: string): PageContent {
  const raw = fs.readFileSync(
    path.join(process.cwd(), "content", `${name}.md`),
    "utf8",
  );
  // "## section" starts a section, "key: value" sets a string,
  // "- item" appends to the last list. That's the whole grammar.
}
Enter fullscreen mode Exit fullscreen mode

The one detail I'd call genuinely reusable: emphasis is part of the copy, but its style belongs to the design. The _underscores_ in the headline mark which words get the special treatment. Each page decides what that treatment is:

// v1: white-to-grey gradient        withEm(headline, "text-gradient")
// v2: hollow outlined display type  withEm(headline, "v2-outline")
// v15: yellow marker highlight      withEm(headline, "v15-mark")
Enter fullscreen mode Exit fullscreen mode

So the copywriter controls emphasis placement; the designer controls what emphasis means. Nobody steps on anyone.

(One war story: our markdown formatter silently rewrote *asterisks* to _underscores_ and broke the emphasis rendering. The fix — accept both, but ignore intra-word underscores so final_final_v3.png survives. Budget an hour for that class of bug.)

Where fifteen designs came from

I didn't design fifteen pages. I fed an AI coding agent real references and let it extract the design DNA:

  • our previous waitlist page (rendered it headless, pulled the fonts and layout patterns)
  • a landing page we admired (fetched its shipped CSS — found the exact accent #B6FC34 and font stack, no guessing)
  • screenshots of our actual product UI and login screen (that's where the cream-confetti-and-charcoal family came from)
  • our older marketing repo (lifted the brand font files and Tailwind tokens directly)

Each batch was three variants. Then the feedback loop did the real work: after "v7 > v9 > v8," the next three versions pushed the v7/v9 direction further and dropped v8's entirely.

Rankings beat reactions. "Make it pop" gives an agent nothing. "A > B > C" gives it a gradient to descend.

The version switcher: zero JavaScript

Every page gets a floating menu from the root layout. It's just a <details> element — works in a static export, no hydration, no state:

<details className="group fixed right-5 bottom-5 z-50">
  <summary className="... [&::-webkit-details-marker]:hidden">versions</summary>
  <nav className="absolute bottom-full right-0 mb-2 ...">
    {VERSIONS.map((v) => (
      <Link key={v.label} href={v.href}>
        {v.name}
      </Link>
    ))}
  </nav>
</details>
Enter fullscreen mode Exit fullscreen mode

We also stripped version links out of every page's header and footer once this existed — one switching mechanism, defined once.

(After shipping the winner we gated the switcher and the /versions index behind a build-time flag — visible in dev, absent from production builds unless NEXT_PUBLIC_SHOW_VERSIONS=1. The variant URLs stay live for posts like this one; the comparison chrome doesn't follow visitors around.)

Shipping the winner

When the team picked v15, promotion was:

// src/app/page.tsx
export { default } from "./v15/page";
Enter fullscreen mode Exit fullscreen mode

The old homepage moved to /v1. Nothing was deleted — fourteen alternates are still browsable, which turns out to be great for onboarding ("here's what we considered and why we didn't pick it").

How it deploys

Infra is provisioned once with terraform apply (S3, CloudFront, Route 53, ACM). After that, deploying is one script:

# scripts/deploy.sh (condensed)
NEXT_PUBLIC_PLAUSIBLE_DOMAIN="$DOMAIN" npm run build

# pass 1: hashed assets — cache forever
aws s3 sync out/ "s3://$BUCKET" --delete \
  --exclude "*.html" --exclude "*.txt" --exclude "*.xml" \
  --cache-control "public,max-age=31536000,immutable"

# pass 2: HTML — never cache
aws s3 sync out/ "s3://$BUCKET" --delete --exclude "*" \
  --include "*.html" --include "*.txt" --include "*.xml" \
  --cache-control "no-cache"

aws cloudfront create-invalidation --distribution-id "$DISTRIBUTION_ID" --paths "/*"
Enter fullscreen mode Exit fullscreen mode

Three details that matter more than they look:

1. Two-pass sync for caching. Next's hashed assets (/_next/static/...) are immutable, so they get max-age=31536000,immutable. HTML gets no-cache so a deploy is visible immediately. Skipping this split is how you end up with users staring at a stale page for a day.

2. A 10-line CloudFront Function for pretty URLs. Static export writes /v7 as v7.html, but browsers request /v7. One viewer-request function bridges the gap — no server needed:

function handler(event) {
  var request = event.request;
  var uri = request.uri;
  if (uri.endsWith("/")) {
    request.uri = uri + "index.html";
  } else if (!uri.split("/").pop().includes(".")) {
    request.uri = uri + ".html";
  }
  return request;
}
Enter fullscreen mode Exit fullscreen mode

This is the gotcha of S3 + CloudFront + output: "export" — without it, every route except / 404s and you'll lose an evening to it.

3. Analytics is a build-time decision. The deploy script injects NEXT_PUBLIC_PLAUSIBLE_DOMAIN, so local builds never track visits and production builds always do. No env drift, no "why is localhost in our analytics."

Total moving parts: an S3 bucket, a CDN, one function, one shell script. There's no CI pipeline yet on purpose — scripts/deploy.sh from a laptop is honest about what this stage of a startup needs.

What I've learned

  1. Same copy, many skins turns taste into a fast decision. When every variant reads identically, "which one?" takes minutes. The moment wording differs between options, people argue about words while believing they're arguing about design.

  2. Copy is a supply chain. Our hero names a problem ("feedback is scattered"), the problem section itemizes it, the solution section cancels each item using the same nouns, and the CTA lands the theme. If a later section can't reuse an earlier section's vocabulary, one of them is wrong. This is also why one shared file works at all.

  3. The markdown-as-CMS pattern is absurdly cheap. ~120 lines of parser bought us: non-devs editing production copy, pure design A/B comparisons, and copy fixes that propagate to fifteen pages in one commit. I'd reach for this long before a headless CMS on any marketing site.

Steal it

The recipe, condensed:

  1. Move every headline, list, and CTA label into one content/*.md file with a trivial key: value grammar.
  2. Make each design a self-contained page that reads it at build time.
  3. Mark emphasis in the copy; style it per design.
  4. Add a <details> version menu in the layout.
  5. Give feedback as rankings, ship the winner as a re-export.

Full disclosure: this workflow is also our product

The landing pages in this post sell Spuree Creative Engine — and the loop the copy describes is the loop I just walked you through, productized: your agent drafts, you share a client-safe review link (not a zip in a Slack thread), feedback lands pinned on the work itself, and every approval becomes per-client memory your agent reads before the next draft — from Claude Code, Codex, or OpenClaw.

Who it's for, concretely:

  • Freelance devs shipping agent output to clients — stop being the human clipboard between your agent and your client, and stop re-teaching your agent each client's taste after every revision round. One review link out, pinned feedback back, and the approval is remembered.
  • Agencies and brand studios — you're running this loop across many clients at once: campaign assets, product imagery, storyboards, social creative, character and IP canon. Creative Engine keeps a separate brand memory per client, so what Client A approved in March is what your agent drafts from in June — and no client ever repeats a note. The sign-off itself becomes the brand guideline.

And the multi-version trick from this post is becoming a feature. We're opening up HTML hosting on Creative Engine — upload your site or campaign variants, get shareable links and a version switcher, and collect client feedback on each one, with none of the S3/CloudFront setup you just read about. Freelancers: test landing page directions with your client before committing. Agencies: send three homepage directions as one link and let the client leave feedback where you'll actually find it. If you'd want that, say so in the comments — early access is going out to people who ask.


If you want to go deeper on any piece — the parser, the reference-driven design generation, the Terraform for the S3 + CloudFront setup, or how this plugs into the Slack + Claude Code workflow from the last post — drop a comment. Happy to share code.

And if you try this and get stuck — the routes 404 behind CloudFront, the emphasis markers break, the parser eats your copy — drop a comment too. I'll help you debug it. That's not politeness; comments about where this pattern fails are exactly what makes the next post better.

Top comments (0)