Type something to search...
How to loop over arrays in Astro components?

How to loop over arrays in Astro components?

To loop over an array in an Astro component, call .map() on the array inside curly braces in the template and return markup for each item, for example {fruits.map((fruit) => <li>{fruit}</li>)}. Astro renders every element the callback returns, in order. There's no special loop syntax like v-for or {#each}, and you don't need a key attribute as you would in React, because Astro renders the list once to HTML and never re-renders it. Filtering, sorting and grouping are best done in the component script first, so the template only has to map over data that's ready to display.

Lists are everywhere on content sites: navigation links, blog post cards, tags, tables of specifications. In this article you'll learn the basic .map() pattern, how to use the index, how to render components for each item, how to prepare data with filter() and toSorted(), how to loop over objects, Maps, Sets and number ranges, how to output several elements per item, how to handle empty lists, how async data fits in, how to render nested data recursively, and how Astro 7's whitespace handling affects inline lists.

The Basic Pattern

Define an array in the component script and map over it in the template:

---
// src/components/FruitList.astro
const fruits = ["Apple", "Banana", "Cherry"];
---
<ul>
  {fruits.map((fruit) => <li>{fruit}</li>)}
</ul>
<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>

.map() returns a new array of rendered elements, and Astro outputs them one after another. Any array works, whether it's written inline, imported from a JSON file, fetched from an API or returned by getCollection().

If the markup for each item spans several lines, wrap it in parentheses:

<ul class="posts">
  {posts.map((post) => (
    <li>
      <a href={post.url}>{post.title}</a>
      <p>{post.excerpt}</p>
    </li>
  ))}
</ul>

Using the Index

The second argument to the callback is the item's position, starting at zero:

<ol>
  {fruits.map((fruit, index) => (
    <li data-index={index}>{index + 1}. {fruit}</li>
  ))}
</ol>
<ol><li data-index="0">1. Apple</li><li data-index="1">2. Banana</li><li data-index="2">3. Cherry</li></ol>

No key Attribute Needed

React needs a key on each item so it can track elements between renders. Astro components render once, on the server, and send static HTML, so there's nothing to track. Leave key out. If you do add key={item.id} to an element, Astro writes it into the HTML as a plain key="..." attribute, and astro check reports it as an error because key isn't a valid attribute on <li>.

The exception is when the list contains a hydrated framework component that itself renders a list on the client. Inside that component, follow the framework's own rules.

Looping Over Components

Rendering a component for each item works the same way as rendering an element. Pass the item's values as props:

---
// src/components/TeamMember.astro
interface Props {
  name: string;
  role: string;
}
const { name, role } = Astro.props;
---
<li class="member"><strong>{name}</strong> – {role}</li>
---
// src/pages/team.astro
import team from "../data/team.json";
import TeamMember from "../components/TeamMember.astro";
---
<ul>
  {team.map((person) => (
    <TeamMember name={person.name} role={person.role} />
  ))}
</ul>

When the item's keys match the component's props, spreading is shorter: {team.map((person) => <TeamMember {...person} />)}. Bear in mind that spreading passes every key, including ones the component doesn't use. Props and spreading are covered in how to pass props to an Astro component.

Filter and Sort in the Frontmatter

It's possible to chain filter() and sort() inside the template, but it gets hard to read quickly. Prepare the data in the component script instead:

---
// src/pages/team.astro
import team from "../data/team.json";
import TeamMember from "../components/TeamMember.astro";

const active = team
  .filter((person) => person.active)
  .toSorted((a, b) => a.name.localeCompare(b.name));
---
<ul>
  {active.map((person) => <TeamMember name={person.name} role={person.role} />)}
</ul>

Use toSorted() rather than sort() when the array comes from an import or a shared module. sort() sorts in place, so it would change the imported array for every other component that uses it during the build. toSorted() returns a sorted copy and is available in the Node.js versions Astro 7 supports.

