Proper websites, done properly

Part 6: New components and tag pages

9 minute read time / 1128 words

We can make our code much more maintainable and re-usable by moving repeated parts into components. We can also use these on our new tags pages!

Before we can implement pagination, we need a couple more blog posts so let's quickly duplicate our existing one and make two new ones with unique page titles and filenames in the same folder.

Once you've done this, check your site and you should see all three posts appearing on the post listings page, and you should be able to click through to each page to see the full content.

Create new components

We've not even started to look at individual tag listing pages and we are already starting to see repeated code. Things like the date output for posts and the tags lists can and should be moved into their own components so we can change them once and have them update everywhere.

Create a new file at src/components/PostDateTime.astro and grab the code from the post listing page and update it like so:

---
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
    {date.toDateString()}
</time>

The tags list is the next obvious code we can make re-usable. Create another new file at src/components/TagList.astro and update our tag listing code:

---
const { tags } = Astro.props;
---
<p>Tagged as:</p>

<ul>
    {tags.map(tag => <li>{tag}</li>)}
</ul>

We're also using the image more than once, so let's go crazy and make a component out of that too. Create another file at src/components/PostImage.astro:

---
const { image } = Astro.props;
---
<img
    src={image.src}
    alt={image.alt}
    width={image.width}
    height={image.height}
/>

We might as well just go component crazy at this point. We're repeating our post list item here, and if this were a fully-fledged site, the chances are we might want to list posts on the homepage, or in a sidebar somewhere, or in a related posts component on a post content page... or even pass other types of content that might have the same schema contents but be different content types.

We can create ourselves a PostItem component that will import all the other components we need.

---
import PostDateTime from './PostDateTime.astro';
import TagList from './TagList.astro';
import PostImage from './PostImage.astro';

const { item } = Astro.props;

console.log(item);
---
<li>
    <PostImage image={item.data.image} />

    <a href={`/posts/${item.slug}`}>
        {item.data.title}
    </a>

    <PostDateTime date={item.data.publishedDate} />

    <p>
        {item.data.description}
    </p>

    <TagList tags={item.data.tags} />
</li>

Now we can import this component and implement it on both the post listings page and the post content page (and the tags listing page in future too!). Update our src/pages/posts/index.astro to the following:

---
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
import PostItem from '../../components/PostItem.astro';

const postEntries = await getCollection('posts', ({ data }) => {
    return data.isDraft !== true;
});
---
<Layout title="Blog posts">
    <ul>
        {postEntries.map(postEntry => (
            <PostItem item={postEntry} />
        ))}
    </ul>
</Layout>

And for the individual post page src/pages/posts/[...slug].astro:

---
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
import PostDateTime from '../../components/PostDateTime.astro';
import TagList from '../../components/TagList.astro';
import PostImage from '../../components/PostImage.astro';

export async function getStaticPaths() {
    const postEntries = await getCollection('posts', ({ data }) => {
        return data.isDraft !== true;
    });

    return postEntries.map(entry => ({
        params: { slug: entry.slug }, props: { entry },
    }));
}

const { entry } = Astro.props;
const { Content } = await entry.render();
---
<Layout title={entry.data.title}>
    <h1>{entry.data.title}</h1>
    <PostDateTime date={entry.data.publishedDate} />
    <PostImage image={entry.data.image} />
    <Content />
    <TagList tags={entry.data.tags} />
</Layout>

Create the tags pages

Using the guide from the official documentation about generating tag pages, we start by making a new file at src/pages/posts/tagged/[tag].astro

Most of this file is very similar to our post listings page, other than that it is a dynamic page that generates new pages in a similar way to our [...slug].astro file.

---
import { getCollection } from 'astro:content';
import Layout from '../../../layouts/Layout.astro';
import PostItem from '../../../components/PostItem.astro';

export async function getStaticPaths() {
    const postEntries = await getCollection('posts', ({ data }) => {
        return data.isDraft !== true;
    });

    const uniqueTags = [...new Set(postEntries.map(post => post.data.tags).flat())];

    return uniqueTags.map((tag) => {
        const filteredPosts = postEntries.filter(post => post.data.tags.includes(tag));

        return {
            params: { tag },
            props: { posts: filteredPosts },
        };
    });
}

const { tag } = Astro.params;
const { posts } = Astro.props;
---
<Layout pageTitle={tag}>
    <p>Posts tagged with {tag}</p>

    <ul>
        {posts.map(post => <PostItem item={post} />)}
    </ul>
</Layout>

Now you can visit pages for any tag you've set in your blog post pages. http://localhost:4321/posts/tagged/sexy for example.

But this isn't particularly useful to visitors as they'll have no idea what all your unique tags are. What we need is to link article tags to the relevant tag listing pages. We know the base URL for these, so now we can update our TagList component to link properly.

---
const { tags } = Astro.props;
---
<p>Tagged as:</p>

<ul>
    {tags.map(tag =>
        <li>
            <a href={`/posts/tagged/${tag}`}>{tag}</a>
        </li>
    )}
</ul>

And because we were sensible enough to create this as a re-usable component, you'll now find that all your post landing page, tag listing pages, and individual article pages all have their tags linked up nicely. WspaniaƂy!

We can even go one step further and pull out each tag item into a component too. Create a new file at src/components/TagItem.astro with the individual item code updated like so:

---
const { tag } = Astro.props;
---
<li>
    <a href={`/posts/tagged/${tag}`}>{tag}</a>
</li>

And then update our <TagList /> component to use that instead.

---
import TagItem from './TagItem.astro';

const { tags } = Astro.props;
---
<p>Tagged as:</p>

<ul>
    {tags.map(tag =>
        <TagItem tag={tag} />
    )}
</ul>

The Astro documentation also gives you the information you need to make a tag listing page too, should you want a page to list all your tags and link off to individual tag listing pages. We've got most of the functionality already in place, so we might as well add that too for completeness!

---
import { getCollection } from 'astro:content';
import Layout from '../../../layouts/Layout.astro';
import TagList from '../../../components/TagList.astro';

const postEntries = await getCollection('posts', ({ data }) => {
    return data.isDraft !== true;
});

const uniqueTags = [...new Set(postEntries.map(post => post.data.tags).flat())];
---
<Layout pageTitle="All tags">
    <TagList tags={uniqueTags} />
</Layout>

We've followed the same route to get all the unique tags used in our [tag].astro page but instead of using getStaticPaths() and looping through tags to create dynamic tag listings pages, we just get all our unique tags and loop through them to create a list of all tags linked to the correct tag listing page.