Rendering Lists
Rendering a collection — a navigation menu, a list of blog posts, a product grid — is one of the most common things you do in a template. Astro has no special loop syntax: because the template is just JavaScript-powered HTML, you render lists by transforming an array into markup with plain Array.prototype.map. The work happens on the server at build time (or per request in SSR), so a list of a thousand items still ships zero client-side JavaScript by default.
Mapping an array to elements
Inside a {} expression slot, call .map() on an array and return an element for each item. Astro flattens the resulting array and renders each element in order.
---
const fruits = ["Apple", "Banana", "Cherry"];
---
<ul>
{fruits.map((fruit) => <li>{fruit}</li>)}
</ul>
Output:
<ul>
<li>Apple</li>
<li>Banana</li>
<li>Cherry</li>
</ul>
The arrow function receives each item and returns one <li>. There is no v-for, *ngFor, or special <For> component to learn — it is the same map you already use in JavaScript.
Looping over objects
Most real data is an array of objects. Destructure each item in the callback and interpolate its properties. The second map argument is the index, which is useful for numbering or as a fallback key.
---
const posts = [
{ slug: "astro-islands", title: "Understanding Islands", minutes: 6 },
{ slug: "zero-js", title: "Zero JS by Default", minutes: 4 },
{ slug: "view-transitions", title: "View Transitions", minutes: 8 },
];
---
<ul>
{posts.map((post, index) => (
<li>
<a href={`/blog/${post.slug}`}>
{index + 1}. {post.title}
</a>
<span>{post.minutes} min</span>
</li>
))}
</ul>
Note the parentheses (...) around the returned JSX-like markup when it spans multiple lines — they let the arrow function return the element implicitly without a return statement.
Keys: when you need them
In Astro components, lists render on the server and produce static HTML, so a key prop is not required and Astro will not warn about missing keys. This differs from React. You only need keys when the list lives inside a hydrated framework component (an island) where the client framework reconciles the DOM.
| Context | Key needed? | Why |
|---|---|---|
.astro template list | No | Rendered to static HTML on the server |
React/Preact/Solid island (client:*) | Yes | Virtual-DOM diffing needs stable identity |
Vue/Svelte island (client:*) | Yes | Reactive reconciliation needs :key/keyed each |
---
import TodoList from "../components/TodoList.jsx";
const todos = [
{ id: "a1", text: "Write docs" },
{ id: "b2", text: "Ship it" },
];
---
<!-- Static .astro list — no key -->
<ul>{todos.map((t) => <li>{t.text}</li>)}</ul>
<!-- Hydrated island — key belongs inside the .jsx component -->
<TodoList todos={todos} client:visible />
Tip: Prefer a stable, unique value from your data (an
idorslug) as the key inside islands. Avoid the array index when the list can reorder, filter, or have items inserted — index keys cause subtle state-mismatch bugs after hydration.
Rendering a list of components
Mapping is not limited to plain HTML — you can render a component per item and pass each object through as props. This keeps the markup declarative while encapsulating each row’s logic.
---
import Card from "../components/Card.astro";
const products = [
{ id: 1, name: "Keyboard", price: 89 },
{ id: 2, name: "Mouse", price: 39 },
{ id: 3, name: "Monitor", price: 249 },
];
---
<section class="grid">
{products.map((product) => (
<Card name={product.name} price={product.price} />
))}
</section>
If Card accepts the same shape as your data, spread the object to forward every property at once: <Card {...product} />.
Filtering, sorting, and transforming first
Do data shaping in the component script, then map the cleaned result in the template. This keeps the markup readable and the logic testable, and it all runs server-side.
---
const items = [
{ name: "Draft post", published: false, date: "2026-05-01" },
{ name: "Live post", published: true, date: "2026-06-10" },
{ name: "Older post", published: true, date: "2026-04-22" },
];
const visible = items
.filter((item) => item.published)
.sort((a, b) => b.date.localeCompare(a.date));
---
<ol>
{visible.map((item) => <li>{item.name} — {item.date}</li>)}
</ol>
Output:
<ol>
<li>Live post — 2026-06-10</li>
<li>Older post — 2026-04-22</li>
</ol>
Handling empty lists
An empty array maps to nothing, producing an empty container. Pair the map with a check so the user sees a meaningful fallback instead of a blank region.
---
const results: string[] = [];
---
{results.length > 0 ? (
<ul>{results.map((r) => <li>{r}</li>)}</ul>
) : (
<p class="empty">No results found.</p>
)}
Best Practices
- Use plain
array.map()returning an element — there is no special loop syntax to learn in Astro templates. - Filter, sort, and reshape data in the component script; keep the template to a single declarative
map. - Skip
keyon static.astrolists, but always supply a stableid/slugkey inside hydrated framework islands. - Pass each item as typed props to a per-row component, using
{...item}spread when the shapes line up. - Always render a fallback for the empty-array case so the page never shows a bare, confusing blank.
- Wrap multi-line returned markup in parentheses, and use a
<Fragment>when a row needs sibling elements without a wrapper.