DEV Community

member_5432fd74
member_5432fd74 Subscriber

Posted on

Fifty States, One Template: Content as Data and the Render Gate That Saved Us

We run a directory that has to say something specific and legally accurate about all fifty states. Eligibility rules, funding programs, and dispute rights are state law, so "generic national copy with the state name interpolated" is not a shortcut, it is fifty pages of wrong information with good Core Web Vitals.

The naive version of this is fifty templates. The lazy version is one template and a {{state}} token. Both are bad for the same reason: the variance between states is real but uneven, and neither approach lets you express "these thirty states are genuinely the same and these twenty are not."

Here is the shape we landed on.

Content as data, not as components

The first move was getting the per-state prose out of the component tree entirely. Copy lives in plain TypeScript modules keyed by state, one file per state, checked into the repo:

// content/state/<state>.ts
export const content: StateContent = {
  intro: "...",
  fundingNote: "...",
  faqs: [{ q: "...", a: "..." }],
}
Enter fullscreen mode Exit fullscreen mode

Not a CMS, not a database table. Three reasons, in order of how much they mattered:

  1. It diffs. When a state changes a scholarship deadline, the change shows up in a pull request next to the person who made it. A CMS edit is invisible in the history of the thing that renders it.
  2. It typechecks. StateContent is a real type. A state file missing a required section fails the build instead of rendering an empty <section> in production.
  3. It is trivially greppable. "Which states mention the 2026 deadline" is grep, not a query.

The tradeoff is that non-engineers cannot edit it. For copy that carries compliance risk, we decided that was a feature.

The render gate is the important part

The mistake we made first was rendering the state section whenever the content file existed. That produced pages with a paragraph of prose and no actual providers under it, which is the classic thin-page pattern: indexable, useless, and a drag on the rest of the site.

The fix is a gate that ties content rendering to inventory:

const entries = await getProvidersFor(state, type)
const copy = stateContent[state]

// prose only ships when there is something for it to introduce
const showStateSection = copy != null && entries.length >= MIN_ENTRIES
Enter fullscreen mode Exit fullscreen mode

MIN_ENTRIES is 1. It could be higher. The point is that it is a number in one place rather than a judgment call repeated across templates. A page that fails the gate either renders a different, honest layout or does not get built at all.

This one condition removed more low-quality pages than any content edit we have made.

The anchor bug, which cost us a week

Related, and worth writing down because it took an embarrassingly long time to find.

Providers in our model have a physical location and a separate set of service areas: places they will travel to but do not have an office in. A therapist in one town might serve six surrounding towns. Reasonable model, matches reality.

The city page resolver, however, was keyed on physical location. So a town where three providers all had service-area coverage and none had an office had, as far as the resolver was concerned, zero providers. The page 404'd. Meanwhile the providers' own profiles happily listed the town as served.

provider.location      -> home city eligibility
provider.serviceAreas  -> where they will travel
city page exists?      -> required >= 1 provider.location  // the bug
Enter fullscreen mode Exit fullscreen mode

Nothing errored. No log line. The pages simply were not there, and the only symptom was a slow leak in an internal crawl report. Two lessons, neither novel and both apparently in need of relearning:

  • A page that does not exist produces no error. Absence is the hardest failure mode to monitor. We now assert expected route counts in CI rather than trusting that a missing page will announce itself.
  • When one model field gates the existence of a route, say so in the type. The relationship was true in the resolver and nowhere else, which meant every developer who touched service areas had to rediscover it.

What it looks like in production

The state hubs are at specialneedsusa.com/states, with the two provider verticals rendering off the same gate at /schools and /therapy. The data reports at /data run the same content-as-data pattern with a heavier chart layer on top, if you want to see where it stops scaling.

The general version

If you are building anything with a geographic or categorical long tail:

  • Keep variant copy in typed, version-controlled data rather than in a CMS or in components.
  • Gate the rendering of that copy on the presence of the thing it describes, with the threshold as a named constant.
  • Assert the existence of expected routes in CI, because missing pages are silent.

None of this is clever. All of it is the difference between fifty useful pages and fifty pages that dilute the forty you had.

Top comments (0)