Type something to search...
How to nest layouts in Astro?

How to nest layouts in Astro?

To nest layouts in Astro, make the inner layout render the outer layout in its template and put its own <slot /> inside it. For example, a BlogPostLayout imports BaseLayout, wraps its article markup in <BaseLayout title={title}>...</BaseLayout>, and places a <slot /> inside the article. A page then uses only BlogPostLayout, and gets the base page shell automatically. Props the outer layout needs are passed down like any component props, and named slots such as head are forwarded with <slot name="head" slot="head" />.

Nesting is how you avoid copying the page shell into every specialised layout. A typical site has one base layout for <html>, <head>, navigation and footer, then thinner layouts on top for blog posts, documentation pages or landing pages. In this article you'll build a three-level chain, forward props without repeating their types using ComponentProps, transfer named slots through each level, nest a Markdown layout, and handle the empty-slot problem that appears when slots pass through more than one component.

The Idea: Layouts Are Just Components

Nothing special happens when layouts nest. A layout is an Astro component with a <slot />, so one layout can use another the same way a page uses a layout. If you haven't built a base layout yet, start with how to create reusable layouts in Astro. This article assumes a BaseLayout like this one:

---
// 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>

The chain we'll build looks like this:

TutorialLayout  (level badge)
  └─ ArticleLayout  (title, author, date, optional aside)
       └─ BaseLayout  (html, head, header, footer)

Content flows inward: the page fills TutorialLayout's slots, TutorialLayout fills ArticleLayout's, and ArticleLayout fills BaseLayout's.

Level Two: An Article Layout

The article layout renders BaseLayout and adds article-specific structure inside it:

---
// src/layouts/ArticleLayout.astro
import type { ComponentProps } from "astro/types";
import BaseLayout from "./BaseLayout.astro";

type Props = ComponentProps<typeof BaseLayout> & {
  author: string;
  pubDate: Date;
};

const { author, pubDate, ...baseProps } = Astro.props;
---
<BaseLayout {...baseProps}>
  <slot name="head" slot="head" />
  <article class="article">
    <header>
      <h1>{baseProps.title}</h1>
      <p>By {author} on {pubDate.toLocaleDateString("en-GB")}</p>
    </header>
    <slot />
    {Astro.slots.has("aside") && (
      <aside>
        <slot name="aside" />
      </aside>
    )}
  </article>
</BaseLayout>

<style>
  .article header {
    border-bottom: 1px solid #ddd;
  }
</style>

There are three techniques here worth unpacking.

Reusing the Outer Layout's Props

ComponentProps<typeof BaseLayout> from astro/types gives you the Props type of BaseLayout without exporting or copying it. The intersection adds the two props only this layout uses. If you later add an image prop to BaseLayout, ArticleLayout accepts it automatically.

In the script, author and pubDate are pulled out and everything else is collected into baseProps, which is spread onto BaseLayout. The article layout can still read baseProps.title for its own heading. More on these type helpers in how to type component props with TypeScript.

Forwarding a Named Slot

<slot name="head" slot="head" /> reads the head slot given to ArticleLayout and passes it on as the head slot of BaseLayout. Without this line, a page that adds <meta slot="head" ...> would lose that tag, because ArticleLayout has no head slot of its own and unmatched slot content is dropped without a warning.

Adding a New Slot

The aside slot is new at this level. Astro.slots.has("aside") checks whether anything was passed, so pages without an aside don't get an empty <aside> element.

Level Three: A Tutorial Layout

The tutorial layout adds a difficulty badge and passes everything else down:

---
// src/layouts/TutorialLayout.astro
import type { ComponentProps } from "astro/types";
import ArticleLayout from "./ArticleLayout.astro";

type Props = ComponentProps<typeof ArticleLayout> & {
  level: "beginner" | "intermediate" | "advanced";
};

