Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 18

Building Dynamic Page Templates

Recommended resources

Hello! Welcome back to the course.

In our last lesson, we built a scalable, paginated archive for your blog. Now, when a user clicks on a post title from that list, they need to be taken to a page that displays the full article. This is where dynamic page templates come in.

Today, your goal is to create dynamic page templates to render individual blog posts and service pages. We'll leverage Astro's file-based routing and Content Collections to generate a unique, static page for every entry in your collections. This is a fundamental pattern for building any content-driven site, and you'll use it for your blog, service descriptions, and case studies on the accounting website.

1. Understanding Dynamic Routes and getStaticPaths

So far, we've created static pages like about.astro and a paginated route blog/[page].astro. Now, we need a route that can handle any possible blog post, like /blog/my-first-post or /blog/tax-tips-2025.

Astro handles this with dynamic routes. You create a file with a special bracket syntax, for example, src/pages/blog/[slug].astro. The [slug] part is a placeholder. At build time, Astro runs a special function within this file called getStaticPaths to determine all the possible values for slug and generates a static HTML page for each one.

Your experience with JavaScript array methods like .map() will be very useful here, as getStaticPaths is all about transforming an array of content entries into an array of page routes.

The following video provides an excellent walkthrough of creating a dynamic route, implementing getStaticPaths, and rendering the content. It's the core of what we're doing today.

Astro 3.0 Crash Course (View Transitions, Image, Content Collections, and More!)

The video 'Astro 3.0 Crash Course' by James Q Quick demonstrates exactly how to build dynamic pages from a content collection. It clearly explains the role of getStaticPaths and how to render the final content.

Please watch the segment from 31:03 to 35:32. Focus on these three key concepts: The purpose of the getStaticPaths function. The structure of the object returned for each post (params and props). How the slug from the content collection is used to define the page's URL via the params object.

As you saw in the video, getStaticPaths must return an array of objects, where each object defines a page to be generated. The structure looks like this:

[
  {
    params: { slug: 'post-1-slug' }, // Determines the URL: /blog/post-1-slug
    props: { post: post1Object }     // Data passed to the page component
  },
  {
    params: { slug: 'post-2-slug' }, // Determines the URL: /blog/post-2-slug
    props: { post: post2Object }     // Data passed to the page component
  },
  // ...and so on for every post
]

2. Building the Blog Post Template

Let's put this theory into practice and build the template for a single blog post.

1. Create the Dynamic Route File:
Inside your src/pages/ directory, create a new folder named blog. Inside src/pages/blog/, create a new file named [slug].astro.

Your file structure should now look something like this:

src/
├── components/
├── content/
│   └── blog/
│       ├── post-1.mdx
│       └── post-2.mdx
├── layouts/
└── pages/
    └── blog/
        ├── [page].astro      // Your paginated list from the last lesson
        └── [slug].astro      // The new template for a single post

2. Implement the Page Template:
Now, open src/pages/blog/[slug].astro and add the following code. We'll break it down right after.

