How to Simplify Content Creation with Astro Layouts


In “How to Refactor Astro for Better Code Maintainability,” I explained how to simplify code to make maintenance easier. But we can do more to make our lives easier when creating content.

For instance, right now, we’re still required to touch a certain amount of code to create pages, when ideally, we should be able to focus solely on the content.

Note! I’m not saying there should only be pure content on the pages. What I mean is that it shouldn’t be necessary to touch code to create a new page.

That’s why in this article, we’re going to focus on simplifying page code to the point where we can create a page with just Markdown content.

One layout to Rule Them All

When I mentioned duplicate code in the previous article, I used the example of the base structure of the website’s HTML:

---
import Head from '@components/Head.astro';
import Header from '@components/Header.astro';
import Footer from '@components/Footer.astro';
---

<!doctype html>
<html lang="en">
    <Head title={...} description={...}>
        ...
    </Head>
    <body>
        <Header />
        <main>
            ...
        </main>
        <Footer />
    </body>
</html>

We can extract this into a generic layout to avoid duplicating it every time we want to create a new page, file, or type of content.

To do this, we need to consider a few things:

1. The parts I replaced with ellipses (…),

will need to be replaced with parameters or Astro slots.

In this case, the parameters of Head will be passed as props, and the ellipses under Head and inside main will become slots. Since there can only be one anonymous slot, we’ll name the slot in the head.

After the changes, the code looks like this:

---
import Head from '@components/Head.astro';
import Header from '@components/Header.astro';
import Footer from '@components/Footer.astro';

type Props = {
    description: string;
    title: string;
}

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

<!doctype html>
<html lang="en">
    <Head
        description={description}
        title={title}
    >
        <slot name="head" />
    </Head>
    <body>
        <Header />
        <main>
            <slot />
        </main>
        <Footer />
    </body>
</html>

Now, save this code in a new file called src/layouts/MainLayout.astro and refactor:

1. src/layouts/PageContent.astro, which will now look like this:

---
import type { CollectionEntry } from 'astro:content';

import Date from '@components/Date.astro';
import MainLayout from '@layouts/MainLayout.astro';
import type { Collection } from '@/types/collections.d';

type Props = CollectionEntry<Collection>['data'];
const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
---
<MainLayout
    description={description}
    title={title}
>
    <style slot="head">
        ...
    </style>
    <article>
        <div class="hero-image">
            {heroImage && <img width={1020} height={510} src={heroImage} alt="" />}
        </div>
        <div class="prose">
            <div class="title">
                <div class="date">
                    <Date date={pubDate} />
                    {updatedDate && (
                        <div class="last-updated-on">
                            Last updated on <Date date={updatedDate} />
                        </div>
                    )}
                </div>
                <h1>{title}</h1>
                <hr />
            </div>
            <slot />
        </div>
    </article>
</MainLayout>

2. src/pages/blog/index.astro, which will now look like this:

---
import { getCollection } from 'astro:content';

import Date from '@/components/Date.astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '@/consts';
import MainLayout from '@/layouts/MainLayout.astro';
import type { Collection, Post } from '@/types/collections.d';

const collection: Collection = "blog";

