Type something to search...
How to create reusable layouts in Astro?

How to create reusable layouts in Astro?

To create a reusable layout in Astro, make an ordinary Astro component, conventionally in src/layouts/, that contains the shared page shell (<html>, <head>, <body>, header and footer) and a <slot /> where each page's content should go. Pages then import the layout and wrap their content in it, for example <BaseLayout title="About">...</BaseLayout>, passing page-specific values such as the title and description as props. There's nothing special about a layout file: it's a component that happens to render a whole page around its children.

Without a layout, every page repeats the same <head> tags, navigation and footer, and a change to any of them means editing every file. A layout puts that markup in one place. In this article you'll build a typed BaseLayout step by step, add a named slot for extra <head> tags, see how styles behave inside layouts, use the same layout for Markdown pages with the layout frontmatter property, render content collection entries through a layout, and avoid the mistakes that come up most often.

A Minimal Layout

Here's the smallest useful layout:

---
// src/layouts/BaseLayout.astro
const { title } = Astro.props;
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{title}</title>
  </head>
  <body>
    <slot />
  </body>
</html>

And a page that uses it:

---
// src/pages/index.astro
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout title="Home">
  <h1>Hello!</h1>
  <p>This page is wrapped in the shared layout.</p>
</BaseLayout>

The page's <h1> and <p> are rendered where the layout's <slot /> sits. The title prop fills the <title> element. If slots are new to you, what slots are and how to use named slots explains them in detail.

Two rules apply when a layout contains a full page shell:

  • <html> must be the outermost element: everything else in the template goes inside it. Astro adds <!DOCTYPE html> for you.
  • Close every tag: Astro 7's Rust compiler no longer accepts unclosed non-void elements. A missing </main> or </body> is now a build error rather than something silently fixed.

A Production-Ready BaseLayout

A real layout usually needs a description, a navigation bar, a footer, global CSS and room for page-specific <head> tags. Here's a fuller version with typed props:

---
// src/layouts/BaseLayout.astro
import Header from "../components/Header.astro";
import "../styles/global.css";

interface Props {
  title: string;
  description?: string;
}

const { title, description = "A site built with Astro." } = Astro.props;
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{title} | My Site</title>
    <meta name="description" content={description} />
    <slot name="head" />
  </head>
  <body>
    <Header />
    <main>
      <slot />
    </main>
    <footer>
      <p>&copy; {new Date().getFullYear()} My Site</p>
    </footer>
  </body>
</html>

<style>
  main {
    max-width: 65ch;
    margin-inline: auto;
    padding: 2rem 1rem;
  }
</style>

Here's what each part does:

  • The Props interface: title is required and description has a default. Any page that forgets the title gets an error in the editor and from astro check. More on this in how to type component props with TypeScript.
  • The global stylesheet import: importing a CSS file in the frontmatter adds it to every page that uses the layout. Astro bundles and optimises it.
  • The head slot: a named slot inside <head> lets individual pages add a canonical URL, extra meta tags or a page-specific script without the layout needing a prop for each one.
  • The default slot inside <main>: page content goes here, so every page gets the same landmark structure.
  • The scoped style: rules in a <style> block apply only to elements written in this file.

A Header with an Active Link

Layouts are a natural place for navigation. Astro.url gives you the current URL, so the header can mark the current page:

---
// src/components/Header.astro
const { pathname } = Astro.url;

const links = [
  { href: "/", label: "Home" },
  { href: "/about/", label: "About" },
];
---
<header>
  <nav>
    {links.map((link) => (
      <a
        href={link.href}
        aria-current={pathname === link.href ? "page" : undefined}
      >
        {link.label}
      </a>
    ))}
  </nav>
</header>

Because the header is its own component, it can be reused in a different layout later.

Using the Head Slot

A page fills the head slot by putting slot="head" on any element:

---
// src/pages/about.astro
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout title="About">
  <link slot="head" rel="canonical" href="https://example.com/about/" />
  <h1>About</h1>
</BaseLayout>

The built page puts the canonical link inside <head>, straight after the description:

<title>About | My Site</title><meta name="description" content="A site built with Astro."><link rel="canonical" href="https://example.com/about/">

Note that the description fell back to the default because the page didn't pass one. For several head elements, wrap them in <Fragment slot="head">.

How Styles Behave in Layouts

Scoped styles in a layout only reach elements written in the layout file. In the built about page, <main> received a data-astro-cid-* attribute and the CSS selector was rewritten to match it, but the page's <h1>, which came through the slot, did not. So you can't style page content from a layout's scoped <style> block with a plain h1 selector.

You have three options:

ApproachWhen to use it
Import a global CSS file in the layoutSite-wide typography, resets and design tokens
<style is:global> in the layoutA few global rules you want to keep next to the layout
:global() inside a scoped styleStyling slotted content within a scoped parent, such as main :global(h1)

For example:

<style>
  main :global(h1) {
    font-size: 2.5rem;
  }
</style>

This targets any <h1> inside this layout's <main>, including ones that arrive through the slot, without leaking to other layouts. Styling strategies are covered in scoped CSS in Astro and global styles in Astro.

Layouts for Markdown Pages

Markdown and MDX files in src/pages/ become pages on their own, but without a layout they have no <html> shell, no styles and no navigation. The special layout frontmatter property tells Astro which component to wrap them in:

---
# src/pages/notes.md
layout: ../layouts/MarkdownLayout.astro
title: "Release notes"
description: "What changed this month."
pubDate: "2026-10-01"
---

## New features

Some **Markdown** content.

## Fixes

More text.

The layout receives the file's data on Astro.props, including frontmatter, headings and url, and the rendered Markdown goes into its default slot. The MarkdownLayoutProps helper types it:

---
// src/layouts/MarkdownLayout.astro
import type { MarkdownLayoutProps } from "astro";
import BaseLayout from "./BaseLayout.astro";

type Props = MarkdownLayoutProps<{
  title: string;
  description: string;
  pubDate: string;
}>;

const { frontmatter, headings } = Astro.props;
---
<BaseLayout title={frontmatter.title} description={frontmatter.description}>
  <article>
    <h1>{frontmatter.title}</h1>
    <p>Published {frontmatter.pubDate}</p>
    <p>{headings.length} sections</p>
    <slot />
  </article>
</BaseLayout>

This layout reuses BaseLayout rather than repeating the page shell, which is the idea behind nesting layouts in Astro. In the built page, Astro added id attributes to the two <h2> headings (new-features and fixes), and headings contained both of them.

Remember that layout is only recognised for Markdown and MDX files in src/pages/. It has no effect on files in a content collection.

Layouts for Content Collections

For blog posts and docs, content collections are the more common choice. Here you render the entry yourself in a dynamic route and wrap it in a layout. First, a layout that accepts a whole collection entry:

---
// src/layouts/PostLayout.astro
import type { CollectionEntry } from "astro:content";
import BaseLayout from "./BaseLayout.astro";

interface Props {
  post: CollectionEntry<"blog">;
}

const { post } = Astro.props;
const { title, description, pubDate } = post.data;
---
<BaseLayout title={title} description={description}>
  <article>
    <h1>{title}</h1>
    <time datetime={pubDate.toISOString()}>
      {pubDate.toLocaleDateString("en-GB", { dateStyle: "long" })}
    </time>
    <slot />
  </article>
</BaseLayout>

Then the route that renders each entry:

---
// src/pages/blog/[id].astro
import { getCollection, render } from "astro:content";
import PostLayout from "../../layouts/PostLayout.astro";

export async function getStaticPaths() {
  const posts = await getCollection("blog");
  return posts.map((post) => ({
    params: { id: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---
<PostLayout post={post}>
  <Content />
</PostLayout>

This uses the current content collections API: entries have an id (not the old slug), and you render them with the render() function from astro:content (not entry.render()). The collection itself is defined in src/content.config.ts with a loader:

// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";

const blog = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
  }),
});

export const collections = { blog };

Passing the whole entry keeps the layout's props short and fully typed: CollectionEntry<"blog"> knows every field in your schema. Collections are covered in depth in content collections in Astro.

Where to Put Layouts

src/layouts/ is a convention, not a requirement. Astro doesn't treat that folder specially, so you can organise layouts however you like. The only folder with special behaviour is src/pages/, where every .astro file becomes a route. If you want to keep a layout next to the pages that use it, prefix its file name with an underscore, such as src/pages/blog/_PostLayout.astro, and Astro won't turn it into a page.

Common Mistakes

  1. Forgetting the slot: a layout without <slot /> renders its shell and silently drops the page content.
  2. Duplicating the page shell: if a page wraps itself in a layout and also writes its own <html> and <body>, you get nested documents. The page should contain only its content.
  3. Missing the charset tag: Astro won't add <meta charset="utf-8"> for you, including on Markdown and MDX pages that use a layout, so put it in the layout's <head>.
  4. Expecting the layout's scoped CSS to style page content: use a global stylesheet or :global() for slotted elements.
  5. Using layout frontmatter in a collection: it only works for files in src/pages/. Collections are rendered through a route and a layout component instead.

FAQ: Reusable Layouts in Astro

A layout is a regular Astro component that provides shared page structure, such as the html, head and body tags, navigation and footer, with a slot element where each page's content is inserted.

No. src/layouts is only a convention. Layouts can live anywhere, and if you keep one inside src/pages, prefix the file name with an underscore so Astro doesn't treat it as a page.

Pass it as a prop, such as title="About", and read it in the layout from Astro.props. Add a Props interface to the layout so a missing title is reported by your editor and astro check.

Add a named slot inside the layout's head element, such as slot name="head", then put slot="head" on the link or meta element in the page. Use a Fragment with slot="head" for several elements.

For Markdown or MDX files in src/pages, set the layout frontmatter property to the path of an Astro layout. For content collections, render the entry in a dynamic route and wrap the Content component in your layout.

Styles in a layout's style block are scoped to elements written in the layout file. Content passed through the slot belongs to the page, so use a global stylesheet, is:global or the :global() selector for it.


Conclusion

A reusable layout in Astro is a component with a page shell and a <slot />. Give it typed props for the values that change from page to page, a named head slot for one-off tags, a header and footer built from smaller components, and a global stylesheet for site-wide rules. Every page then shrinks to its own content wrapped in a single layout tag.

The same layout serves Markdown pages through the layout frontmatter property and content collections through a dynamic route that calls render(). Keep in mind that scoped styles don't reach slotted content, that Astro 7 requires every tag to be closed, and that src/layouts/ is just a convention. When one layout isn't enough, compose several by nesting them, building specialised layouts on top of a single base.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper