> For the complete documentation index, see [llms.txt](https://docs.uichemy.com/llms.txt).
> Use it to discover all available pages before exploring further.

# Loops & Listings

Use Composer's loop system to build dynamic post grids, product listings, term archives, user directories, and external API feeds.


Composer's loop system lets you iterate over WordPress content directly inside a Composer widget's HTML using a `{% for %}` loop. One widget becomes a dynamic listing that renders every item in the result set.

<Callout kind="warning">
The free version supports `get_posts` (posts and custom post types) only. `get_products`, `get_terms`, `get_users`, and `get_api` require UiChemy Pro, as do the pagination renderers (`loop_pagination()` and `loop_load_more()`). `current_page()` itself is Free, it only reads a URL parameter, no rendering involved.
</Callout>

### The Loop Syntax

```twig
{% for post in get_posts({ post_type: 'post', posts_per_page: 6 }) %}
  <article class="card">
    <img src="{{ post.thumbnail.src('medium') }}" alt="{{ post.thumbnail.alt }}">
    <h3>{{ post.title }}</h3>
    <p>{{ post.excerpt|truncate(100) }}</p>
    <a href="{{ post.link }}">Read More</a>
  </article>
{% endfor %}
```

Inside the `{% for %}` block, the loop variable (`post` in the example) is a full data provider, every field, filter, and chained expression available on `post` works inside the loop.

### Loop Source 1: `get_posts`

Query any WordPress post type. All `WP_Query` arguments are supported.

```twig
{% for item in get_posts({
  post_type: 'portfolio',
  posts_per_page: 9,
  orderby: 'date',
  order: 'DESC'
}) %}
  <div class="item">{{ item.title }}</div>
{% endfor %}
```

**Key parameters:**

| Parameter | Type | Notes |
|---|---|---|
| `post_type` | string or array | Default: `'post'`. Any registered post type. |
| `posts_per_page` | number | Default: 10. Max: 100. |
| `orderby` | string | `date`, `title`, `menu_order`, `rand`, `modified`, `comment_count`, `ID` |
| `order` | string | `ASC` or `DESC` |
| `post__in` | array | Include only specific post IDs: `[12, 45, 67]` |
| `post__not_in` | array | Exclude specific post IDs |
| `author` | number | Filter by author ID |
| `s` | string | Search query |
| `tax_query` | array | Filter by taxonomy/term (standard `WP_Query` format) |
| `meta_key` | string | Order by or filter on a meta key |
| `meta_value` | string | Meta value to match |
| `paged` | number | Page number for pagination. Use `current_page()` for dynamic paging. |
| `avoid_duplicates` | boolean | Skip posts already rendered by an earlier loop on this page |

Taxonomy filter example:

```twig
{% for post in get_posts({
  post_type: 'post',
  posts_per_page: 6,
  tax_query: [{
    taxonomy: 'category',
    field: 'slug',
    terms: ['news', 'updates']
  }]
}) %}
```

### Loop Source 2: `get_products` (Pro)

Query WooCommerce products. Same parameters as `get_posts` with `post_type` forced to `product`.

```twig
{% for product in get_products({
  posts_per_page: 8,
  orderby: 'date',
  meta_key: '_featured',
  meta_value: 'yes'
}) %}
  <div class="product-card">
    <img src="{{ product.thumbnail.src('woocommerce_thumbnail') }}" alt="{{ product.thumbnail.alt }}">
    <h3>{{ product.title }}</h3>
    <p class="price">{{ product.price|raw }}</p>
    {% if product.is_on_sale %}
      <span class="badge">Sale</span>
    {% endif %}
    <a href="{{ product.link }}">View Product</a>
  </div>
{% endfor %}
```

### Loop Source 3: `get_terms` (Pro)

Query taxonomy terms, categories, tags, or any custom taxonomy.

```twig
{% for term in get_terms({
  taxonomy: 'category',
  hide_empty: true,
  number: 12
}) %}
  <a href="{{ term.link }}" class="tag-chip">
    {{ term.name }} ({{ term.count }})
  </a>
{% endfor %}
```

**Key parameters:**

| Parameter | Type | Notes |
|---|---|---|
| `taxonomy` | string | Default: `'category'`. Any registered taxonomy. |
| `hide_empty` | boolean | Default: true. Exclude terms with no posts. |
| `number` | number | Max items. Default: 100. |
| `orderby` | string | `name`, `count`, `slug`, `term_id` |
| `order` | string | `ASC` or `DESC` |
| `include` | array | Include only specific term IDs |
| `exclude` | array | Exclude specific term IDs |
| `parent` | number | Only direct children of this term ID |

### Loop Source 4: `get_users` (Pro)

Query WordPress site users.

```twig
{% for member in get_users({ role: 'author', number: 12 }) %}
  <div class="team-card">
    <img src="{{ member.avatar('150') }}" alt="{{ member.name }}">
    <h3>{{ member.name }}</h3>
    <p>{{ member.bio }}</p>
    <a href="{{ member.link }}">View Posts</a>
  </div>
{% endfor %}
```

**Key parameters:**

| Parameter | Type | Notes |
|---|---|---|
| `role` | string | WordPress role: `'author'`, `'editor'`, `'subscriber'`, etc. |
| `number` | number | Max items. Default: 100. |
| `orderby` | string | `display_name`, `registered`, `ID` |
| `order` | string | `ASC` or `DESC` |
| `include` | array | Include only specific user IDs |
| `exclude` | array | Exclude specific user IDs |

### Loop Source 5: `get_api`, External JSON (Pro)

Fetch any external JSON endpoint and loop its items. Results are cached for 5 minutes by default.

```twig
{% for repo in get_api({
  url: 'https://api.github.com/users/yourname/repos',
  limit: 6,
  cache: 600
}) %}
  <div class="repo-card">
    <h3>{{ repo.name }}</h3>
    <p>{{ repo.description }}</p>
    <a href="{{ repo.html_url }}">View on GitHub</a>
  </div>
{% endfor %}
```

**Key parameters:**

| Parameter | Type | Notes |
|---|---|---|
| `url` | string | The JSON endpoint URL (required) |
| `method` | string | `GET` (default) or `POST` |
| `path` | string | Dot-notation path into the response. Use when the array is nested: `'data.results'` |
| `headers` | object | Request headers, for `Authorization`, `Accept`, etc. |
| `body` | any | Request body for `POST` requests |
| `limit` | number | Max items to return (0 = all, max 100) |
| `cache` | number | Cache duration in seconds. Default: 300. Set to 0 to disable caching. |

Nested response example:

```twig
{# API returns { "data": { "items": [...] } } #}
{% for item in get_api({ url: 'https://api.example.com/feed', path: 'data.items', limit: 5 }) %}
  <p>{{ item.title }}</p>
{% endfor %}
```

Access nested fields with dot notation: `{{ item.author.name }}`, `{{ item.metadata.tags }}`.

<Callout kind="warning">
API keys passed via `headers` are visible to anyone who reads the Composer widget's code. For sensitive APIs, route the request through a server-side WordPress function instead.
</Callout>

### Avoid Duplicates

When you have two loops on the same page (a Featured Posts section and a Latest Posts section) and don't want the same post appearing twice, add `avoid_duplicates: true` to the second loop:

```twig
{# First loop, featured posts #}
{% for post in get_posts({ post_type: 'post', meta_key: '_featured', meta_value: 'yes', posts_per_page: 3 }) %}
  <!-- featured card -->
{% endfor %}

{# Second loop, latest posts, skips anything shown above #}
{% for post in get_posts({ post_type: 'post', posts_per_page: 6, avoid_duplicates: true }) %}
  <!-- latest card -->
{% endfor %}
```

`avoid_duplicates` works across the entire page request, any post ID rendered by any earlier `get_posts` or `get_products` loop is excluded.

### Pagination

`current_page()` is Free, `loop_pagination()` requires Pro. To paginate a loop, add `paged: current_page()` to the query and render pagination links with `loop_pagination()`:

```twig
{% for post in get_posts({
  post_type: 'post',
  posts_per_page: 9,
  paged: current_page()
}) %}
  <article><!-- card content --></article>
{% endfor %}

{{ loop_pagination()|raw }}
```

`current_page()` reads the `?loop_page=N` URL parameter (falls back to WordPress's standard `paged` query var). `loop_pagination()` renders standard WordPress-style numbered pagination links that update `?loop_page=N`.

<Callout kind="tip">
Pagination works across loops on the same page. If you have two paginated loops on one page, they share the same page number.
</Callout>

### Numeric Range Loops

For simple repeating patterns that aren't backed by data:

```twig
{% for i in range(1, 5) %}
  <div class="star">★</div>
{% endfor %}
```

`range(start, end, step)`, max span of 1000.

### Safety Limits

Every loop source is capped at **100 items**, regardless of what you request. `range()` has a max span of **1000**. The `?loop_more=` offset parameter (used with `loop_load_more()`, Pro) caps out at **10,000**.

### Breadcrumbs (Pro)

`breadcrumbs()` and `breadcrumbs_html()` build a context-aware breadcrumb trail, home, hierarchical pages, taxonomy archives, post type archives, author, search, 404, and WooCommerce shop, with optional schema.org `BreadcrumbList` structured data. There's also a matching **Breadcrumbs** dynamic tag for the no-code panel. See [Dynamic Tags](/uichemy-composer/design-data/dynamic-tags).

### Use Cases

- **A blog post grid**, showing your latest posts, filtered by category, tag, or custom field.
- **A product listing**, built from `get_products` instead of relying on WooCommerce's own archive template.
- **A directory or team page**, looping over users or terms to render a card per person or category.
- **Pulling in content from outside WordPress**, `get_api` renders a loop from any external JSON endpoint.

### Frequently Asked Questions

<ExpandableGroup>
  <Expandable title="How many items can a loop return?" default-open="false">
    The hard maximum is 100 items per loop. Set `posts_per_page` or `number` to control the count.
  </Expandable>

  <Expandable title="Can I nest loops inside loops?" default-open="false">
    Yes. You can loop over categories, and inside each category card loop over that category's posts. Use different variable names for each loop (`{% for term in get_terms() %}` and inside it `{% for post in get_posts({ tax_query: [...] }) %}`).
  </Expandable>

  <Expandable title="Can I use get_api to load data from a protected endpoint?" default-open="false">
    Yes. Pass authorization headers: `headers: { Authorization: 'Bearer YOUR_TOKEN' }`. However, API keys in headers are visible to anyone who reads the widget's code. For sensitive APIs, route the request through a server-side WordPress function instead.
  </Expandable>

  <Expandable title="Does avoid_duplicates work across different loop types?" default-open="false">
    Only for `get_posts` and `get_products`. Term and user loops don't use the duplicate-tracking system.
  </Expandable>
</ExpandableGroup>

### Related

<Columns cols={2}>
  <Card title="Twig" href="/uichemy-composer/design-data/twig">
    See the full expression syntax loops are built on.
  </Card>
  <Card title="Dynamic Tags" href="/uichemy-composer/design-data/dynamic-tags">
    Bind a single field with no code instead of looping.
  </Card>
  <Card title="Developer Mode" href="/uichemy-composer/build/developer-mode">
    Write your loop markup in the panel where it actually goes.
  </Card>
  <Card title="Archive Templates" href="/uichemy-plugin/theme-builder/archive-templates">
    See loops powering a real archive template.
  </Card>
</Columns>
