Configuration
Medice keeps its content in src/config/, as typed TypeScript modules rather than as literals in components or frontmatter in .astro files. The rule is strict enough to be predictable: a component never carries a string a config file could own.
Two reasons, and the second is the one that matters. The first is the obvious one — rebranding means editing data, not hunting through markup. The second is that .astro frontmatter is not importable, so a string living there cannot be read by a second page that needs the same string, and cannot be checked. A .json.ts module can be both.
The three site-wide files
siteData.json.ts is the brand. It carries the practice name and description — both ship in the JSON-LD on every page, and the description opens llms.txt, so it is how crawlers learn what the practice is — the contact block, twitterCreator, the sameAs social URLs that disambiguate the JSON-LD Organization, the default social image, and the three bands whose copy is identical on every page that draws them (the closing CTA, the diagnostics panel, the clinic gallery).
siteData.test.ts fails the build if the description still describes a starter template. That check is the reason the placeholder cannot quietly reach production.
siteSettings.json.ts is behaviour rather than content: siteLang and siteLocale (which feed <html lang> and og:locale), plus the two feature switches useViewTransitions and useAnimations. useViewTransitions gates whether <ClientRouter /> renders at all, in BaseHead.
legalData.json.ts holds the terms and privacy copy section by section, and both are placeholders. Have them reviewed before launch; they are not legal advice.
The per-page data files
Each page group owns one:
| File | Feeds | Holds |
|---|---|---|
homeData.json.ts |
/ |
the hero, the bands, and the six specialties it reads from servicesData |
aboutData.json.ts |
/about/ |
the story, the credential grid, the gallery |
servicesData.json.ts |
/services/ + 9 details |
all 18 specialties, the symptom router, the featured card |
doctorsData.json.ts |
/doctors/ + 9 profiles |
the directory, its filters, the callout |
careersData.json.ts |
/careers/ + 14 roles |
the listing, its facets, the benefits and hiring bands |
faqData.json.ts |
/faq/ |
26 questions across 5 groups |
bookData.json.ts |
/book/ |
the six steps, the reassurance panel, the checklist |
blogData.json.ts |
the health library | the category list and the author slug list the schema validates against |
navData.json.ts |
header + footer | the utility bar, six primary routes, the actions, the footer columns |
The larger ones break into folders — config/doctors/ has one file per clinician, config/services/ one per specialty detail, config/careers/ the role adverts — so a single profile is a single file rather than a hundred lines inside a thousand-line module.
config/types/configDataTypes.ts holds the interfaces every one of them satisfies. Because the files are typed rather than JSON, a missing field is a build error naming the file, not a blank space on a page.
One fact, one home
This is the part of the config layer worth internalising, because it is what stops a growing site drifting into inconsistency: nothing that another file already determines gets stored a second time.
No service entry stores an href. It is /services/${slug}/ for all eighteen, and serviceHref() derives it once. A field that restates what another field already determines is a field that can disagree with it.
The homepage’s specialty cards are not six entries in homeData. They are the first six of servicesData.specialties, read directly, so the title, description and icon on the homepage cannot fall out of step with the services index.
The booking form’s visit types, clinician list and insurer list are read from the price list, the doctor roster and the pricing band. A thirteenth insurer is one edit.
A blog post’s byline has no frontmatter fields at all. It reads the clinician’s roster entry, so the second line is credentials · specialty · clinic and cannot be missing, wrong, or out of step with the directory.
Reading time is computed from the article body, never stored. A hand-written readTime: 4 is a second copy of something the body already knows, and it is wrong the first time anybody edits a paragraph.
The featured post is the head of the date sort, not a featured: true flag that two entries could set at once.
The checks that keep it honest
Nine config files have a *.test.ts beside them, and they exist to catch exactly the class of mistake the rule above prevents by design — plus the ones it cannot.
servicesData.test.ts pins that a clinician the homepage also names keeps the homepage’s face. doctorsData.test.ts guards the roster the blog schema derives its author list from. navData.test.ts asserts that the links the chrome draws which do not resolve are exactly the declared PLANNED_ROUTES list — so a typo fails the build, and every page you build must be struck off the list.
They run under pnpm test, which discovers *.test.ts anywhere under src/ and fails if it finds none. Discovery rather than registration means a check written beside the code it covers runs without being listed anywhere.
Environment variables
Four, and only the first is needed to deploy at all.
SITE_URL is your production domain. It feeds canonical URLs, OG, JSON-LD, the sitemap, robots.txt and llms.txt — six things one wrong value poisons at once, and none of them visibly broken in review. It defaults to https://example.com so a fresh clone builds, and a production deploy throws on that placeholder. The gate reads each host’s own signal — Netlify’s CONTEXT, Vercel’s VERCEL_ENV, or a DEPLOY_ENV you set anywhere else — so local builds and deploy previews build freely. On a host not in that list, set DEPLOY_ENV=production in the production build environment and nowhere else.
RESEND_API_KEY, BOOKING_FROM and BOOKING_TO are the booking endpoint’s secrets. They are declared in the env block of astro.config.mjs as access: "secret", optional: true — which is what makes them read at request time rather than being baked into the build, and what lets a fresh clone with no keys still build. See Booking Form for why that declaration is load-bearing rather than ceremonial.
Copy .env.example to .env for local work; set the same names in your host’s environment for a deploy.
Feature switches
useViewTransitions in siteSettings.json.ts decides whether <ClientRouter /> renders. It pairs with vite.build.assetsInlineLimit: 0 in astro.config.mjs, which stops short scripts being inlined so they do not break under the router — if you turn view transitions on and see scripts stop firing after a navigation, that setting is why it is there.
useAnimations gates the animation layer. Neither switch removes the reduced-motion guard, which is unconditional; see Motion.