Building SeatCheck: A Car Seat Law Site Where a Wrong Number Could Hurt a Kid
One of my kids wanted a booster seat, I didn't know if the law allowed it, and the answers online were terrible. So Claude and I built a cited car seat law site for all 56 US jurisdictions.
One of my kids is getting close to the line. He’s old enough that he’s lobbying hard to ditch the car seat for a booster, the way you’d lobby for a later bedtime. And I realized I had no idea whether the law in my state actually allowed it, or whether I just felt like it should.
So I did what anyone does. I searched. The results were dire. Blog posts from 2017 wrapped in popup ads, a state DMV page that buried the answer four clicks deep, and a dozen “ultimate guides” that all hedged because none of them wanted to be wrong about a child-safety rule. Nobody just told me: here is what your state requires, here is the statute, and here is what pediatricians actually recommend on top of that.
That gap is the whole project. SeatCheck answers that one question, for every US state, with a citation, and it never pretends a recommendation is a law.
What it does
You pick a state and enter your child’s age, height, and weight. It tells you the restraint the law requires: rear-facing car seat, car seat or booster, or a seat belt. It cites the exact statute. And next to the legal answer it shows the American Academy of Pediatrics best-practice recommendation, which is almost always stricter than the law, clearly labeled as guidance and not a requirement.
It covers 56 jurisdictions: 50 states, DC, and five territories. There are 84 state-vs-state comparison pages for the “we’re moving from Ohio to Pennsylvania, do the rules change” question. And the checker itself is an embeddable widget any parenting or gear-review site can iframe in.
How I worked with Claude on this one
This was the most even human-AI split of anything I’ve built. Not “I directed and Claude typed,” and not “Claude built it while I watched.” Genuinely split down the middle, but along a clean seam.
I owned the data. Specifically the part of the data that fought back: finding the real statute, reading it, and deciding what number actually belonged in a field. Claude owned the machinery: the comparison engine, the Astro site, the embeddable widget, and the provenance-lint tooling that keeps 56 jurisdictions honest.
The reason for that seam is the whole point of the project. The code is just code. If the comparison engine has a bug, a page looks wrong and I fix it. But if a statute value is wrong, a parent puts a kid in the wrong seat. That is not a bug I get to fix later, so I did not hand it off.
The stack
Astro 5, static, deployed to Cloudflare Pages. No framework on the page, no React, no client-side hydration beyond the small bit of vanilla JS the widget needs. The car seat law data is a single JSON file, and every page is generated from it at build time. Claude suggested this shape early and I agreed immediately: for content where correctness is everything, a static build from one verified data file means there is exactly one place a number can be wrong.
The one piece of backend is privacy-light analytics. A Cloudflare D1 database logs page views and widget embed loads, no cookies and no per-user tracking, mostly so I can see which third-party sites pick up the widget. Claude wrote the Worker, including a bot filter I would not have bothered to make this careful:
function isBot(ua: string | null): boolean {
if (!ua) return false;
// `bot\b` (boundary only at the end) matches suffixed crawlers like Googlebot, bingbot, and
// AhrefsBot, which a leading `\bbot\b` would miss. The rest are substrings that do not occur in
// real browser UAs.
return /bot\b|spider|crawl|slurp|headless|scraper|axios|\bcurl\b|wget|python-requests|node-fetch|\bfetch\b|preview/i.test(
ua,
);
}
That comment explaining why the word boundary sits at the end of bot and not the front is the kind of thing Claude does well: it not only handled the edge case, it left a note so future-me does not “simplify” it back into a bug.
The legal answer itself is computed by one canonical function. The same logic runs on the homepage, the state pages, and inside the embedded widget on someone else’s domain, so the rule lives in one shared module:
// 1. Rear-facing, unless the child has aged out or met a weight/height exemption.
if (s.rfUntil != null && ageYears < s.rfUntil) {
const exW = s.rfExLb != null && weightLb != null && weightLb >= s.rfExLb;
const exH = s.rfExIn != null && heightIn != null && heightIn >= s.rfExIn;
if (!exW && !exH) {
return {
stage: "rear-facing",
category: "Rear-facing car seat",
tone: "warn",
detail: `At ${al}, ${s.state} requires a rear-facing car seat${exempt}.`,
backNote,
aapText: s.aapRf,
};
}
}
This was a together effort. I knew the staging rule a parent needs (rear-facing, then harnessed seat or booster, then belt, with weight and height “outs” at each step). Claude turned it into the exemption-aware branch logic and, importantly, kept the AAP note as a separate field rather than letting it leak into the legal detail string.
The hard part: the states that did not want to be read
The hardest part had nothing to do with code. It was getting the actual statute text out of 11 states that block a normal web request.
Most states have a statute page you can fetch and read. These 11 do not. Some sit behind a reCAPTCHA. One serves its code through an ancient Folio frameset. A few only exist as PDFs. Indiana hands you a JavaScript app and makes you ask its own API for the text. Each one was a small, separate fight.
What saved this from being pure pain was deciding to record the win. Every hard state has a recipe stored in a manifest, so the next time the data needs re-verifying, nobody has to re-derive the trick:
"tennessee": {
"fetchMethod": "lexisnexis-recaptcha",
"statuteUrl": "https://www.lexisnexis.com/hottopics/tncode/",
"fetchRecipe": "lexisnexis.com/hottopics/tncode/. reCAPTCHA-gated: human solves checkbox once (clears TN/MS/AR session). Then #searchTerms -> click the section result."
}
Tennessee, Mississippi, and Arkansas all live behind the same LexisNexis CAPTCHA, and solving it once in a visible browser clears the session for all three. Claude drove the browser, I solved the checkbox, and then it pulled all three. That human-in-the-loop loop, where Claude does the navigation and I do the one thing a bot is not allowed to do, was the only way several of these states got sourced at all. Connecticut, Oklahoma, and Kentucky came out of raw PDFs parsed with Python. New Jersey came out of that frameset by typing into one specific form field and reading a named frame.
None of this is glamorous. It was a week of small archaeology. But it is the difference between a site that cites real statutes and one that launders a blog’s guess.
Where Claude surprised me: the strictness engine
I expected to write the comparison logic myself, because “which state is stricter” sounds simple and then immediately is not. Claude wrote it better than I would have, mostly because it took the non-comparable cases seriously instead of forcing a number.
The case that sold me was the booster rule. Some states release a kid from a booster at an age. Some release by height with no age cap at all. Those two are not on the same axis. A height-only rule is stricter for a short ten-year-old and looser for a tall five-year-old, so ranking one “stricter” than the other is just wrong:
// Age-based vs height-only (e.g. Washington graduates by 4'9" with no age
// cap): each is stricter for a different child (a short older kid vs a tall
// young kid), so it cannot be cleanly ranked. Call it a tie.
const aAgeBased = aAge != null;
const bAgeBased = bAge != null;
if (aAgeBased !== bAgeBased) return 'tie';
That comment is Claude’s reasoning, not mine added afterward. It caught a category error I would probably have shipped.
The other piece I did not ask for but kept was the rule that a state cannot be crowned “stricter” on a fine alone. If two states match on every rule that changes what seat a child needs, but one happens to have a bigger fine, they are comparable, not “stricter.” That is a judgment call baked into the engine:
// The "core" dimensions (rear-facing, booster, back seat) are the ones that
// actually change what restraint a child needs. If the two states tie on all
// of those, they are comparable even if one happens to set a fine the other
// does not; we do not crown a "stricter" state on a fine alone.
const CORE_KEYS = new Set(['rearFacing', 'booster', 'backSeat']);
const coreDecisive = rawDims.some(
({ d, stricter }) => CORE_KEYS.has(d.key) && (stricter === 'a' || stricter === 'b'),
);
That is a genuinely good content decision living inside the code. It keeps the comparison pages honest instead of manufacturing a winner for every pair.
Where Claude fell short: thin, templated pages
The first batch of comparison pages was bad. Not wrong, just thin. Claude built them as templates with the two states’ numbers dropped into the same sentences, and the result read exactly like every SEO-farm page I was trying to beat. “California requires X. Texas requires Y.” Forty of those in a row is content nobody should rank, and frankly nobody should read.
The fix took a few rounds of me pushing back. The rule we landed on is that every sentence on a comparison page has to be derived from a real difference in the data, phrased to describe that specific difference. So the engine does not have one template, it has a per-dimension note function that says different things depending on whether a state is stricter, whether the other is silent, or whether they are not comparable:
note: (a, b, s) => {
if (s === 'na') return 'Neither state sets a statutory rear-facing age; both defer to the car seat manufacturer.';
if (s === 'tie') return `Both require rear-facing until age ${an}.`;
const winner = s === 'a' ? a : b;
const loser = s === 'a' ? b : a;
if (ln == null)
return `${A(winner)} requires rear-facing until age ${wn}; ${A(loser)} sets no statutory rear-facing age and defers to the seat manufacturer.`;
return `${A(winner)} requires rear-facing longer (until age ${wn} vs age ${ln} in ${A(loser)}).`;
},
Now two pages are only as similar as the two states’ laws actually are, which is the entire defense against being thin. Claude built this version well once I forced the constraint, but it would happily have shipped the templated version, and I had to be the one to say no.
The related miss, smaller but worth naming, was the pull toward filling a blank. When a statute is simply silent on, say, a rear-facing age, the correct value is null, and the site should say the state defers to the seat manufacturer. Claude’s instinct, more than once, was to infer a “reasonable” number to fill the field. On most projects that is helpful. On this one it is the exact failure mode that endangers a kid, so a lot of my review time went to catching guesses and turning them back into nulls. The codebase now has guardrails and a lint that flag any non-official value, which exists specifically because that temptation kept showing up.
What else went wrong
The non-Claude problems were mostly entropy. Statute URLs rot. Two of the source links I had carefully verified were already redirecting or dead within the build, so I added a network lint that sweeps every source URL and flags 404s and redirects, and is smart enough to know that a 403 on a CAPTCHA state is expected but a 404 anywhere is a real failure.
Scope also crept, in a good direction. It started as “50 states.” Then DC, obviously. Then I added five territories because leaving them out felt like exactly the kind of gap the existing sites have. Each addition was more hand-sourcing, which is why the data work outweighed the code work by a lot.
Where it is now
Shipped and live at seatchecker.org. All 56 jurisdictions are in, each statute-sourced with a verification date. The 84 comparison pages are published, the embeddable widget works on third-party sites, and the privacy-light analytics are running so I can see which sites embed it. I’m now in the slow part: pitching the widget to parenting and gear-review blogs for the kind of links that make a small authority site actually get found.
My own kid, for the record, has to wait. The law in our state and the AAP both agree he is not there yet, and now I have a page that proves it to him.
What I would do differently
Lock the data model before generating a single page. The thin-content detour and a chunk of rework both trace back to the same root cause: I let Claude start producing comparison pages before the schema and the strictness rules were settled. Every later change to how “stricter” works, or to what a silent statute means, rippled back through pages that already existed. On a data-driven site the data model is the product. If I were starting over, the first week would be nothing but the JSON shape, the strictness engine, and the lint scripts, with zero pages rendered until all three were boring.
The second lesson is about the split itself. The even human-AI division worked because the seam matched the risk. Claude is fast and good at the code, where mistakes are cheap and visible. I was slow and careful on the statutes, where mistakes are expensive and silent. The trick was not picking the smartest tool. It was being honest about which half of the work I was not allowed to delegate.
Frequently Asked Questions
- You pick a state and your child's age, height, and weight, and it tells you the restraint the law requires there: rear-facing car seat, car seat or booster, or a seat belt, with the exact statute cited. Alongside that it shows the American Academy of Pediatrics best-practice recommendation, which is usually stricter than the law. The two are always labeled separately so the recommendation is never mistaken for a legal requirement.
- A pure, deterministic engine scores each state across weighted dimensions like rear-facing age, booster release, and back-seat rules, then picks a winner. It deliberately refuses to crown a 'stricter' state on a fine or an exemption alone, and when two rules are not cleanly comparable (a height-only booster rule versus an age-based one) it calls them comparable rather than forcing a ranking. Every sentence on a comparison page is generated from the underlying data, so the page is only as detailed as the law it describes.
- Every threshold traces to the official state statute. Each fact block stores its own source URL, the date it was last verified, and a source tier, and three lint scripts check that nothing is unsourced, internally contradictory, or pointing at a dead link. Eleven states block a normal web request, so those were sourced by hand through PDFs, same-origin browser fetches, and one human-solved CAPTCHA, then recorded so the next refresh can repeat the exact steps.
- Yes. Besides the standalone page, SeatCheck ships an embeddable widget that other parenting, gear-review, or family-travel sites can drop in via an iframe. It carries its own styling, sets no cookies, needs no accounts, detects the host theme, and resizes itself with a roughly two-kilobyte script. You can pre-fill a state and accent color with URL parameters.
- No. It is a plain-language summary of statutory requirements with the official citation so you can read the law yourself, plus the separate AAP best-practice guidance. It is built as you-your-money-or-your-life child-safety content, which is why accuracy and provenance are prioritized over speed, but it does not replace reading the statute or talking to a certified car seat technician.
What does SeatCheck actually tell a parent?
How does the state-vs-state comparison decide which state is stricter?
Where does the car seat law data come from?
Can I put the car seat checker on my own site?
Is SeatCheck legal advice?
Founder of Vient and senior staff engineer
Caden Sorenson runs Vient, an independent studio building iOS apps, web tools, and client websites, including Travel Vient, a travel research site with everything cited to primary sources. He's a senior staff engineer with 15+ years of experience building iOS apps, web platforms, and developer tools, and a Computer Science graduate from Utah State University. Based in Logan, Utah.
Related posts
- Building Travel Vient: The Real Work Was Not Trusting ClaudeHow I built travelvient.com, a data-driven travel site with 80 airlines and a fleet of free tools, by letting Claude generate under skills I wrote and building the harness that catches it when it makes a fact up.
- Building a Travel Power Adapter Tool with Claude in a WeekendHow I turned leftover destination data into a 221-country power adapter finder with plug types and voltage comparison. The first version was unusable.
- Building a Layover Calculator That Knows Every Terminal at JFKHow I built a connection time calculator covering 70 airports with pairwise terminal transfers, customs buffers, and a five-factor assessment algorithm.
Built as part of
View the project →