Common Patterns
This page shows complete template patterns you can adapt for real projects. Each one combines multiple concepts — expressions, filters, loops, conditions — into a working template.
For individual syntax details, see Expressions & Syntax. For learning the basics, start with Your First Expression.
Post card with fallback image
A card that handles missing thumbnails and optional custom fields:
<article class="{{ post.meta('featured') ? 'card featured' : 'card' }}">
<img
src="{{ post.thumbnail.src('medium') ?? '/wp-content/themes/starter/img/placeholder.jpg' }}"
alt="{{ post.thumbnail.alt ?? post.title }}"
/>
<div class="card-body">
<span class="card-date">{{ post.date('M j, Y') }}</span>
<h3>{{ post.title }}</h3>
<p>{{ post.excerpt({words: 20}) }}</p>
</div>
</article>
What's happening:
??provides a placeholder image when no thumbnail existspost.thumbnail.alt ?? post.titlefalls back to the post title for accessibilitypost.meta('featured')adds a CSS class conditionallypost.excerpt({words: 20})limits the excerpt length
Meta line with categories
A single line combining date, author, and taxonomy — common below post titles:
<div class="meta">
<time datetime="{{ post.date('c') }}">{{ post.date('F j, Y') }}</time>
<span>{{ post.author.name }}</span>
<span>{{ post.categories|map(c => c.name)|join(', ') }}</span>
</div>
What's happening:
post.date('c')outputs ISO 8601 format for thedatetimeattributepost.date('F j, Y')outputs a human-readable datemapextracts category names,jointurns the array into a comma-separated string
Archive header with post count
Show a page title and total count before looping through results:
{% set results = get_posts({'post_type': 'product', 'posts_per_page': 12}) %}
<header>
<h1>Products ({{ results.found_posts }})</h1>
{{ results.pagination }}
</header>
{% for post in results %}
<div class="product">
<h2>{{ post.title }}</h2>
<span class="price">{{ post.meta('price')|number_format(2) }} €</span>
</div>
{% endfor %}
<footer>
{{ results.pagination }}
</footer>
What's happening:
- A Variable block stores the query so
found_postsandpaginationare available before the loop number_format(2)formats the price with two decimal places- Pagination appears both above and below the list
Category index with nested posts
List categories, each with its latest posts underneath:
{% for term in get_terms({'taxonomy': 'category', 'hide_empty': true}) %}
<section>
<h2>{{ term.name }} ({{ term.count }})</h2>
{% for post in get_posts({'category_name': term.slug, 'posts_per_page': 3}) %}
<a href="{{ post.link }}">
{{ post.title }}
<time>{{ post.date('M j') }}</time>
</a>
{% endfor %}
</section>
{% endfor %}
What's happening:
- The outer loop iterates over categories
term.slugis passed to the inner query to filter posts by category- Different variable names (
termandpost) avoid shadowing
If both loops use the same variable name (e.g., two for post in ...), the inner loop shadows the outer — you lose access to the outer item. Always use different names.
Event listing with date range
Display events with start/end dates from custom fields:
{% for post in get_posts({'post_type': 'event', 'meta_key': 'start_date', 'orderby': 'meta_value', 'order': 'ASC'}) %}
<div class="event">
<h3>{{ post.title }}</h3>
<time>
{{ post.meta('start_date')|date('F j') }} – {{ post.meta('end_date')|date('F j, Y') }}
</time>
<span class="location">{{ post.meta('location') ?? 'TBD' }}</span>
</div>
{% endfor %}
What's happening:
meta_key+orderby: 'meta_value'sorts events by start datedate()filter formats raw date strings into readable dates?? 'TBD'handles events without a location
User greeting with role-based content
Show different content based on login status and capabilities:
{{ user.logged_in ? 'Welcome, ' ~ user.first_name ~ '!' : 'Welcome, guest!' }}
{{ user.can('edit_posts') ? '<a href="/wp-admin/edit.php">Your posts</a>' : '' }}
{{ user.can('manage_options') ? '<a href="/wp-admin/">Dashboard</a>' : '' }}
What's happening:
~concatenates strings (unlike+, it always treats both sides as strings)user.can()checks WordPress capabilities — the link only renders if the user has the right role- An empty string
''outputs nothing, effectively hiding the link
Term breadcrumb
Build a breadcrumb from a term's parent chain:
{{ term.parent ? term.parent.name ~ ' / ' : '' }}{{ term.name }}
If the term "Running Shoes" has parent "Shoes": Shoes / Running Shoes
If the term "Accessories" has no parent: Accessories
Price with sale logic
Display a sale price when available, otherwise the regular price:
{% if post.has_field('sale_price') %}
<span class="price-original">{{ post.meta('regular_price')|number_format(2) }} €</span>
<span class="price-sale">{{ post.meta('sale_price')|number_format(2) }} €</span>
{% endif %}
{% if not post.has_field('sale_price') %}
<span class="price">{{ post.meta('regular_price')|number_format(2) }} €</span>
{% endif %}
Or in a single expression (for use inside a Text block):
{{ post.has_field('sale_price') ? post.meta('sale_price')|number_format(2) ~ ' €' : post.meta('regular_price')|number_format(2) ~ ' €' }}
Loop with alternating classes
Apply different CSS classes to odd/even items, plus first/last:
{% for post in get_posts({'post_type': 'post', 'posts_per_page': 10}) %}
<article class="card {{ loop.index0 % 2 == 0 ? 'even' : 'odd' }} {{ loop.first ? 'first' : '' }} {{ loop.last ? 'last' : '' }}">
<h2>{{ loop.index }}. {{ post.title }}</h2>
</article>
{% endfor %}
What's happening:
loop.index0 % 2alternates between 0 and 1 for even/odd classesloop.firstandloop.lastare booleans — true only on the first and last iterationloop.indexgives a 1-based counter for numbered lists
Comma-separated list
Join items with commas, no trailing comma:
{% for term in post.categories %}{{ term.name }}{{ loop.last ? '' : ', ' }}{% endfor %}
Output: News, Tech, Updates
The same result using filters instead of a loop:
{{ post.categories|map(c => c.name)|join(', ') }}
The filter approach is shorter when you only need text. The loop approach gives you full HTML control (links, classes, etc.).
Next steps
- Expressions & Syntax — full operator and filter reference
- Providers — all available data fields
- Loop — data sources and query setup