Project Structure
Finly has one organising idea, and almost every directory decision follows from it: a route is a thin shell that owns its URL and its SEO, and delegates its markup to a feature component, which composes UI primitives. Once you know that sentence you can predict where a given piece of code lives, and — more usefully — where a new piece belongs.
Every route file in the theme is between five and fifty lines. src/pages/product/index.astro is eleven of them, and nine are a comment explaining why the page exists.
The tree
src/
├── actions/ the contact form's server half — index.ts is what Astro mounts
├── assets/images/ photographs and product shots, imported not referenced
├── components/
│ ├── <Feature>/ one PascalCase folder per page or band (Home, Pricing, Blog, …)
│ ├── ui/<name>/ the 61 primitives — lowercase folder, PascalCase file
│ ├── svg/icons/ <Icon> + the generated 571-entry registry
│ └── svg/logos/ integration marks and SSO lockups
├── config/
│ ├── en/*.json.ts typed page content — one file per page, plus siteData and navData
│ ├── en/careers/ the nine role adverts
│ ├── en/integrations/ the sixty connectors, one module per category
│ ├── types/ the interfaces those files satisfy
│ ├── siteSettings.json.ts locales, defaultLocale, localeMap, feature switches
│ └── translationData.json.ts registries: data / text / route
├── data/ content collections — blog, customers, authors
├── js/ helpers: schema, contact, and the *Facts / *Utils pairs
├── layouts/ BaseHead (every meta and SEO tag) + BaseLayout (the shell)
├── pages/ thin file routes and the three text/XML endpoints
└── styles/ global.css (entry), tailwind-theme.css, motion/
Outside src/: public/ holds the three files that must ship unprocessed (favicon.svg, favicon.ico, og.jpg), scripts/ holds the check runner and the theme-contrast check, wiki/ is a maintained knowledge base covering each subsystem, and .claude/rules/ holds the five coding rules the project is written to.
The component layer, in three tiers
Tier one — src/components/ui/. Sixty-one primitives, each in its own lowercase folder with a PascalCase .astro file and an index.ts re-export. A primitive is content-unaware: it knows about props, variants and tokens, and nothing about routes, collections or copy. PostCard takes a href, a title and a deck; it has never heard of CollectionEntry. That is deliberate, and it is what lets three different bands render the same card. The contract those primitives are written to is in UI Components.
Leading-underscore files in that folder are shared internals rather than primitives: _client.ts (the load and astro:after-swap re-init contract every scripted primitive shares), _dialog.ts, _popover.ts, _field.ts, _listbox.ts, _motion.ts, _reveal.ts, _sequence.ts, _zoom.ts, _overlay.css and _prose.css.
Tier two — src/components/<Feature>/. One PascalCase folder per feature, holding the bands that make up a page. Home/ has fourteen files; Customers/ has fifteen. These know about routes and content. They read typed config through getTranslatedData, map collection entries onto primitive props, and compose. A file here may be long; what it may not do is hard-code a user-facing string.
Tier three — src/pages/. The route. It owns getStaticPaths where there is one, resolves the entries it needs, and hands them to a feature component. src/pages/blog/[slug].astro calls render() itself — because that call returns both the <Content /> component the body needs and the headings array the reading rail needs — and passes the pair down, which keeps Blog/Post.astro free of astro:content calls entirely.
The rule for a new file follows directly. Does it know about a route or a collection? It is a feature component. Does it know only about props and tokens? It is a primitive. Is it neither, and is it arithmetic? It belongs in src/js/.
The config layer
Nothing user-facing is written in a component. Every page’s copy lives in a typed module under src/config/en/, reached through getTranslatedData("homeData", locale), and satisfies an interface in src/config/types/. There are thirteen data files, one per page or page family, plus siteData and navData.
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. Two of them are directories rather than single files, because their catalogs outgrew one module — en/careers/roles.ts holds the nine adverts, and en/integrations/ holds the sixty connectors split across five category modules.
What is not in config is anything derivable. The number of open roles appears in six places on the careers pages; it is {count} in the config and catalog.length at render. See Configuration for the whole layer.
The helper split in src/js/
Several subsystems appear twice, and the split is not arbitrary. roleFacts.ts and roleUtils.ts, integrationFacts.ts and integrationUtils.ts, postFacts.ts and blogUtils.ts — in each pair the *Facts half is pure arithmetic over a catalog and the *Utils half is the impure part that knows about routes and collections.
The reason is the check runner. pnpm test executes every src/**/*.test.ts under bare Node with --experimental-strip-types, which resolves neither the @js/* path aliases nor astro:content. A module that wants a runnable check therefore has to be import-clean: relative specifiers with extensions, import type for anything from the config layer, and no astro:* anywhere. postFacts.ts, roleFacts.ts, integrationFacts.ts, productFacts.ts, template.ts, groupBy.ts, contact.ts and schema.ts all keep that discipline, and each says so at the top of the file. Their siblings do not have to.
The same constraint explains src/components/ui/count-up/format.ts sitting beside CountUp.astro: the formatter is shared between the server render and the browser’s counting frames, so it is split out where both can reach it and a check can run over it.
Path aliases
tsconfig.json declares six, and they are preferred over deep relative imports everywhere:
"paths": {
"@config/*": ["./src/config/*"],
"@js/*": ["./src/js/*"],
"@layouts/*": ["./src/layouts/*"],
"@components/*": ["./src/components/*"],
"@images/*": ["./src/assets/images/*"],
"@/*": ["./src/*"]
}
There is no baseUrl: since TypeScript 4.1 paths resolve relative to the config file’s own directory, and baseUrl is deprecated. The path values are prefixed ./ to stay explicitly relative.
The one place aliases are deliberately not used is inside the checkable modules described above, which import their neighbours as ./groupBy.ts — bare Node cannot see tsconfig paths, and keeping those files runnable there is the entire reason they are separate.
The styles layer
Four files and one directory, imported in one place. src/styles/global.css is the single CSS entry point — BaseLayout imports it and nothing else imports a stylesheet. It pulls in fonts.css, Tailwind itself, tailwind-theme.css (the palette aliases and the @theme inline bridge) and motion/index.css (the animation catalog, which in turn imports motion/keyframes.css). It then defines the semantic runtime variables for :root and .dark.
That ordering is load-bearing and is documented in Colors & Theming.
Where the invariants live
Three kinds of correctness are enforced in three different places, and knowing which is which saves a lot of hunting:
- Types —
astro checkand thesatisfiesclauses on every config module. A missing field or a wrong shape fails here. - Content schemas — Zod, in
src/content.config.ts, atdevandbuildtime. Bad frontmatter fails with the entry named. - Cross-file invariants the compiler cannot see — build hooks in
astro.config.mjs.finly:link-integrityreads the emitted HTML and fails on any href pointing at a path the build did not produce; the sitemap’sfilterandcustomPageskeep the crawl surface honest about the one on-demand route.
Anything that is pure logic gets a runnable check beside it instead. That is the fourth place, and it is the one you add to when you write something non-trivial.