Project Structure
Urengi has one organising idea, and almost every directory decision follows from it: a page is a thin route shell that owns its layout and its SEO, and composes sections that own markup. Once you know that sentence you can predict where a given piece of code lives.
src/
├── actions/ the six form actions — the only trust boundary; each ships inert
├── assets/ optimizable media for astro:assets (images/, logos/)
├── components/
│ ├── Sections/<Page>/ layout-free page sections (Global/ for cross-page ones)
│ ├── Cards/ content-aware card compositions, one per collection
│ ├── ui/<name>/ the 44-primitive library (see its README for the contract)
│ └── svg/icons/ the <Icon> system
├── config/ typed site + per-page config — the source of truth, never literals
├── data/<collection>/ content collections, Zod-validated
├── js/ TypeScript utilities + their *.test.ts checks
├── layouts/ BaseLayout + BaseHead (all meta/SEO tags live here)
├── pages/ file routes — thin shells owning BaseLayout + SEO; *.ts are endpoints
└── styles/ global.css entry, tailwind-theme.css tokens, motion/ catalog
The three tiers
Composition runs in one direction, and each tier has a README stating its contract.
Pages (src/pages/) own BaseLayout, the page title and description, noindex, any page-specific JSON-LD, and — for dynamic routes — getStaticPaths. They read config and collections, and they compose sections. A page holds almost no markup of its own; src/pages/integrations/index.astro is thirty lines of frontmatter and six section tags.
Sections (src/components/Sections/) are layout-free blocks of page content: a hero, a feature grid, a legal article. A section never imports BaseLayout. It either receives its content as typed props from the route, or reads typed config itself from @config/* — never both for the same data.
The folder split is a single admission test: a section used by two or more pages moves to Global/; until then it lives under its page’s folder. That test is worth enforcing, because Global/ is the busiest folder in the tree and stays one job rather than a junk drawer only because nothing enters it on a hunch. Sub-parts of a section sit as sibling files in the same folder and are imported relatively, which is why a few Global/ files have exactly one consumer that is itself in Global/ — the footer bands under Footer.astro, DeviceFrame under DifferentiatorPanel.astro.
Primitives (src/components/ui/) are the generic building blocks — Button, Dialog, Table, the motion set. They know nothing about the site’s data. Cards (src/components/Cards/) sit between the two: a card is a content-aware composition built from the ui/card parts that knows a data shape. BlogCard takes a CollectionEntry<"blog"> and destructures inside; a section maps over entries and passes them straight through, because spreading the fields at the call site would put the data shape in two files and make every new field a two-file edit.
Three cards ship — BlogCard, CaseStudyCard, IntegrationCard — one per collection with a listing, which is the shape to copy for a fourth.
The per-page section folders
src/components/Sections/
├── Global/ 20 shared sections + 7 internal modules — chrome, bands, listings, prose
├── About/ Story, Principles, Team, Milestones, Careers
├── Auth/ the sign-in and sign-up frames, their panels and their two switches
├── Blog/ Hero, PostGrid, PostHeader, PostBody, PostAside, RelatedPosts
├── CaseStudies/ StoryGrid, StoryHeader, StoryBody, StoryAside, ResultsBand, ResultsStrip
├── Contact/ ContactHero, ContactMain, ContactForm, ContactDetails, Faq
├── Home/ Hero, ProblemStats, HowItWorks, Differentiators, Integrations, Pricing, …
├── Integrations/ Hero, Featured, Directory, DetailHero, Capabilities, Permissions, SetupSteps, …
├── Legal/ LegalArticle — one section renders both /privacy/ and /terms/
├── NotFound/ the 404 body
├── Pricing/ Plans, Matrix, Included
├── Product/ Hero, Capabilities, RiskHeatmap, Differentiators, ProductMock
└── UiCatalog/ the dev-only showroom — delete this and src/pages/examples/ together
Two naming conventions run through Global/. A PascalCase.astro file is a section. A file with a leading underscore — _footerBand.ts, _formGate.ts, _newsletter.ts — is an internal TypeScript module owning one decision, and it is a module rather than an export from a component because astro/no-exports-from-components is switched off only for ui/ and svg/. A Sections/ component may not export, so anything that needs to be importable lives in a sidecar.
The config layer
src/config/ is typed data, and it is the surface you edit to rebrand. Four files are site-wide — siteData, siteSettings, navData, legalData — and one file per page carries the rows that page draws: homeData, productData, pricingData, aboutData, contactData, authData, blogData, caseStudyData, integrationData. plannedRoutes.json.ts is the odd one out: it is a list of routes the design links but no page builds yet, and astro.config.mjs turns it into redirects.
Interfaces for all of them live in src/config/types/configDataTypes.ts. Eight files carry a *.test.ts beside them, checking what a type cannot — that every row a page needs is filled, that an internal link points at a route which ships, that a value repeated in a second file still agrees with it. Configuration covers each in detail.
The utility layer
src/js/ holds pure TypeScript and its checks, and the split inside it is worth knowing because it decides where a new helper goes. Most modules are pure functions over structural shapes — blog.ts, listing.ts, integrations.ts, caseStudies.ts, nav.ts, schema.ts, rss.ts, textUtils.ts, svg.ts — so their self-checks run under bare node --experimental-strip-types. collections.ts is the exception: it touches astro:content and therefore cannot. If a helper needs the content layer it goes in collections.ts; otherwise it goes beside the shape it operates on.
That module is where the site’s three definitions of “published” live — publishedPosts, publishedStories, publishedIntegrations. Each predicate was inlined at half a dozen call sites before, which is half a dozen chances for them to disagree about what a draft is. The failure mode is not a broken page: it is a draft that stays hidden on the listings and quietly ships in the RSS feed, where nobody is looking.
The remaining modules under src/js/ are the server halves of the six forms — newsletter.ts, contactEnquiry.ts, referenceCall.ts, integrationRequest.ts, salesEnquiry.ts, signUp.ts, plus the shared formGuards.ts and resend.ts. They are live, self-checked code that the actions in src/actions/ call; see Forms & Email.
Path aliases
tsconfig.json defines six, and they resolve relative to the config file rather than through a baseUrl (which TypeScript has deprecated):
| Alias | Resolves to |
|---|---|
@config/* |
src/config/* |
@js/* |
src/js/* |
@layouts/* |
src/layouts/* |
@components/* |
src/components/* |
@assets/* |
src/assets/* |
@/* |
src/* |
Use them for anything crossing a directory boundary. Relative imports stay for siblings — a section’s sub-parts, a primitive’s own parts — which is the signal that those files belong together.
One alias trap is worth stating: import.meta.glob needs a literal relative path, because an alias is invisible to Vite’s static analysis. src/js/logos.ts spells out "../assets/logos/*.svg" for exactly this reason.
Where does a new file go?
- A block of page markup used by one page →
Sections/<Page>/. - The same block, now wanted by a second page → move it to
Sections/Global/and update both call sites. - Something generic with variants and no knowledge of your data →
components/ui/<name>/, following the five-rule contract insrc/components/ui/README.md. - Something that renders one collection entry as a card →
components/Cards/. - A pure function two components both need →
src/js/, with a*.test.tsbeside it. - A value the copywriter edits →
src/config/, never a literal in a component. - A value that is a colour, size or spacing decision → a token in
src/styles/, not config.