Skip to content
AstroCraft Docs
On this theme

The Car Records

Sixty-nine vehicle records build sixty-nine pages at /cars/<ref>/. They live in two config modules — 37 in carsData.json.ts for /used-cars/, and 16 each in hubsData.json.ts for /new-cars/ and /electric-cars/ — and one route merges all three:

export function getStaticPaths() {
  return [...carRecords, ...newCarRecords, ...electricCarRecords].map((car) => ({
    params: { ref: car.ref.toLowerCase() },
    props: { car },
  }));
}

The ref is the primary key and the URL. IX-2025-27450 becomes /cars/ix-2025-27450/, the reference is printed on the record page and on every card, and four other modules cross-reference records by it: the collection picks, the search results, the saved dashboard’s manifest and each record’s own “related” rail. Refs must be unique across all three datasets, because getStaticPaths keys on them — and that is checked rather than trusted.

The record shape

CarRecordProps in src/config/types/configDataTypes.ts is the full contract. The required half is what every card and every record page needs:

{
  ref: "IX-2025-27450",
  title: "2025 Leapmotor C10 Design 69.9 kWh",
  make: "Leapmotor", year: 2025, engine: "69.9 kWh",
  mileage: 2100, fuel: "Electric", transmission: "Automatic",
  bodyType: "SUV", doors: 5,
  tags: ["Below index", "Price drop", "Delivery miles only"],
  price: 27450, monthly: 366,
  features: ["fsh", "warranty", "finance", "delivery"],
  sellerType: "Franchised dealer", seller: "Harbourside Autos",
  location: { town: "Bristol", postcode: "BS1", distance: 3 },
  image: "c10", imageAlt: "…",
}

Four of those fields are filter keys, and their values are not free text. fuel, bodyType, transmission and the features slugs (fsh, warranty, mot, finance, delivery) must match the values the browse rail offers, or the client filter would hide every record when someone ticks a box. sellerType is a union of the three real options, so a typo fails the type-check.

location.distance is miles from the rail’s default postcode. It is what the distance filter and the search page’s nearest-first ranking read, so it is a stored number rather than a computed one — there is no geocoder in a static build.

The optional half of the record is the full record page: specGroups (the spec sheet), checks (the history-check grid), histogram (the price-position chart), provenance, related, priceWas/priceDropNote/pcpNote, sellerFacts. One record — the featured Leapmotor C10, IX-2025-27450 — carries all of it. The other 68 render the core sections only.

That is visible in the route, and it is the cleanest example of how Indexa composes a page:

<Header car={car} />
{car.specGroups && <SpecSheet car={car} />}
{car.checks && <Checks car={car} />}
{car.histogram && <PricePosition car={car} />}
<ExitBand car={car} />
{car.related && <Related car={car} />}
<Faq />

A section is present because its data is present. Filling in specGroups on a second record gives that record a spec sheet with no other change — no flag, no template variant, no route edit. That is the seam a real feed arrives through: records gain optional blocks, and pages grow sections to match.

Nine records carry priceDrop: true, which is what the rail’s “Include price drops” filter selects on, and what the strike-through price on a card renders from.

The photo pool

Thirteen photographs cover all 69 records, keyed by CarImageKey in src/config/carImages.ts. A record names a key; the pool resolves it to an imported ImageMetadata that Astro optimizes at build time.

The split between the two files is not tidiness. carsData.json.ts stays free of asset imports so it can be loaded by plain node --experimental-strip-types — which is how four self-checks assert against the real dataset without running a build. Put an import photo from "@assets/…" in the data file and those checks stop working.

It is a placeholder pool, and it is meant to be replaced by per-listing photography. Twelve of the thirteen keys are claimed by records; the thirteenth, dossier, belongs to the home page’s dossier band and is registered here so there is one photo pool rather than two.

What the self-checks pin

src/config/carsData.test.ts and hubsData.test.ts are the two longest checks in the project, and between them they hold four invariants:

Enough records for the pager. The browse page shows eight at a time, so a dataset that shrank below a pageful would make the pager look broken rather than empty.

Refs unique across every dataset. Duplicated refs would collide in getStaticPaths — a build error at best, one page silently winning at worst.

Every rail value matches at least one record. This is the one worth stealing. The filter rail is authored copy and the records are data; nothing in the type system connects a checkbox labelled “Estate” to a record with bodyType: "Estate". So the check walks every rail option and asserts a record carries it, which means the client engine can never empty the list via a value no car has.

Featured-extras integrity. The blocks the record page renders conditionally have the shape those sections expect — a histogram with bands that sum, a check grid with a total line.

pnpm test runs all of it in about fifty milliseconds, with no framework and no fixtures. Commands covers the runner.

NEXT STEPBrowse Hubs & Index Pages