Skip to content
AstroCraft Docs
On this theme

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 in src/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.ts beside 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.
NEXT STEPConfiguration