Plays · No. 1

Account pages from one table.

A personalized landing page for every target account, generated from a spreadsheet. No CMS, no page builder, no per-page cost. The version below took one afternoon and runs on a static site.

For: teams running ABM to 10 to 100 named accountsStack: a CSV, one HTML template, 40 lines of Python, NetlifyCost: $0Build time: one afternoon

The play

When you run outbound into a short list of named accounts, the landing page is the weakest link. The email is personal. The ad is personal. Then the click lands on the generic homepage, and the prospect has to find themselves in it.

The fix is a page per account: their name in the headline, their problem in the first paragraph, a number from their world, and a customer that looks like them. Most teams don't build it because they picture 50 pages built by hand in a page builder, or a CMS upgrade to get dynamic pages.

You don't need either. Every account page is the same page with different words. So the words live in a table, the page lives in one template, and a script writes the pages. Add a row, run the script, you have a new page.

Who it's for

  • You have a named account list, 10 to 100 companies, and you're already emailing or running ads to them.
  • You can write one good sentence about each account's situation. If you can't, the page won't save you.
  • Your site is static or you can put a folder of HTML somewhere. Netlify, Vercel, GitHub Pages, an S3 bucket, or a subfolder on whatever you already run.

If you're on HubSpot Content Hub Professional, HubDB and dynamic pages do the same job inside HubSpot. I've built it both ways. This version is for everyone who isn't paying for that.

The build

Stack

The tableaccounts.csv. One row per account. Columns are whatever your template needs.
The templatetemplate.html. A normal page with {{company}} style tokens wherever a word changes.
The scriptbuild.py. Reads the CSV, fills the template, writes one folder per account. Python, no libraries.
HostingNetlify free tier. Commit the generated folders and push.
  1. Write the page for one account by hand first. Pick the account you know best and write the whole page in plain HTML. Don't template anything yet. If the page isn't convincing for one account, no amount of generation fixes it.
  2. Mark what changes. Read it again and circle every word that would be different for the next account. Those are your columns. In the mock below there are nine: slug, company, industry, locations, the contact's first name, the pain sentence, a peer customer, the peer's result, and one metric with its label.
  3. Replace each circled word with a token. {{company}}, {{pain}}, and so on. Keep the token names boring and matching the column headers exactly.
  4. Fill the CSV. One row per account. This is the actual work of the play, and it's the part most people want to automate too early. Write the pain sentence yourself for the first 20. Use Clay or an LLM to draft it once you know what a good one looks like.
  5. Run the script. python3 build.py. It writes /demo/account-pages/<slug>/index.html for every row and stops with an error if the template asks for a column the CSV doesn't have.
  6. Add noindex to the template. These pages are for the people you send them to, not for search. The template includes the tag; don't remove it.
  7. Push. The site deploys. Each account gets its own URL, like /demo/account-pages/marigold-dental-partners/ in the mock. Put that URL in the email and on the ad.
  8. Watch who visits. Each page is its own URL, so your analytics already tells you which accounts clicked. If you have a visitor ID tool, route a hit on any account page straight to the rep who owns it.

The mock

Zebrawood is a made-up scheduling product for multi-location businesses. Three made-up target accounts, three generated pages, one CSV. Click any of them. Then open the CSV and find the row that made it.

The files that built them, in this exact folder on my site:

Here is the whole script. It's shorter than this paragraph's worth of explanation.

import csv, html, os, re

template = open("template.html").read()
tokens = set(re.findall(r"{{(\w+)}}", template))

for row in csv.DictReader(open("accounts.csv")):
    missing = tokens - set(row)
    if missing:
        raise SystemExit(f"{row['slug']}: no column for {sorted(missing)}")
    page = template
    for key, value in row.items():
        page = page.replace("{{" + key + "}}", html.escape(value.strip()))
    os.makedirs(f"../../demo/account-pages/{row['slug']}", exist_ok=True)
    open(f"../../demo/account-pages/{row['slug']}/index.html", "w").write(page)

What breaks

Three things I built guards for, because each one has bitten me on a real version of this.

An ampersand. A plain find-and-replace puts "Cobblestone Freight & Co." into the page title as a raw ampersand, and some browsers and every validator choke on it. Every value goes through html.escape before it lands in the template. Boring, and it only shows up on account 37.

A token with no column. Rename a CSV header and forget the template, and the page ships with a literal {{contact_first}} in the headline. The script compares the tokens in the template to the CSV headers and refuses to build if they don't match. That check is the most useful line in the file.

A number that sounds like their data. "310 open chair hours a week" reads like a fact. It's an estimate. The page says so next to the number. On a real account page, the number either comes from a source you can name or it gets a label that says it's a guess. Prospects forgive a guess. They don't forgive a made-up fact about their own business.

Copy it

Take the three files, swap Zebrawood for your product, rewrite the template around your buyer, and fill ten rows. If you want the full version, the one with visitor identification routing hits to the account owner's Slack, that's next week's play.

One play like this, every week.

The email gets the build files and the mock before they go on the site. No pitch in it.

Get the weekly play →