Skip to main content
@shmVirus

Building a Small Astro Theme That Can Grow

A practical architecture for an Astro theme that begins with a single layout but remains easy to extract, configure, and maintain.

Evergreen

10 min read1,909 words

A small website rarely stays small in the ways that matter. The first version may contain one layout, a navigation bar, and a handful of pages. Then the site gains publications, projects, teaching material, dark mode, search, structured metadata, and content collections. None of those features is individually difficult. The difficulty comes from adding them without turning the original theme into a collection of page-specific exceptions.

The useful goal is therefore not “make a reusable theme” on day one. That goal tends to produce abstractions before there is enough evidence to justify them. A better goal is:

Build one real site with clear boundaries, then make those boundaries strong enough that extraction becomes mechanical.

This article describes the structure I use for that process. It is intentionally modest: no component framework, no elaborate token pipeline, and no configuration language pretending to be a CMS. The result is still a normal Astro project, but the theme-related code has an obvious home and the content remains independent of it.

Start with ownership, not components

The first architectural decision is where theme code lives. A dedicated directory makes ownership visible:

src/
├── content/
│   ├── projects/
│   ├── publications/
│   └── writing/
├── devscholar/
│   ├── components/
│   ├── layouts/
│   ├── lib/
│   ├── styles/
│   └── types.ts
└── pages/

The pages directory owns URLs. The content directory owns authored material. The theme directory owns presentation and reusable behavior. This sounds obvious, but it prevents a common problem: placing half of a feature in a page, half in a global stylesheet, and its data assumptions in an unrelated utility.

A useful test is to imagine removing the theme directory. The site should lose its presentation, but the Markdown content and route intent should still be understandable. Conversely, moving the theme into another project should not require copying personal content with it.

Keep route files thin

Astro route files are excellent composition points. They are less effective as permanent homes for shared logic. A route should normally do three things:

  1. load the data required for the URL;
  2. select the layout and components;
  3. provide the small amount of markup unique to that page.

If a route contains its own button system, card styling, date formatter, and metadata rules, the theme boundary is already leaking.

Establish one layout contract

The base layout is the center of the theme. It does more than wrap a header and footer: it defines the document contract shared by every page.

---
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import SearchModal from "../components/SearchModal.astro";
import baseCssUrl from "../styles/base.scss?url";

const { title, description } = Astro.props;
---

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>
    <meta name="description" content={description} />
    <link rel="stylesheet" href={baseCssUrl} />
  </head>
  <body>
    <Header path={Astro.url.pathname} />
    <main class="site-main">
      <div class="site-container">
        <slot />
      </div>
    </main>
    <Footer />
    <SearchModal />
  </body>
</html>

The exact markup is not important. The contract is. Every page receives canonical typography, theme initialization, metadata, navigation, search, and footer behavior through one path. A feature that truly varies can be exposed as a prop or named slot, but variation should be deliberate.

For example, a teaching chapter may need a page-level outline while ordinary pages do not. A named page-aside slot is a clean exception because the layout explicitly owns where an aside belongs. Adding an ad hoc fixed-position element inside the chapter page is not.

Use tokens as constraints

Design tokens are sometimes treated as a branding exercise. For a small theme, their more valuable role is limiting accidental decisions.

:root {
  --bg: #fbfbf8;
  --surface: #f3f5f7;
  --text: #1b2430;
  --muted: #5f6b7a;
  --border: #e1e6ee;
  --accent: #1d4ed8;

  --radius: 12px;
  --container: 980px;
  --header-h: 56px;
}

html[data-theme="dark"] {
  --bg: #121417;
  --surface: #171c24;
  --text: #e6e8ee;
  --muted: #a3acb8;
  --border: #232a36;
  --accent: #8ab4f8;
}

A component should consume these values instead of inventing a new near-black, gray, or blue. The same principle applies to spacing and shape. Not every measurement needs a variable, but recurring semantic choices do.

The color-mix() function is particularly useful for restrained interfaces. It lets a component derive a subtle hover background or border from the active theme:

.project-card:hover {
  border-color: color-mix(in srgb, var(--accent) 28%, var(--border));
  background: color-mix(in srgb, var(--surface) 92%, transparent);
}

This keeps dark mode from becoming a second, separately maintained stylesheet.

Let typography do most of the work

A content-heavy academic or developer site does not need many decorative elements. Hierarchy can come from four variables:

  • size;
  • weight;
  • line height;
  • available measure.

Body text that spans the full 980-pixel container will feel unfinished regardless of font choice. Constraining prose to roughly 68ch–76ch improves the page more than adding another card or gradient. Likewise, a title benefits from a tighter line height and slightly negative letter spacing, while metadata should be smaller and quieter.

Good typography is not a layer applied after layout. It is the layout.

Put personal data behind configuration

A reusable theme cannot contain a hard-coded name, profile link, navigation item, or site URL. Those values should enter through a typed configuration object:

export type SiteConfig = {
  description: string;
  author: string;
  url: string;
  nav: Array<{ label: string; href: string }>;
  profile: {
    name: {
      first: string;
      middle?: string;
      last?: string;
    };
    tagline: string;
    avatarSrc: string;
  };
};

Types matter here because configuration errors otherwise surface far away from their cause. A misspelled property may show up as a blank footer or invalid metadata during a build. A typed object catches the mismatch where it was introduced.

