Skip to content
Astro as templating 4 min read

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.

ContextKey needed?Why
.astro template listNoRendered to static HTML on the server
React/Preact/Solid island (client:*)YesVirtual-DOM diffing needs stable identity
Vue/Svelte island (client:*)YesReactive 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 id or slug) 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 key on static .astro lists, but always supply a stable id/slug key 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.
Last updated June 14, 2026
Was this helpful?