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.
- 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. - 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.
- Create a new directory:
src/content/testimonials/. The folder name must match the collection name we defined. - Inside this folder, create two new files:
client-one.mdandclient-two.md. - 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:
- Querying the
testimonialscollection. - 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
- Create a new file at
src/components/TestimonialCard.astro. - Add the following code. It's a simplified version of our previous
Cardcomponent, 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.
- Create a new file at
src/components/sections/TestimonialsSection.astro. - 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 ourtestimonialscollection. 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 ourTestimonialCard.
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.

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
testimonialscollection with a type-safe schema insrc/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
TestimonialCardand rendered the Markdown body content within it usingtestimonial.render()and the<Content />component. - Clean Architecture: You organized your components into
components/andcomponents/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.