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:
- We don’t include
main
in thislayout
and remember to add it in every newlayout
, page component, etc. we create from now on. - We create named
slots
to add content before and aftermain
.
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:
- title,
- description,
- 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 insrc/pages
),src/layouts/PostLayout.astro
will handle collection articles (i.e., files insrc/content
),
The only differences right now are:
- the content managed by
src/layouts/PostLayout.astro
will be rendered within thearticle
tag, src/layouts/PostLayout.astro
will show publication and update dates,src/layouts/PageLayout.astro
will not show the page title configured in thefrontmatter
,src/layout/PageLayout.astro
will have a single prop calledfrontmatter
of typeCommonFrontmatter
, whilesrc/layout/PostLayout.astro
will retain the current props of typeCollectionEntry<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:
- create
src/layouts/HomepageLayout.astro
, - 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>
- refactor
src/pages/index.astro
assrc/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 theor
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 theand
operator to generate a union of types. This means thatArchiveFrontmatter
has everything thatCommonFrontmatter
has and, in addition, thecollection
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:
- we’ve defined a
frontmatter
prop of the type we just created, - 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