---
import { type CollectionEntry, getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import FormattedDate from '../../components/FormattedDate.astro'; // Let's assume you create this component

// 1. Generate routes for all blog posts
export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

// 2. Get the post for the current page from props
const { post } = Astro.props;
type Props = { post: CollectionEntry<'blog'> };

// 3. Get the <Content /> component to render the post body
const { Content } = await post.render();
---
<BaseLayout title={post.data.title} description={post.data.description}>
  <article>
    <div class="post-header">
      <h1>{post.data.title}</h1>
      <FormattedDate date={post.data.pubDate} />
      <p><em>By {post.data.author}</em></p>
    </div>
    
    <div class="prose">
      <Content />
    </div>
  </article>
</BaseLayout>

<style>
  .post-header {
    text-align: center;
    margin-bottom: 2rem;
  }
  .post-header h1 {
    font-size: 3rem;
    margin-bottom: 0.5rem;
  }
  /* The 'prose' class can be used with @tailwindcss/typography for nice default styling */
  .prose {
    max-width: 65ch;
    margin: auto;
    line-height: 1.6;
  }
</style>

Code Breakdown:

  1. getStaticPaths: We fetch all entries from the blog collection and map over them. For each post, we return an object telling Astro to create a page at /blog/{post.slug} and pass the entire post object as a prop to that page.
  2. Astro.props: We retrieve the post object that getStaticPaths provided for this specific page.
  3. post.render(): This is a key method on content collection entries. It's an async function that processes the Markdown/MDX body and returns a <Content /> component. This component, when rendered, will be the HTML of your article.
  4. Template: We use our BaseLayout and structure the page. We pull metadata like post.data.title directly from the frontmatter. The magic happens when we render <Content />, which injects the main body of our article.

For a clearer textual reference on this process, the following article is very helpful.

Getting Started With Astro's Content Collection API

The article 'Getting Started With Astro's Content Collection API' by Shawn.so provides a concise, code-first explanation of creating dynamic pages.

Read the section 'Rendering The Post Page'. Notice the similar structure: creating the file, implementing getStaticPaths, and using Astro.props to render the content. This reinforces the pattern we just implemented.

Test your understanding!

In our [slug].astro template, the code currently renders the post title, date, and author. Let's say your blog's frontmatter also includes an image field for a hero image (e.g., image: '/images/blog/my-post-hero.jpg').

How would you modify the template to display this image between the header and the main content? You can use a standard <img> tag for this.

Show answer

You would access post.data.image and add an <img> tag inside the <article> element. It's also good practice to check if the image exists before trying to render it.

---
// ... (rest of the frontmatter script)
---
<BaseLayout title={post.data.title} description={post.data.description}>
  <article>
    <div class="post-header">
      <h1>{post.data.title}</h1>
      <FormattedDate date={post.data.pubDate} />
      <p><em>By {post.data.author}</em></p>
    </div>

    {/* Add the hero image here */}
    {post.data.image && (
      <img src={post.data.image} alt="" class="hero-image" />
    )}
    
    <div class="prose">
      <Content />
    </div>
  </article>
</BaseLayout>

<style>
/* ... (existing styles) */
  .hero-image {
    width: 100%;
    max-height: 400px;
    object-fit: cover;
    margin-bottom: 2rem;
    border-radius: 8px;
  }
</style>

This demonstrates how you can easily use any piece of frontmatter data to build out your page template. Note that for a production site, you'd want to use Astro's <Image /> component for optimization, which we'll cover later.


3. Applying the Pattern to Service Pages

The beauty of this dynamic routing pattern is its reusability. Your goal is to build a site with services, case studies, and a blog. The process for creating pages for each is nearly identical.

Let's outline how you would create dynamic pages for your accounting firm's services.

  1. Ensure a services collection exists. In your src/content/config.ts, you would define a services collection with its own schema (e.g., title, summary, icon).
  2. Add content. Create a src/content/services/ folder and add MDX files like tax-planning.mdx, bookkeeping.mdx, etc.
  3. Create a new dynamic route. Create the file src/pages/services/[slug].astro.
  4. Implement the template. The code inside would be a direct adaptation of the blog post template:
// src/pages/services/[slug].astro
---
import { type CollectionEntry, getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';

export async function getStaticPaths() {
  const services = await getCollection('services'); // <-- Use the 'services' collection
  return services.map(service => ({
    params: { slug: service.slug },
    props: { service }, // <-- Pass 'service' as a prop
  }));
}

const { service } = Astro.props;
const { Content } = await service.render();
---
<BaseLayout title={service.data.title}>
  <section class="service-page">
    <h1>{service.data.title}</h1>
    <!-- You might have a different layout here -->
    <p class="summary">{service.data.summary}</p>
    
    <hr />
    
    <div class="prose">
      <Content />
    </div>
    
    <!-- Maybe a call-to-action component -->
  </section>
</BaseLayout>

<style>
/* Styles specific to a service page */
.service-page {
  /* ... */
}
</style>

As you can see, the core logic (getStaticPaths, Astro.props, render()) remains the same. Only the collection name and the surrounding HTML structure change. This powerful, repeatable pattern is key to building a maintainable and scalable website.


Conclusion

Excellent work today! You have now mastered one of Astro's most powerful features: generating dynamic, static pages from content collections. This is the foundation for displaying all the detailed content on your accounting website.

Key Takeaways:

  • Dynamic routes like [slug].astro allow you to generate pages programmatically.
  • The getStaticPaths function is the engine that drives this, defining which pages to build at build time.
  • getStaticPaths maps a content collection to an array of { params, props } objects, which define the URL and data for each page.
  • An individual content entry's body is rendered using the <Content /> component obtained from the async post.render() method.
  • This dynamic page pattern is highly reusable and can be applied to any content type, such as blog posts, services, or case studies.

Up Next:
You've created a template for blog posts and another for services. You might have noticed some repetition in their structure. In our next lesson, we will implement nested layouts for different content types. This will allow you to create a specific layout for all blog posts (e.g., one that always includes author info) and a different one for all services, all while inheriting from the main BaseLayout to keep your code DRY (Don't Repeat Yourself).