const posts: Post[] = (await getCollection(collection)).sort(
    (a: Post, b: Post) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<MainLayout
    description={SITE_DESCRIPTION}
    title={SITE_TITLE}
>
    <style slot="head">
        ...
    </style>
    <section>
        <ul>
            {posts.map((post) => (
                <li>
                    <a href={`/blog/${post.slug}/`}>
                        <img width={720} height={360} src={post.data.heroImage} alt="" />
                        <h4 class="title">{post.data.title}</h4>
                        <p class="date">
                            <Date date={post.data.pubDate} />
                        </p>
                    </a>
                </li>
            ))}
        </ul>
    </section>
</MainLayout>

3. src/pages/index.astro, which will now look like this:

---
import { SITE_TITLE, SITE_DESCRIPTION } from '@/consts';
import MainLayout from '@/layouts/MainLayout.astro';
---
<MainLayout
    description={SITE_DESCRIPTION}
    title={SITE_TITLE}
>
    <h1>🧑‍🚀 Hello, Astronaut!</h1>
    <p>
        Welcome to the non-official <a href="https://astro.build/">Astro</a> blog starter template
        created by <a href="https://github.com/borjalofe">Borja LoFe</a>. This template serves as a 
        lightweight, minimally-styled starting point for anyone looking to build a personal website,
        blog, or portfolio with Astro.
    </p>
    <p>
        This template comes with a few integrations already configured in your
        <code>astro.config.mjs</code> file. You can customize your setup with
        <a href="https://astro.build/integrations">Astro Integrations</a> to add tools like Tailwind,
        React, or Vue to your project.
    </p>
    <p>Here are a few ideas on how to get started with the template:</p>
    <ul>
        <li>Edit this page in <code>src/pages/index.astro</code></li>
        <li>Edit the site header items in <code>src/components/Header.astro</code></li>
        <li>Add your name to the footer in <code>src/components/Footer.astro</code></li>
        <li>Check out the included blog posts in <code>src/content/blog/</code></li>
        <li>Customize the blog post page layout in <code>src/layouts/BlogPost.astro</code></li>
    </ul>
    <p>
        Have fun! If you get stuck, remember to <a href="https://docs.astro.build/">read the docs</a>
        or <a href="https://github.com/borjalofe/astro-blog-template/issues">create an issue</a> to
        ask questions.
    </p>
</MainLayout>

2. The main tag

should contain only the specific content of the page (e.g., the content of the article), but we should be able to add secondary content before and/or after that tag. We can solve this in two ways:

  1. We don’t include main in this layout and remember to add it in every new layout, page component, etc. we create from now on.
  2. We create named slots to add content before and after main.

For convenience, I chose the second option, so I went to the file src/layouts/MainLayout.astro and added named slots.

After the changes, the code looks like this:

---
import Head from '@components/Head.astro';
import Header from '@components/Header.astro';
import Footer from '@components/Footer.astro';

type Props = {
    description: string;
    title: string;
}

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

<!doctype html>
<html lang="en">
    <Head
        description={description}
        title={title}
    >
        <slot name="head" />
    </Head>
    <body>
        <Header />
        <slot name="pre-content" />
        <main>
            <slot />
        </main>
        <slot name="post-content" />
        <Footer />
    </body>
</html>

Writing Without Worrying About Code

Right now, pages are Astro (*.astro) files where we always need to remember to add the content within the PageContent tag.

Also, if we wanted to export the content, we would have to remove the Astro code to do so.

Between the possible forgetfulness and the need to clean up for export, wouldn’t it be better to avoid code (Astro or otherwise) in content pages?

For me, yes.

Moreover, reducing content files to Markdown does not prevent us from using MDX (Markdown + XML (e.g., JSX)) in the future.

However, this change means we need to manage how we pass the necessary parameters to the Astro files, especially when we are using TypeScript and it requires a type for this.

Inside src/types/common.frontmatter.d.ts

We create the file src/types/common.frontmatter.d.ts with the Frontmatter properties that can be common to all content files (hence the name “Common Frontmatter”):

## Writing Without Worrying About Code

Right now, pages are Astro (`*.astro`) files where we always need to remember to add the content within the `PageContent` tag.

Also, if we wanted to export the content, we would have to remove the Astro code to do so.

Between the possible forgetfulness and the need to clean up for export, wouldn't it be better to avoid code (Astro or otherwise) in content pages?

For me, yes.

Moreover, reducing content files to Markdown does not prevent us from using MDX (Markdown + XML (e.g., JSX)) in the future.

However, this change means we need to manage how we pass the necessary parameters to the Astro files, especially when we are using TypeScript and it requires a type for this.

### Inside `src/types/common.frontmatter.d.ts`

We create the file `src/types/common.frontmatter.d.ts` with the Frontmatter properties that can be common to all content files (hence the name "Common Frontmatter"):

You can see that the data common to all content includes:

  1. title,
  2. description,
  3. cover image (or featured image in some systems), optional

Layouts Will Accept a Prop with the Newly Created frontmatter

Actually, Astro already manages that you can access the frontmatter of a Markdown file via a frontmatter prop.

We will take advantage of this to pass our own frontmatter.

In fact, since in our src/layouts/MainLayout.astro all frontmatter properties are passed to the src/components/Head.astro component, we will modify that component from:

---
// Import the global.css file here so that it is included on
// all pages through the use of the <Head /> component.
import '@styles/global.css';

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

const canonicalURL = new URL(Astro.url.pathname, Astro.site);

const { title, description, image = '/blog-placeholder-1.jpg' } = Astro.props;
---

...

to:

---
// Import the global.css file here so that it is included on
// all pages through the use of the <Head /> component.
import '@styles/global.css';
import { type CommonFrontmatter } from '@/types/common.frontmatter';

type Props = {
    frontmatter: CommonFrontmatter,
}  

const {
    frontmatter: {
        description,
        title,
        heroImage,
    }
} = Astro.props;

const image = heroImage ?? '/blog-placeholder-1.jpg';

const canonicalURL = new URL(Astro.url.pathname, Astro.site);
---

...

This means changing src/layouts/MainLayout.astro from:

---
import Head from '@components/Head.astro';
import Header from '@components/Header.astro';
import Footer from '@components/Footer.astro';

type Props = {
    description: string;
    title: string;
}

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

<!doctype html>
<html lang="en">
    <Head
        description={description}
        title={title}
    >
    ...

to:

---
import Head from '@components/Head.astro';
import Header from '@components/Header.astro';
import Footer from '@components/Footer.astro';
import { type CommonFrontmatter } from '@/types/common.frontmatter';

type Props = {
    frontmatter: CommonFrontmatter,
}

const { frontmatter } = Astro.props;
---

<!doctype html>
<html lang="en">
    <Head
        frontmatter={frontmatter}
    >
    ...

We will also need to change src/layouts/PageContent.astro from:

---
...
import type { Collection } from '@/types/collections.d';

type Props = CollectionEntry<Collection>['data'];

const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
---
<MainLayout
    description={description}
    title={title}
>
    ...

to:

---
...
import type { Collection } from '@/types/collections.d';
import { type CommonFrontmatter } from '@/types/common.frontmatter';

type Props = CollectionEntry<Collection>['data'];

const { title, description, pubDate, updatedDate, heroImage } = Astro.props;  

const frontmatter: CommonFrontmatter = {
    title,
    description,
}
---
<MainLayout
    frontmatter={frontmatter}
>
    ...

Finally, we will need to change the file src/pages/about.astro, which uses the previous layout, from:

---
import PageContent from '@/layouts/PageContent.astro';
---

<PageContent
    title="About Me"
    description="Lorem ipsum dolor sit amet"
    pubDate={new Date('August 08 2021')}
    heroImage="/blog-placeholder-about.jpg"
>

to:

---
import type { CollectionEntry } from 'astro:content';

import PageContent from '@/layouts/PageContent.astro';
import type { Collection } from '@/types/collections.d';

const props: CollectionEntry<Collection>['data'] = {
    title: 'About Me',
    description: 'Lorem ipsum dolor sit amet',
    heroImage: '/blog-placeholder-about.jpg',
    pubDate: new Date('August 08 2021'),
};
---

<PageContent
    {...props}
>

A Small Reflection

We are using a template that is designed for collection content (in this case, the blog) for a page (which doesn’t belong to any collection). In fact, do we need the publication date of the page? Does it matter when a “Home,” “About,” or “Contact” page was published?

Because it’s important to know when an article was published/updated, but for a page?

That’s why we’re going to split src/layouts/PageContent.astro into two layouts:

  • src/layouts/PageLayout.astro will handle pages (i.e., files in src/pages),
  • src/layouts/PostLayout.astro will handle collection articles (i.e., files in src/content),

The only differences right now are:

  • the content managed by src/layouts/PostLayout.astro will be rendered within the article tag,
  • src/layouts/PostLayout.astro will show publication and update dates,
  • src/layouts/PageLayout.astro will not show the page title configured in the frontmatter,
  • src/layout/PageLayout.astro will have a single prop called frontmatter of type CommonFrontmatter, while src/layout/PostLayout.astro will retain the current props of type CollectionEntry<Collection>['data'],

Additionally, this means there will no longer be files using src/layouts/MainLayout.astro as a layout, so we’ll move it to the src/components folder and, since it’s no longer a layout, we’ll rename it HTML.astro.

After the changes, src/layouts/PostLayout.astro will look like this:

---
import type { CollectionEntry } from 'astro:content';

import Date from '@components/Date.astro';
import HTML from '@components/HTML.astro';
import type { Collection } from '@/types/collections.d';
import { type CommonFrontmatter } from '@/types/common.frontmatter';

type Props = CollectionEntry<Collection>['data'];

const { title, description, pubDate, updatedDate, heroImage } = Astro.props;

const frontmatter: CommonFrontmatter = {
	title,
	description,
}
---

<HTML
    frontmatter={frontmatter}
>
    <article class="prose">
        <div class="hero-image">
            {heroImage && <img width={1020} height={510} src={heroImage} alt="" />}
        </div>
        <div class="title">
            <div class="date">
                <Date date={pubDate} />
                {updatedDate && (
                    <div class="last-updated-on">
                        Last updated on <Date date={updatedDate} />
                    </div>
                )}
            </div>
            <h1>{title}</h1>
            <hr />
        </div>
        <slot />
    </article>
    <style>
        .hero-image {
            width: 100%;
        }
        .hero-image img {
            display: block;
            margin: 0 auto;
            border-radius: 12px;
            box-shadow: var(--box-shadow);
        }
        .prose {
            width: 720px;
            max-width: calc(100% - 2em);
            margin: auto;
            padding: 1em;
            color: rgb(var(--gray-dark));
        }
        .title {
            margin-bottom: 1em;
            padding: 1em 0;
            text-align: center;
            line-height: 1;
        }
        .title h1 {
            margin: 0 0 0.5em 0;
        }
        .date {
            margin-bottom: 0.5em;
            color: rgb(var(--gray));
        }
        .last-updated-on {
            font-style: italic;
        }
    </style>
</HTML>

And src/layouts/PageLayout.astro like this:

---
import HTML from '@components/HTML.astro';
import { type CommonFrontmatter } from '@/types/common.frontmatter';

type Props = {
    frontmatter: CommonFrontmatter;
};

const { frontmatter } = Astro.props;
const { heroImage } = frontmatter;
---

<HTML
    frontmatter={frontmatter}
>
    <div class="prose">
        <div class="hero-image">
            {heroImage && <img width={1020} height={510} src={heroImage} alt="" />}
        </div>
        <slot />
    </div>
    <style>
        .hero-image {
            width: 100%;
        }
        .hero-image img {
            display: block;
            margin: 0 auto;
            border-radius: 12px;
            box-shadow: var(--box-shadow);
        }
        .prose {
            width: 720px;
            max-width: calc(100% - 2em);
            margin: auto;
            padding: 1em;
            color: rgb(var(--gray-dark));
        }
    </style>
</HTML>

Removing Code from Pages and Posts

With the changes we’ve made, we can make all pages become Markdown or MDX files. The simplest case is the page src/pages/about.astro. If we change the extension to .md and remove the code, it will look like this:

---
title: About Me
description: Lorem ipsum dolor sit amet
heroImage: /blog-placeholder-about.jpg
---

Lorem ipsum...

Of course, this does not apply any layout, but fortunately, Astro has us covered: just add the layout attribute to the frontmatter with the layout you want to apply, and that will be enough to fix it.

After this change, the file will look like this:

---
title: About Me
description: Lorem ipsum dolor sit amet
layout: '@layouts/PageLayout.astro'
heroImage: /blog-placeholder-about.jpg
---

Lorem ipsum...

And it will display as expected.

The Homepage Is a Special Case

In general, on the homepage, we want the title and description that appear in the meta tags and head tags to be those of the website itself.

We could add code to src/layouts/PageLayout.astro to handle this case, but it’s easier to create a new layout and manage it:

  1. create src/layouts/HomepageLayout.astro,
  2. add the following code to the file:
    ---
    import { SITE_TITLE, SITE_DESCRIPTION } from '@/consts';
    import PageLayout from '@layouts/PageLayout.astro';
    import { type CommonFrontmatter } from '@/types/common.frontmatter';
    
    type Props = {
        frontmatter: CommonFrontmatter;
    };
    
    const { frontmatter } = Astro.props;
    
    const homeFrontmatter: CommonFrontmatter = {
        ...frontmatter,
        title: SITE_TITLE,
        description: SITE_DESCRIPTION,
    };
    ---
    
    <PageLayout
        frontmatter={homeFrontmatter}
    >
        <slot />
    </PageLayout>
    
  3. refactor src/pages/index.astro as src/pages/index.md and, after removing the code, it will look like this:
    ---
    title: Home
    description: This is the homepage
    layout: '@layouts/HomepageLayout.astro'
    ---
    
    # 🧑‍🚀 Hello, Astronaut!
    
    Welcome to the non-official [Astro](https://astro.build/) blog starter template created by [Borja LoFe](https://github.com/borjalofe). This template serves as a lightweight, minimally-styled starting point for anyone looking to build a personal website, blog, or portfolio with Astro.
    
    This template comes with a few integrations already configured in your `astro.config.mjs` file. You can customize your setup with [Astro Integrations](https://astro.build/integrations) to add tools like Tailwind, React, or Vue to your project.
    
    Here are a few ideas on how to get started with the template:
    
    - Edit this page in `src/pages/index.astro`
    - Edit the site header items in `src/components/Header.astro`
    - Add your name to the footer in `src/components/Footer.astro`
    - Check out the included blog posts in `src/content/blog/`
    - Customize the blog post page layout in `src/layouts/BlogPost.astro`
    
    Have fun! If you get stuck, remember to [read the docs](https://docs.astro.build/) or [create an issue](https://github.com/borjalofe/astro-blog-template/issues) to ask questions.
    

### The Not-So-Special Case of the Blog Page

The blog page is essentially an archive page for the `blog` collection... and we can have many other collections, each with its own archive.

So, we’re going to create a new type and layout to manage the archives.

We’ll need the type to define which collection will be rendered in the archive.

#### Frontmatter for Archive

Since we want to define the pages with basic Markdown (without dismissing MDX), all the archive configuration should be passed as frontmatter properties.

For this, we’ll create the file `src/types/archive.frontmatter.ts` and add the following code to it:

```typescript
import type { Collection } from '@/types/collections.d';
import type { CommonFrontmatter } from "@/types/common.frontmatter";

export type ArchiveFrontmatter = CommonFrontmatter & {
    collection: Collection;
}

Note:

Unlike interfaces (which only allow extending from them), types allow group operations to create new types.

That’s why, if you look at the definition of Collection, right now it only includes “blog”, but we can add new collections with the or operator (represented by the vertical bar |) so we set the options to choose from for a collection.

In the case of the ArchiveFrontmatter type, we are using the and operator to generate a union of types. This means that ArchiveFrontmatter has everything that CommonFrontmatter has and, in addition, the collection property.

This way, we can easily generate new types.

Layout for Archive

We’re going to create the layout for the archive (in the file src/layouts/ArchiveLayout.astro) following the same logic as for creating the homepage layout: importing the page layout and adding what is specific to the archive.

So, create the file src/layout/ArchiveLayout.astro and write the following code in it:

---
import { getCollection } from 'astro:content';

import Date from '@components/Date.astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '@/consts';
import PageLayout from '@layouts/PageLayout.astro';
import type { Post } from '@/types/collections.d';
import { type ArchiveFrontmatter } from '@/types/archive.frontmatter';
import { type CommonFrontmatter } from '@/types/common.frontmatter';

type Props = {
    frontmatter: ArchiveFrontmatter;
};

const { frontmatter } = Astro.props;

const homeFrontmatter: CommonFrontmatter = {
    ...frontmatter,
    title: SITE_TITLE,
    description: SITE_DESCRIPTION,
};

const { collection } = frontmatter;

const posts: Post[] = (await getCollection(collection)).sort(
    (a: Post, b: Post) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---

<PageLayout
    frontmatter={homeFrontmatter}
>
    <section>
        <ul>
            {
                posts.map((post) => (
                    <li>
                        <a href={`/blog/${post.slug}/`}>
                            <img width={720} height={360} src={post.data.heroImage} alt="" />
                            <h4 class="title">{post.data.title}</h4>
                            <p class="date">
                                <Date date={post.data.pubDate} />
                            </p>
                        </a>
                    </li>
                ))
            }
        </ul>
    </section>
    <style>
        ul {
            display: flex;
            flex-wrap: wrap;
            gap: 2rem;
            list-style-type: none;
            margin: 0;
            padding: 0;
        }
        ul li {
            width: calc(50% - 1rem);
        }
        ul li * {
            text-decoration: none;
            transition: 0.2s ease;
        }
        ul li:first-child {
            width: 100%;
            margin-bottom: 1rem;
            text-align: center;
        }
        ul li:first-child img {
            width: 100%;
        }
        ul li:first-child .title {
            font-size: 2.369rem;
        }
        ul li img {
            margin-bottom: 0.5rem;
            border-radius: 12px;
        }
        ul li a {
            display: block;
        }
        .title {
            margin: 0;
            color: rgb(var(--black));
            line-height: 1;
        }
        .date {
            margin: 0;
            color: rgb(var(--gray));
        }
        ul li a:hover h4,
        ul li a:hover .date {
            color: rgb(var(--accent));
        }
        ul a:hover img {
            box-shadow: var(--box-shadow);
        }
        @media (max-width: 720px) {
            ul {
                gap: 0.5em;
            }
            ul li {
                width: 100%;
                text-align: center;
            }
            ul li:first-child {
                margin-bottom: 0;
            }
            ul li:first-child .title {
                font-size: 1.563em;
            }
        }
    </style>
</PageLayout>

If you compare this code with that of src/pages/blog/index.astro, you’ll see there are only two differences:

  1. we’ve defined a frontmatter prop of the type we just created,
  2. and, there is no collection defined.

With this, we now have a completely generic archive layout for collections.

Blog Archive

Defining an archive for a collection is now super simple. We just need to create the index.md file in the src/pages/<collection>/ folder where <collection> is the name of the collection.

Since this case is the blog, we will replace the file src/pages/blog/index.astro with the file src/pages/blog/index.md, which will have the following code:

---
title: Blog
description: These are our blog's posts
layout: '@layouts/ArchiveLayout.astro'
collection: blog
---

Simple, right?

A Little Trick

Thanks for making it this far. In upcoming articles, I’ll continue explaining how to create a custom template.

But if you don’t want to create one, you can use mine (the one you’ll have, more or less, if you follow all the steps in this series of articles) with the following command:

yarn create astro -- --template borjalofe/astro-blog-template