The same approach applies to content collections. Query, filter and sort once, then map:

---
// src/pages/blog/index.astro
import { getCollection } from "astro:content";

const posts = (await getCollection("blog", ({ data }) => !data.draft))
  .toSorted((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
  .slice(0, 10);
---
<ul>
  {posts.map((post) => (
    <li><a href={`/blog/${post.id}/`}>{post.data.title}</a></li>
  ))}
</ul>

Collection queries in more depth are covered in how to query and sort content collections.

Looping Over Objects, Maps, Sets and Ranges

.map() is an array method, so other data structures need converting first.

Objects

Use Object.entries() to get [key, value] pairs:

---
const specs = {
  Weight: "1.2 kg",
  Battery: "12 hours",
  Ports: "2 × USB-C",
};
---
<dl>
  {Object.entries(specs).map(([label, value]) => (
    <>
      <dt>{label}</dt>
      <dd>{value}</dd>
    </>
  ))}
</dl>

Object.keys() and Object.values() work too when you only need one side.

Maps and Sets

Spread them into an array, or use Array.from():

---
const prices = new Map([["Basic", 9], ["Pro", 29]]);
const tags = new Set(["astro", "html", "astro"]);
---
<ul>
  {[...prices].map(([plan, price]) => <li>{plan}: £{price}</li>)}
</ul>
<ul>
  {[...tags].map((tag) => <li>#{tag}</li>)}
</ul>
<ul><li>Basic: £9</li><li>Pro: £29</li></ul><ul><li>#astro</li><li>#html</li></ul>

The Set removed the duplicate astro tag before rendering, which makes it a handy way to deduplicate tags gathered from many posts.

Number Ranges

To repeat something a fixed number of times, create an array with Array.from():

---
const rating = 3;
---
<div class="stars" aria-label={`${rating} out of 5`}>
  {Array.from({ length: 5 }, (_, i) => (
    <span class="star">{i < rating ? "★" : "☆"}</span>
  ))}
</div>

This is also how pagination links are commonly built, for example Array.from({ length: totalPages }, (_, i) => i + 1). A full pagination setup is covered in how to paginate a blog in Astro.

Rendering Several Elements Per Item

The callback has to return a single expression. In the <dl> example above, each entry needs a <dt> and a <dd> side by side, and wrapping them in a <div> would be valid but changes the structure. A fragment groups them without adding anything to the HTML:

{faq.map((item) => (
  <>
    <dt>{item.question}</dt>
    <dd>{item.answer}</dd>
  </>
))}

<Fragment>...</Fragment> does the same thing. Fragments are explained in how to use the Fragment component.

Handling Empty Lists

An empty array renders nothing, which usually leaves an empty <ul> behind. Check the length and show a message instead:

{results.length > 0 ? (
  <ul>
    {results.map((result) => <li>{result}</li>)}
  </ul>
) : (
  <p>No results found.</p>
)}

Use results.length > 0, not results.length &&. When the array is empty, results.length && ... evaluates to 0, and Astro prints that 0 on the page. That trap and others are covered in conditional rendering in Astro templates.

Grouping Items

For grouped lists, such as posts by year or people by team, build the groups in the frontmatter. Object.groupBy() returns an object keyed by whatever your callback returns:

---
import team from "../data/team.json";

const grouped = Object.groupBy(team, (person) =>
  person.active ? "Active" : "Inactive"
);
---
{Object.entries(grouped).map(([group, people]) => (
  <section>
    <h2>{group}</h2>
    <ul>
      {people?.map((person) => <li>{person.name}</li>)}
    </ul>
  </section>
))}

TypeScript types each group as possibly undefined, hence the people?.map. Object.groupBy() is supported in current Node.js releases; if your editor complains it doesn't exist, check that your tsconfig.json targets a recent ECMAScript library.

Async Data in Loops

The component script supports top-level await, so the simplest approach is to fetch everything before the template:

---
const ids = [1, 2, 3];
const posts = await Promise.all(
  ids.map((id) =>
    fetch(`https://api.example.com/posts/${id}`).then((res) => res.json())
  )
);
---
<ul>
  {posts.map((post) => <li>{post.title}</li>)}
</ul>

Promise.all() runs the requests in parallel rather than one after another, which keeps build times down. Astro will also wait for promises returned inside the template, so an async callback in .map() does render correctly, but fetching in the frontmatter keeps errors and data handling in one place. Fetching at build time is covered in how to fetch data at build time.

Recursive Lists with Astro.self

Nested data such as a file tree or a multi-level menu can be rendered with a component that calls itself through Astro.self:

---
// src/components/Tree.astro
interface Node {
  label: string;
  children?: Node[];
}

interface Props {
  nodes: Node[];
}

const { nodes } = Astro.props;
---
<ul>
  {nodes.map((node) => (
    <li>
      {node.label}
      {node.children && <Astro.self nodes={node.children} />}
    </li>
  ))}
</ul>
<Tree
  nodes={[
    { label: "src", children: [
      { label: "pages" },
      { label: "components", children: [{ label: "ui" }] },
    ] },
    { label: "public" },
  ]}
/>
<ul><li>src<ul><li>pages</li><li>components<ul><li>ui</li></ul></li></ul></li><li>public</li></ul>

Each level renders a new <ul> inside its parent <li>, which is valid HTML for nested lists.

Whitespace in Inline Lists

Astro 7 changed the default compressHTML setting to 'jsx', which strips whitespace between elements the way React does. For block elements like <li> this makes no difference, but for inline elements in a sentence the words run together:

---
const tags = ["astro", "html", "css"];
---
<p>{tags.map((tag) => <span>{tag}</span>)}</p>
<p><span>astro</span><span>html</span><span>css</span></p>

That displays as "astrohtmlcss". Add separators explicitly, either as a string inside a fragment or with {" "}:

<p>
  {tags.map((tag, i) => (
    <>
      {i > 0 && ", "}
      <a href={`/tags/${tag}/`}>{tag}</a>
    </>
  ))}
</p>
<p><a href="/tags/astro/">astro</a>, <a href="/tags/html/">html</a>, <a href="/tags/css/">css</a></p>

For plain text, tags.join(", ") is simpler still. If you'd rather keep Astro 6's HTML-aware whitespace handling, set compressHTML: true in astro.config.mjs.


FAQ: Looping in Astro

No. Astro templates use JavaScript expressions, so you loop with the array map method inside curly braces. You can use for loops in the component script to build an array, then map over it in the template.

No. Astro components render once to static HTML and never re-render, so there's nothing for a key to track. Only framework components that render lists on the client need keys, following their own rules.

Convert it to an array first with Object.entries, Object.keys or Object.values, then call map on the result. Destructure the key and value in the callback, such as ([label, value]).

Use a ternary that checks array.length > 0, rendering the list in one branch and a message in the other. Avoid array.length && because an empty array would print a 0.

Astro waits for promises in templates, so an async callback does render. It's usually clearer to fetch everything in the component script with Promise.all and map over the resolved data in the template.

Astro 7 strips whitespace between elements by default using JSX rules. Add separators explicitly with a string or an expression containing a single space, join the values into a string, or set compressHTML to true in your config.


Conclusion

Looping in Astro is just .map() inside curly braces. Return an element or a component for each item, use the index when you need positions, and skip key because nothing re-renders. Objects, Maps, Sets and ranges all become arrays with Object.entries(), the spread operator or Array.from(), and fragments let each item output several elements without a wrapper.

Keep the template simple by doing the work in the component script: filter, sort with toSorted(), group with Object.groupBy() and fetch with Promise.all() before rendering. Handle empty lists with a length > 0 check, use Astro.self for nested data, and remember that Astro 7 removes whitespace between elements by default, so inline lists need explicit separators.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper