Skip to main content

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 exists
  • post.thumbnail.alt ?? post.title falls back to the post title for accessibility
  • post.meta('featured') adds a CSS class conditionally
  • post.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 the datetime attribute
  • post.date('F j, Y') outputs a human-readable date
  • map extracts category names, join turns 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_posts and pagination are 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.slug is passed to the inner query to filter posts by category
  • Different variable names (term and post) avoid shadowing
Common mistake

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 date
  • date() 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 % 2 alternates between 0 and 1 for even/odd classes
  • loop.first and loop.last are booleans — true only on the first and last iteration
  • loop.index gives 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