const { level, ...articleProps } = Astro.props;
---
<ArticleLayout {...articleProps}>
  <slot name="head" slot="head" />
  <p class="level">Level: {level}</p>
  <slot />
  <slot name="aside" slot="aside" />
</ArticleLayout>

Notice that <p class="level"> and the default <slot /> both go into ArticleLayout's default slot, in that order. You can mix your own markup with forwarded content freely.

A page using it only needs one import:

---
// src/pages/tutorials/first-steps.astro
import TutorialLayout from "../../layouts/TutorialLayout.astro";
---
<TutorialLayout
  title="First steps"
  description="Your first Astro project."
  author="Sajjad"
  pubDate={new Date("2026-10-24")}
  level="beginner"
>
  <meta slot="head" name="robots" content="noindex" />
  <p>Install Astro and run the dev server.</p>
  <p slot="aside">Need help? Ask in the forum.</p>
</TutorialLayout>

I built this page, and every piece landed where expected. The robots tag travelled two levels to reach <head>, and the aside travelled two levels to reach the <article>:

<title>First steps | My Site</title><meta name="description" content="Your first Astro project."><meta name="robots" content="noindex">
...
<article class="article" data-astro-cid-sgg5joot><header data-astro-cid-sgg5joot><h1 data-astro-cid-sgg5joot>First steps</h1><p data-astro-cid-sgg5joot>By Sajjad on 24/10/2026</p></header><p class="level">Level: beginner</p><p>Install Astro and run the dev server.</p><aside data-astro-cid-sgg5joot><p>Need help? Ask in the forum.</p></aside></article>

Because the props types are chained with ComponentProps, astro check reports a missing title, author, pubDate or level on this page, even though three of those are declared in other files.

The Empty Slot Problem

Here's the catch with forwarded slots. I built a second tutorial page with no aside content:

<TutorialLayout title="No aside" author="Sajjad" pubDate={new Date("2026-10-24")} level="advanced">
  <p>Body</p>
</TutorialLayout>

The output still contained an empty aside:

<p class="level">Level: advanced</p><p>Body</p><aside data-astro-cid-sgg5joot></aside>

TutorialLayout always writes <slot name="aside" slot="aside" />, so from ArticleLayout's point of view an aside slot was always passed, and Astro.slots.has("aside") returned true. I also tried wrapping the forwarding line in {Astro.slots.has("aside") && ...} inside TutorialLayout, and in a <Fragment slot="aside">. Neither changed the result: the slot was still registered as present.

The reliable fixes are:

  • Hide empty wrappers with CSS: add aside:empty { display: none; } to the inner layout's styles. Astro 7's default whitespace handling leaves the element truly empty, so :empty matches.
  • Pass a flag as a prop: have the outer layout pass hasAside={Astro.slots.has("aside")} and use that instead of Astro.slots.has() in the inner one.
  • Don't wrap in the inner layout: let each layout that forwards the slot decide whether to render the wrapper, using its own Astro.slots.has(), which sees the real content.

The CSS route is the least code:

<style>
  .article header {
    border-bottom: 1px solid #ddd;
  }

  aside:empty {
    display: none;
  }
</style>

Avoid trying to detect emptiness with await Astro.slots.render("aside") in a layout. When I tested that approach, the rendered string picked up the page's <style> output, which then ended up inside the <aside> instead of the <head>.

Nesting Markdown Layouts

The layout frontmatter property for Markdown pages in src/pages/ works with nested layouts too. The Markdown layout receives frontmatter and headings, and passes what it needs to the next level:

---
// src/layouts/DocsMarkdownLayout.astro
import type { MarkdownLayoutProps } from "astro";
import ArticleLayout from "./ArticleLayout.astro";

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

const { frontmatter, headings } = Astro.props;
const toc = headings.filter((heading) => heading.depth === 2);
---
<ArticleLayout
  title={frontmatter.title}
  description={frontmatter.description}
  author={frontmatter.author}
  pubDate={new Date(frontmatter.pubDate)}
