Skip to main content

Welcome to a Grasp lesson

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

Lesson 29

Building a Testimonials Section Component

Recommended resources

Hello again! It's great to see you back.

In our last lesson, you built a fantastic, reusable Card.astro component. You learned how to pass props for content and even for structural elements like the heading tag. We brought it to life on the homepage using mock data, which was a perfect way to test the component in isolation.

Today, we'll take the crucial next step towards a truly dynamic, content-driven website. Your goal for this lesson is to create a TestimonialsSection component that queries and renders testimonials. We will replace the mock data approach with Astro's powerful Content Collections API. This will allow you or your client to add, remove, or edit testimonials simply by changing content files, without ever touching the component code again—a core principle of the maintainable architecture you aim to build.

1. Defining the "Testimonials" Content Collection

First, we need a dedicated place to store and structure our testimonial data. Astro's Content Collections are designed for exactly this. Think of a collection as a type-safe database for your Markdown or JSON files.

We'll start by defining the "shape" of a testimonial. What information does every testimonial need?

  • The author's name
  • The author's role or company
  • The quote itself
  • Optionally, a profile picture

By defining this shape in a schema, Astro ensures all your testimonials are consistent and gives you helpful autocompletion in your editor.

Content collections - Astro Docs

The official Astro documentation provides a comprehensive guide to Content Collections. It's the best place to understand the core concepts of defining, querying, and rendering collections.

Please read the sections titled 'Defining Collections' and 'Defining the collection schema'. Focus on how the src/content/config.ts file is used and how a schema is defined using defineCollection and the z utility from Zod.

Action: Define the Testimonials Schema

Now, let's apply what you've just read.

  1. If it doesn't already exist, create a file at src/content/config.ts. This special file is where all your content collections are defined.
  2. Add the following code to define a collection named testimonials.
// src/content/config.ts
import { z, defineCollection } from 'astro:content';

// Define a `testimonials` collection
const testimonialsCollection = defineCollection({
  type: 'content', // 'content' for MD/MDX, 'data' for JSON/YAML
  schema: z.object({
    author: z.string(),
    role: z.string(),
    // We'll use the main body of the markdown file for the quote, so no 'quote' field is needed here.
    image: z.object({
        src: z.string(),
        alt: z.string(),
    }).optional(),
  }),
});

// Export a single `collections` object to register your collection(s)
export const collections = {
  'testimonials': testimonialsCollection,
};

Notice we've omitted a quote field in the schema. That's because we'll use the main body of the Markdown file for the quote itself, which is a common and clean pattern.

Action: Create Testimonial Content

With the schema in place, let's create some testimonials.

  1. Create a new directory: src/content/testimonials/. The folder name must match the collection name we defined.
  2. Inside this folder, create two new files: client-one.md and client-two.md.
  3. Add the following content to each file, respecting the schema we just created.

File: src/content/testimonials/client-one.md

---
author: "Jane Doe"
role: "CEO, Tech Innovators Inc."
image:
  src: "/images/jane-doe.jpg" # Assuming you have an image here
  alt: "Headshot of Jane Doe"
---
Premier Accounting transformed our financial operations. Their expertise in tax advisory is unmatched, saving us significant capital that we've reinvested into growth.

File: src/content/testimonials/client-two.md

---
author: "John Smith"
role: "Founder, Creative Solutions"
---
As a small business owner, bookkeeping was my biggest headache. The team at Premier Accounting streamlined everything, giving me back hours of my week. Highly recommended!

Note: You might need to restart your dev server for Astro to recognize the new collection.

You've now successfully separated your content from your presentation—a huge win for maintainability!


2. Building the TestimonialsSection Component

Now we need a component to fetch and display this data. This component will be responsible for two things:

  1. Querying the testimonials collection.
  2. Looping through the results and rendering a card for each testimonial.

In the last lesson, we built a clickable Card.astro for things like case study summaries. However, testimonials are typically static and don't link anywhere. To maintain a clean separation of concerns, we'll create a dedicated, non-interactive TestimonialCard.astro component.

Action: Create the TestimonialCard Component

  1. Create a new file at src/components/TestimonialCard.astro.
  2. Add the following code. It's a simplified version of our previous Card component, built as a semantic <figure> element, which is perfect for self-contained content like a quote.
---
// src/components/TestimonialCard.astro
import type { CollectionEntry } from 'astro:content';

interface Props {
  testimonial: CollectionEntry<'testimonials'>;
}

const { testimonial } = Astro.props;
const { author, role, image } = testimonial.data;
---
<figure class="testimonial-card">
  {image && (
    <img src={image.src} alt={image.alt} class="author-image" width="64" height="64" />
  )}
  <blockquote class="quote">
    <slot /> <!-- The quote from the MD body will be injected here -->
  </blockquote>
  <figcaption class="author-info">
    <div class="author-name">{author}</div>
    <div class="author-role">{role}</div>
  </figcaption>
</figure>

