Loops & Listings
How to use Protuno's loop system to build dynamic post grids, product listings, term archives, user directories, and external API feeds inside a Proton widget.
Protuno's loop system lets you iterate over WordPress content directly inside a Proton widget's HTML using a {% for %} loop. One widget becomes a dynamic listing that renders every item in the result set.
get_posts (posts and custom post types) only. get_products, get_terms, get_users, and get_api require Protuno Pro, as does loop pagination (loop_pagination() and loop_load_more()).The Loop Syntax
{% 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.
{% 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:
{% 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. Requires Protuno Pro. Same parameters as get_posts with post_type forced to product.
{% 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. Requires Protuno Pro.
{% 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. Requires Protuno Pro.
{% 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.
{% 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:
{# 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 }}.
Avoid Duplicates
When you have two loops on the same page (a Featured Posts section and a Latest Posts section) and you do not want the same post appearing twice, add avoid_duplicates: true to the second loop:
{# 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 (Pro)
Loop pagination requires Protuno Pro. To paginate a loop, add paged: current_page() to the query and render pagination links with loop_pagination():
{% 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.
Note: Pagination works across loops on the same page. If you have two paginated loops on one page, they share the same page number.
Numeric Range Loops
For simple repeating patterns that are not backed by data:
{% for i in range(1, 5) %}
<div class="star">★</div>
{% endfor %}
range(start, end, step), max span of 1000.
Frequently Asked Questions
How many items can a loop return?
The hard maximum is 100 items per loop. Set posts_per_page or number to control the count.
Can I nest loops inside loops?
Yes. You can have a 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: [...] }) %}).
Can I use get_api to load data from a protected endpoint?
Yes. Pass authorization headers: headers: { Authorization: 'Bearer YOUR_TOKEN' }. However, API keys in headers are visible to anyone who reads the Proton widget's code. For sensitive APIs, route the request through a server-side WordPress function instead.
Does avoid_duplicates work across different loop types?
Only for get_posts and get_products. Term and user loops do not use the duplicate-tracking system.