Configuration
Finly’s configuration is a directory of TypeScript modules under src/config/, each typed by an interface in src/config/types/. They are TypeScript rather than JSON for one reason: a mistake becomes a build error naming the offending value, instead of a blank space on a page.
There is one governing rule, and it is worth reading before the file list. Nothing user-facing is hard-coded in a component, and nothing derivable is stored in config. The first half is why the layer exists. The second half is why it is smaller than you might expect.
The three top-level files
src/config/siteSettings.json.ts holds the locale source of truth and the feature switches:
export const locales = ["en"] as const;
export const defaultLocale = "en" as const;
export const localeMap = { en: "en-US" } as const;
export const siteSettings = {
useViewTransitions: true,
useAnimations: true,
} satisfies SiteSettingsProps;
Finly ships one locale, and astro.config.mjs has no i18n block, so this file is the only place locales lives and nothing has to be kept in sync with the Astro config. localeMap maps each locale to a BCP-47 tag for Intl date formatting, and BaseHead reads it for og:locale too.
satisfies rather than a type annotation is deliberate on the settings object: it checks the shape while preserving the literal true, so a component branching on useViewTransitions gets a constant rather than a widened boolean.
src/config/translationData.json.ts is three registries. dataTranslations maps each locale to its thirteen data modules, which is what getTranslatedData("homeData", locale) reads. textTranslations holds the handful of UI strings that are not page copy — the menu labels, the 404 text, “Last updated” — reached through useTranslations(locale). routeTranslations maps localized URL segments and is empty, because there is only one locale.
src/config/types/ is fifteen modules behind one barrel. configDataTypes.ts re-exports them all, and sixty-four files import it, but importing ./careers directly is the better habit in new code. The seam mirrors the component tree: bands.ts holds the contracts for the bands more than one page draws (ClosingCta, FaqBand, ProofBand, ReasonBand, RuleList), sitting above the per-page modules for the same reason those components sit above the page folders.
The per-page data files
Thirteen modules under src/config/en/, one per page or page family:
| File | Drives |
|---|---|
siteData.json.ts |
brand, home page SEO, author block, sameAs, default OG image |
navData.json.ts |
the header’s four mega-menu groups and the footer’s four columns |
homeData.json.ts |
the home page’s ten bands |
productData.json.ts |
/product/ — hero, three pillars, workflow rail, comparison |
integrationsData.json.ts |
the directory’s chrome; the connectors live beside it |
pricingData.json.ts |
three tiers, the feature matrix, add-ons, the enterprise band |
customersData.json.ts |
the stories index and story-page chrome |
blogData.json.ts |
the blog index and post-page chrome |
careersData.json.ts |
the careers index and role-page chrome, plus offices and departments |
aboutData.json.ts |
origin story, values, timeline, team, offices, press, investors |
contactData.json.ts |
the contact hero, the enquiry routes and the form’s labels |
authData.json.ts |
the three account screens |
legalData.json.ts |
the terms and privacy copy |
Each satisfies its interface, so adding a band to a page is: extend the interface, fill the field, render it. Miss the second step and the build tells you which page and which key.
Two of the thirteen have grown a directory beside them. en/careers/roles.ts holds the nine adverts, and en/integrations/ holds the sixty connectors split into accounting.ts, erp.ts, hris.ts, banking.ts and sso.ts — one module per category, each carrying its own descriptor, its defaults and its connectors together. The catalog was a single 1,962-line file once, and the size was the smaller problem: a category’s defaults sat up to 1,500 lines away from the twelve connectors that fall back to them. integrationsData now derives its three exposed shapes from one ordered list of those five modules, so a category id is written once instead of three times, and a category cannot exist without defaults or defaults without a category.
Nothing derivable is stored
This is the rule that most changes how the layer feels to edit, and the careers pages are its clearest case. Six places quote the number of open roles — the index heading, its lead, the hero card, the about page’s teaser, the detail page’s secondary CTA and the closing band’s. The design writes “nine” into all six. The config writes {count}:
title: "Careers at Finly — {count} open roles in Amsterdam and Lisbon",
@js/roleFacts fills it from catalog.length through the tiny fill() helper in @js/template. Opening a tenth role is one entry in roles.ts, not a hunt through two config files for the word “nine” — and a “nine roles” button under a table of eight becomes unrepresentable rather than merely unlikely.
The same rule runs through the rest of the site. A customer story stores headcount: 610 and financeHeadcount: 4; the card’s “Marketplace · 610 people” line, the facts rail’s company-size sentence and the size band the filter groups by are all computed from those two numbers by @js/customerUtils. A sizeBand: "medium" field would be a second copy of headcount that can disagree with it.
The corollary matters when you are adding a field: if you find yourself writing a number that appears elsewhere in the same data, write the formula instead and put it in a *Facts module, where it can carry a runnable check.
Photographs are not config
Every band on the site splits its images the same way. The alt text is content and lives in src/config/en/; the file is a static import at the call site, because a path read from config at runtime cannot be optimized by astro:assets.
That split leaves one hazard — two lists that must stay in step, with nothing checking it — and @js/imageRegistry is the eight lines that close it:
const PORTRAITS = { "Ruth Adeyemi": Ruth, /* … */ };
imageFor(PORTRAITS, person.name, "TeamSection");
It throws naming the key and the caller rather than falling back to a placeholder, because the failure it prevents is silent: a config entry with no matching import renders a person-shaped hole on a wall whose own heading says a hundred and eighty. It is keyed rather than positional for the same reason — two of the three call sites it replaced indexed by array position, where reordering the config silently repaints every card with the wrong photograph.
navData, and the rule that every row resolves
navData is the site’s information architecture in one file: four header groups, each opening a mega panel; a headerAction (“Log in”); a headerCta (“Book a demo”); and four footer columns.
Two properties of it are enforced rather than merely intended.
Trailing slashes are required. astro.config.mjs sets trailingSlash: "always", so a row written without one earns a 301 on every click. The single exception is /rss.xml, a file rather than a directory, and getLocalizedRoute leaves it un-slashed for that reason.
Every row is a page this repo builds. The footer used to carry Finly’s intended IA with dead rows included — /docs/, /docs/api/, /status/, /changelog/, /security/, /cookies/ and three /product/* surfaces. They were deleted rather than stubbed, on the grounds that a footer is the site map and a map whose rows 404 is worse than a short one. The three product nouns survive as fragments into /product/, which is where all three pillars are actually written.
A column can also generate its rows from a collection:
{ heading: "By industry", source: "industries" }
resolveHeaderGroups in @js/nav fills those at build time from the live set of industries and categories, so the archive lists in the mega menu cannot go stale when a story or a post changes its label.
The check behind all of this is finly:link-integrity, a build hook in astro.config.mjs. It scans the emitted HTML, not this file, which is what makes it complete: it also covers headerCta (on 120 of 123 pages), a linkHref written in a customer story’s frontmatter, and — the one no config-side check can reach — a href that a helper rewrote on the way out. It caught getLocalizedRoute normalizing the footer’s correctly-declared /rss.xml into /rss.xml/, which 404s under trailingSlash: "always".
Reaching config from a component
One helper, everywhere:
---
import { getLocaleFromUrl } from "@js/localeUtils";
import { getTranslatedData } from "@js/translationUtils";
const locale = getLocaleFromUrl(Astro.url);
const { hero, surfaces } = getTranslatedData("homeData", locale);
---
getTranslatedData is typed against the dataTranslations registry, so a typo in the key is a compile error and the returned object is fully typed. It does a second, non-i18n job at one locale: it is how a component reaches typed config at all, which is why the locale helpers are kept even though the site ships one language. Fifty-nine files read them, every lookup folds to a constant at build, and tearing the seam out would be a fifty-nine-file diff to delete about 170 lines.
Adding a locale
There is no script for it. What it takes is listed in siteSettings.json.ts itself:
- Add the code to
localesand give it alocaleMapentry. - Mirror
src/config/en/*.json.tsundersrc/config/<locale>/and register them intranslationData.json.ts. - Mirror the routes under
src/pages/<locale>/. - Create
src/data/blog/<locale>/andsrc/data/customers/<locale>/.
BaseHead starts emitting hreflang and og:locale:alternate on its own once locales.length > 1 — that block is already written and simply drops out at one locale.