Configuration should describe identity and choices, not arbitrary markup. If a config file grows fields such as homeSecondColumnCardPadding, the boundary is going in the wrong direction. Visual behavior belongs in components and styles; content and user-selectable options belong in configuration.

Model content independently

Theme components should not infer meaning from filenames or raw frontmatter. Content collections provide a stable interface between authored Markdown and presentation.

const writing = defineCollection({
  loader: glob({
    pattern: "**/*.{md,mdx}",
    base: "./src/content/writing",
  }),
  schema: z.object({
    title: z.string(),
    date: z.coerce.date(),
    kind: z.enum(["tutorial", "tricky", "general"]),
    summary: z.string(),
    topics: z.array(z.string()).default([]),
    featured: z.boolean().default(false),
  }),
});

The page can now depend on entry.data.title, entry.data.topics, and entry.id rather than parsing conventions itself. The schema also documents what authors are expected to provide.

There is an important balance here. A schema should protect fields used by the interface, but it should not attempt to encode every possible sentence in the article. Structured metadata is valuable when the site sorts, filters, labels, or links with it. The Markdown body remains the right place for the argument.

Prefer small components with complete responsibilities

Component size is a poor proxy for component quality. A fifty-line component can be confused; a two-hundred-line component can be cohesive. The better question is whether a component has a complete, nameable responsibility.

Examples of useful responsibilities include:

  • render the global header from navigation configuration;
  • display one publication and its citation controls;
  • render an icon from the supported icon set;
  • provide the document shell and metadata;
  • show one project using normalized repository information.

By contrast, TextWithOptionalIconAndMaybeTooltip.astro is likely an implementation fragment rather than a meaningful boundary.

I usually extract a component when at least one of these conditions is true:

  1. the pattern appears in more than one route;
  2. it contains behavior that deserves isolated testing or reasoning;
  3. it has a stable data contract;
  4. giving it a name makes the parent page easier to understand.

Keep enhancement optional

Search filters, copy buttons, theme switches, and reading progress indicators are useful, but the page should remain understandable without them. Astro makes this style of progressive enhancement natural because the primary document is rendered as HTML.

A writing archive, for example, can render every article as a normal linked entry. A small script then filters those existing elements:

const items = document.querySelectorAll("[data-writing-item]");
const search = document.querySelector("[data-writing-search-input]");

search?.addEventListener("input", () => {
  const query = search.value.trim().toLowerCase();

  items.forEach((item) => {
    const haystack = item.dataset.writingSearch ?? "";
    item.hidden = query !== "" && !haystack.includes(query);
  });
});

The script improves navigation; it does not create the archive. This distinction produces pages that load quickly, remain accessible, and are easier to debug.

Make dark mode boring

Dark mode should be a token swap, not a component-by-component project. Apply the saved or system theme before the first paint so the page does not flash in the wrong mode:

<script>
  const saved = localStorage.getItem("site-theme");
  const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
  document.documentElement.dataset.theme =
    saved === "light" || saved === "dark"
      ? saved
      : prefersDark
        ? "dark"
        : "light";
</script>

Then inspect components for hard-coded colors. Syntax highlighting, diagrams, browser-native form controls, and transparent overlays are the places most likely to escape the token system.

Validate the boundaries

The production build is the first test because it exercises content loading, static paths, Markdown rendering, and bundling together:

npm run build

It is not the last test. I also verify representative URLs from each content family:

/
/writing/example-article/
/projects/example-project/
/teaching/example-course/chapter/
/search.json

Then I inspect at least four visual states:

StateWhat to look for
Wide, lightline length, empty space, alignment
Wide, darkcontrast, borders, code blocks
Narrow, lightwrapping, tap targets, overflow
Narrow, darkoverlays, menus, subtle text

Testing only the home page is not enough. Detail pages usually expose typography and overflow problems that card-based indexes hide.

Know when extraction is justified

The theme is ready to extract when the operation feels boring. That means:

  • theme code already lives under one directory;
  • personal values already come from configuration;
  • content already enters through documented schemas;
  • routes mostly compose theme components;
  • assets have clear ownership;
  • the build has no implicit dependency on the original repository.

At that point, extraction is mostly packaging: move the directory, document installation, expose the configuration type, and decide how consumers import styles and components.

If extraction still requires redesigning half the site, the boundaries were not real yet.

A practical sequence

For a new Astro site, I would build in this order:

  1. create one base layout and one real page;
  2. define the small token set used by that page;
  3. move identity and navigation into typed configuration;
  4. add one content collection with a strict but modest schema;
  5. build the first index and detail route;
  6. extract components only as responsibilities become clear;
  7. add dark mode through token overrides;
  8. progressively enhance search and small interactions;
  9. verify production output and representative routes;
  10. consider packaging only after a second content family uses the same system.

This sequence produces useful software at every stage. It also leaves room for the theme to be shaped by real content instead of imagined future requirements.

Closing thought

A small theme succeeds when it disappears behind the work it presents. Readers should notice that the site is calm, coherent, and easy to navigate. Maintainers should notice that every concern has a predictable home.

That outcome does not require a large framework. It requires a few boundaries that are consistently respected: routes own URLs, content owns authored material, configuration owns identity, layouts own the document, and the theme owns presentation. Once those boundaries hold, growth becomes much less dramatic.

Written bySabbir Hosen Mamun

Academic • Programmer • Linux Enthusiast