Skip to content
AstroCraft Docs
On this theme

Project Structure

Grafio has one organising idea, and once you see it the rest of the tree explains itself: a page is a thin shell that owns its layout and its SEO, and composes sections; a section owns markup and layout; a card knows about a data shape; a primitive knows nothing at all. Every file in src/components/ sits at exactly one of those four levels, and the folder it lives in tells you which.

The second idea is that content and copy live outside components. Editorial text is in src/config/, long-form content is in src/data/, and design values are CSS custom properties in src/styles/. A component that hard-codes a headline is a component you have to open again later.

The tree

src/
├── actions/index.ts              # the contact action — the only server code path
├── assets/                       # optimizable media: images/ (by owner), logos/
├── components/
│   ├── Sections/<Page>/<Name>.astro  # layout-free page sections; Global/ for shared ones
│   ├── Cards/<Name>Card.astro        # content-aware compositions
│   ├── ui/<name>/<Name>.astro        # 46 UI primitives
│   └── svg/icons/                    # the 571-icon registry
├── config/                       # typed site config — the copy layer
├── content.config.ts             # Zod schemas for the three collections
├── data/<collection>/<slug>/     # content entries and their own images
├── js/                           # blog, work, contact, rss, schema, readingTime, textUtils
├── layouts/                      # BaseHead (the <head>), BaseLayout (the shell)
├── pages/                        # routes
└── styles/                       # global.css (entry), tailwind-theme.css, fonts.css, motion/

The four component tiers

This is the part worth internalising, because it answers “where do I put this?” for almost everything you will build.

A route in src/pages/ owns route concerns and nothing else: it imports BaseLayout, computes a unique title and description, optionally sets noindex, schema or article, and composes sections. It holds no markup. src/pages/about.astro is the whole pattern in fifteen lines:

---
import Experience from "@components/Sections/About/Experience.astro";
import Hero from "@components/Sections/About/Hero.astro";
import Cta from "@components/Sections/Global/Cta.astro";
import Testimonial from "@components/Sections/Global/Testimonial.astro";
import siteData from "@config/siteData.json";
import BaseLayout from "@layouts/BaseLayout.astro";

const { author } = siteData;
const title = `About ${author.name} — ${siteData.name}`;
const description = `${author.name} is a creative developer working on the seam between high-end design and robust engineering.`;
---

<BaseLayout title={title} description={description}>
  <Hero />
  <Experience />
  <Testimonial />
  <Cta />
</BaseLayout>

A section in src/components/Sections/ is a layout-free block of page content — a hero, a feature grid, a legal article. It owns its own rhythm, its own responsive behaviour and its own motion timing. It never imports BaseLayout. Sections live in a folder named after the page that renders them (Home/, About/, Contact/), or in Global/ once a second page uses them. The rule for promotion is literal: a section used by two or more pages moves to Global/, and not before.

A card in src/components/Cards/ is a content-aware composition that knows a data shape — ProjectCard takes a CollectionEntry<"work">, ArticleCard takes a CollectionEntry<"blog">. Sections map over cards. Cards own their insides only: no margin, no width, no arrival animation. Whatever places a card owns those, which is what lets a section wrap the same card in a staggered reveal on one page and a plain grid on another.

A primitive in src/components/ui/ knows nothing about your data. It is a Button, a Dialog, a Reveal. There are 46 of them and they follow a strict five-rule contract, covered on the Components page.

There is a fifth, informal tier: a sub-part, which is a sibling file inside a section’s folder for something independently swappable. NotFound/NotFoundIllustration.astro is one. So is Home/ServiceList.astro. Pure logic gets the same treatment — Global/headerScroll.ts sits beside Global/Header.astro with its own headerScroll.test.ts.

How data reaches a section

A section gets its content one of two ways, and never both for the same value. Either the route passes typed props down, or the section imports from src/config/ itself.

The props-in style is for anything derived from content or split by the route. src/pages/blog/index.astro calls getAllPosts(), destructures const [featured, ...rest], and hands each piece to the section that draws it — so the featured slot and the grid can never disagree about which post is which.

The config-reading style is for a section’s own framing copy. Sections/Home/Hero.astro imports pageCopy.home directly, because nothing else on the page needs those strings and threading them through the route would only add a hop.

The rule against doing both matters more than it looks. If a route reads pageCopy.work for its meta description and the section reads it again for its heading, you have two readers of one fact and no compiler to keep them honest.

Path aliases

Deep relative imports are the thing this tree makes easy to get wrong, so tsconfig.json declares eight aliases. Prefer them everywhere:

{
  "@config/*":     ["./src/config/*"],
  "@js/*":         ["./src/js/*"],
  "@layouts/*":    ["./src/layouts/*"],
  "@components/*": ["./src/components/*"],
  "@assets/*":     ["./src/assets/*"],
  "@images/*":     ["./src/assets/images/*"],
  "@videos/*":     ["./src/assets/videos/*"],
  "@/*":           ["./src/*"]
}

There is no baseUrl, deliberately. Since TypeScript 4.1 paths resolve relative to the config file’s own directory, and baseUrl is deprecated — removed in TypeScript 7. The values are prefixed ./ to stay explicitly relative.

ESLint’s simple-import-sort orders imports for you, side-effect imports first and then alphabetised, so run pnpm format rather than hand-sorting.

One naming quirk worth knowing

The Sections/ subfolder is named after the page a route renders, not the URL segment. That is why you will find both Blog/ and Post/, and both Work/ and Project/. Blog/ holds the sections of the blog index; Post/ holds the sections of a single post. Work/ is the work index, Project/ is a case study. Two routes, two designs, two folders.

Files you will edit, and files you probably won’t

On a normal project you will spend nearly all your time in four places: src/config/ for copy and settings, src/data/ for content, src/styles/tailwind-theme.css for the palette and type, and src/components/Sections/ for layout changes.

You will occasionally touch src/pages/ — adding a route, changing a page title — and src/components/Cards/ if a card needs a new field.

You will rarely need to open src/components/ui/ (the primitives are done), src/js/ (small, tested helpers), src/layouts/ (the head and the shell), or src/styles/motion/ (an owned animation catalog). If you find yourself editing a primitive to change how it looks on one page, the primitive contract has an answer that does not involve editing it — pass a class, and tailwind-merge resolves the conflict in your favour.

The one thing to delete before launch

src/components/Sections/UiCatalog/ and src/pages/examples/ are the development-only primitive showroom. The route emits no pages in a production build, but Tailwind still scans the catalog’s markup, so its demo classes sit in the stylesheet every real page loads. Deleting both directories takes the shared CSS from roughly 108 KB to 89 KB. Keep them while you are still choosing components; the cost is CSS, not JavaScript, and it is by far the fastest way to see all 46 primitives at once.

NEXT STEPConfiguration