<style>
  .testimonial-card {
    background-color: #f9fafb;
    border: 1px solid #e5e7eb;
    border-radius: 0.5rem;
    padding: 2rem;
    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
  }
  .author-image {
    border-radius: 50%;
    margin-bottom: 1rem;
    width: 64px;
    height: 64px;
    object-fit: cover;
  }
  .quote {
    font-style: italic;
    color: #4b5563;
    margin-bottom: 1.5rem;
    /* Allows for quotes to have their own styling */
  }
  .quote::before, .quote::after {
    content: '"';
  }
  .author-name {
    font-weight: 600;
  }
  .author-role {
    font-size: 0.875rem;
    color: #6b7280;
  }
</style>

Action: Create and Implement the TestimonialsSection

Now for the main event. We'll create the section component that performs the data query. To align with the scalable architecture we discussed, let's place this in a sections subfolder.

  1. Create a new file at src/components/sections/TestimonialsSection.astro.
  2. Add the following code:
---
// src/components/sections/TestimonialsSection.astro
import { getCollection } from 'astro:content';
import TestimonialCard from '../TestimonialCard.astro';

// 1. Query the collection
const testimonials = await getCollection('testimonials');
---
<section class="py-16">
  <div class="container mx-auto px-4">
    <h2 class="text-3xl font-bold text-center mb-12">What Our Clients Say</h2>
    <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
      <!-- 2. Map over the data and render the component -->
      {testimonials.map(async (testimonial) => {
        const { Content } = await testimonial.render();
        return (
          <TestimonialCard testimonial={testimonial}>
            <Content />
          </TestimonialCard>
        )
      })}
    </div>
  </div>
</section>

Let's break down this powerful piece of code:

  • import { getCollection } from 'astro:content';: This is the magic function from Astro that lets us query our collections.
  • await getCollection('testimonials'): We asynchronously fetch all entries from our testimonials collection. Astro does this at build time, so it's incredibly fast for the end-user.
  • testimonial.render(): For Markdown/MDX files, this function processes the body content into HTML.
  • <Content />: The rendered content is available as a component, which we pass into the <slot /> of our TestimonialCard.

This pattern is central to working with content in Astro: query your data, then map over it to render components.


3. Adding the Section to the Homepage

The final step is to display our new section.

Open src/pages/index.astro and import and place your new component.

---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
import TestimonialsSection from '../components/sections/TestimonialsSection.astro'; // <-- Import the new section

// ... your existing frontmatter
---
<Layout title="Welcome to Premier Accounting">
  <!-- ... Your existing hero section ... -->

  <!-- ... Your existing 'Success Stories' section ... -->

  <!-- Add the new testimonials section -->
  <TestimonialsSection />

</Layout>

Refresh your homepage. You should now see a professionally styled "What Our Clients Say" section, dynamically rendering the content from your Markdown files.

Testimonial Section Design Examples
Inspiration for testimonial card designs. Our component provides a solid foundation that can be styled to match any of these aesthetics.
Test your understanding!

Imagine your client wants to feature the newest testimonials first. How would you modify TestimonialsSection.astro to ensure the testimonials are displayed in chronological order, from newest to oldest? Assume you've added a publishDate: z.date() field to your schema and frontmatter.

Show answer

You would modify the query in src/components/sections/TestimonialsSection.astro. The getCollection function returns a standard JavaScript array, so you can use array methods like sort() before mapping.

---
import { getCollection } from 'astro:content';
import TestimonialCard from '../TestimonialCard.astro';

const allTestimonials = await getCollection('testimonials');

// Sort the testimonials by date in descending order (newest first)
const sortedTestimonials = allTestimonials.sort((a, b) => 
  b.data.publishDate.valueOf() - a.data.publishDate.valueOf()
);
---
<section class="py-16">
  <div class="container mx-auto px-4">
    <h2 class="text-3xl font-bold text-center mb-12">What Our Clients Say</h2>
    <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
      {sortedTestimonials.map(async (testimonial) => {
        // ... same rendering logic
      })}
    </div>
  </div>
</section>

Conclusion

Excellent work! You have now mastered a fundamental workflow for building content-driven sites with Astro. By separating content from presentation, you've made your project far more scalable and easier to maintain.

Key Takeaways:

  • Content Collections: You defined a testimonials collection with a type-safe schema in src/content/config.ts.
  • Data Authoring: You created content as simple Markdown files within the src/content/testimonials/ directory.
  • Querying Data: You used getCollection('testimonials') within an Astro component to fetch your content at build time.
  • Component Composition: You created a specialized TestimonialCard and rendered the Markdown body content within it using testimonial.render() and the <Content /> component.
  • Clean Architecture: You organized your components into components/ and components/sections/, continuing to build a scalable project structure.

Up Next:

You've successfully rendered content summaries and lists. The next logical step is to create dedicated pages for individual pieces of content. In the next lesson, we will build a dynamic listing page for case studies and create individual case study pages. This will introduce you to Astro's dynamic routing, allowing you to generate hundreds of pages from a single template file.