>
  <slot />
  <nav slot="aside" aria-label="On this page">
    <ul>
      {toc.map((heading) => (
        <li><a href={`#${heading.slug}`}>{heading.text}</a></li>
      ))}
    </ul>
  </nav>
</ArticleLayout>
---
layout: ../../layouts/DocsMarkdownLayout.astro
title: "Setup"
description: "Set up the project."
author: "Sajjad"
pubDate: "2026-10-24"
---

## Install

Run the installer.

## Configure

Edit the config.

The built page has the Markdown content in the article and a generated table of contents in the aside:

<aside data-astro-cid-sgg5joot><nav aria-label="On this page"><ul><li><a href="#install">Install</a></li><li><a href="#configure">Configure</a></li></ul></nav></aside>

Frontmatter values are strings, so the date is converted with new Date() before being passed to ArticleLayout, which expects a Date. A fuller table of contents is covered in how to build a table of contents in Astro.

The same nesting works for content collections. Render the entry in a dynamic route with render() from astro:content, then wrap <Content /> in ArticleLayout or any layout built on it.

How Styles Combine

Each layout's <style> block is scoped to the elements written in that file. In the built page, BaseLayout's elements carry one data-astro-cid-* value and ArticleLayout's elements carry another, and the CSS from both is bundled into the page. That means:

  • Outer layouts can't style inner layout markup with scoped selectors, and vice versa.
  • Neither can style page content that arrives through a slot, unless you use :global() or a global stylesheet.
  • Order isn't something to rely on: write selectors that don't depend on which layout's CSS appears first.

Keep site-wide rules in a global stylesheet imported by the base layout, and keep each layout's scoped styles about its own structure. Scoped CSS in Astro explains the scoping rules in more detail.

How Deep Should You Nest?

Two or three levels covers almost every site: a base layout, a section layout (blog, docs, marketing) and occasionally a variant on top. Beyond that, each change needs tracing through several files, and every forwarded slot adds a line that's easy to forget.

Some guidelines:

  • Nest when the structure is shared: if several page types need the same article header, a shared ArticleLayout is worth it.
  • Use components when only a piece is shared: a reading-time badge or author box is better as a component that any layout can include.
  • Use props for small variations: a wide boolean on one layout is simpler than a separate WideLayout.
  • Forward every slot you expose: if the base layout has a head slot, every layout in between must forward it, or pages lose that ability.

FAQ: Nested Layouts in Astro

Yes. A layout is a regular Astro component, so it can import another layout and wrap its own markup in it. Place a slot element inside so the page's content still has somewhere to go.

Destructure the props the inner layout uses and collect the rest with a rest pattern, then spread them onto the outer layout. Type them with ComponentProps from astro/types so the outer layout's props are included automatically.

In each layout between the page and the target, write a slot element with both attributes, such as slot name="head" slot="head". This reads the incoming slot and forwards it to the next layout.

A forwarded slot always counts as present, so Astro.slots.has() returns true in the inner layout even when the page passed nothing. Hide the wrapper with the :empty selector or pass a boolean prop from the outer layout instead.

Yes. The layout set in a Markdown page's frontmatter receives frontmatter and headings, and can render any other layout, passing values from the frontmatter as props.

Two or three levels is usually enough: a base layout, a section layout and occasionally a variant. For smaller shared pieces, use components or props rather than another layout.


Conclusion

Nesting layouts in Astro means rendering one layout inside another. The inner layout wraps its markup in the outer layout, places a <slot /> for the page content, passes along the props the outer layout needs, and forwards any named slots with <slot name="x" slot="x" />. Typing each level with ComponentProps<typeof OuterLayout> keeps the whole chain checked from a single page.

The one behaviour to plan for is that forwarded slots always count as filled, so wrapper elements guarded by Astro.slots.has() can render empty; :empty or a boolean prop solves it. Keep the chain shallow, put site-wide styles in a global stylesheet, and use components for small shared pieces. With that, a single base layout can support every page type on your site.

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