Auto-list a section with Dynamic nodes
Manually adding every blog post or product to a menu does not scale. A Dynamic node projects matching Craft elements as children at read time — stored manual children appear first, then projected items.
When to use Dynamic nodes
| Situation | Approach |
|---|---|
| Fixed set of links (About, Contact, Services) | Manual Entry or Custom nodes |
| Entire channel or section that changes often | Dynamic node with an entry section source |
| Category tree | Dynamic with a category group source |
| Asset library links | Dynamic with an asset volume source |
Step 1 — Add a Dynamic node
- Open the menu in the menu builder.
- Add a Dynamic node where children should appear — for example under a Passive parent titled Blog.
- In the slide-out, choose a source:
- Entry section — pick a section and optional entry conditions
- Category group, Asset volume, or Product type (Commerce) as needed
- Set sort order and any source-specific filters.
- Save menu.
Built-in sources are listed in Node Types. Plugins can register more via Events.
Step 2 — Expected behaviour
- Manual nodes nested under the Dynamic node render before projected children.
- Each projected item is a ProjectedNode — not a stored CP node.
- New entries matching the source appear on the next front-end request without editing the menu.
- Deleting the source section (or group, volume, etc.) removes matching Dynamic nodes — see Events — Linked-element lifecycle.
Step 3 — Render in Twig
Projected children appear in node.children like stored nodes:
{% set nodes = craft.navigation.nodes('mainMenu').all() %}
{% for node in nodes %}
{% if 'Dynamic' in node.type|default %}
<ul class="blog-list">
{% for child in node.children %}
<li>
<a href="{{ child.url }}" class="{{ child.getCurrent() ? 'is-current' : '' }}">
{{ child.title }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}Tell projected nodes apart
When markup or fields differ for projected vs stored children:
{% for child in node.children %}
{% if child.isProjected ?? false %}
{# Projected entry — no node custom fields #}
<a href="{{ child.url }}">{{ child.title }}</a>
{% else %}
{# Manually placed node — may have custom fields #}
<a href="{{ child.url }}">{{ child.myBadge }}</a>
{% endif %}
{% endfor %}Projected nodes support title, url, active flags, and element. They do not have node custom fields or CP edit URLs.
Step 4 — Active state and context
Projected entries participate in active resolution. On a projected blog post URL:
node.getCurrent()is true on the projected child.craft.navigation.getActiveNode({ handle: 'mainMenu' })can return aProjectedNode.craft.navigation.context('mainMenu').siblings()includes projected siblings under the same Dynamic parent.
Step 5 — Opt out of projection
Skip projected children on a specific query:
{% set nodes = craft.navigation.nodes()
.handle('mainMenu')
.withProjectedChildren(false)
.all() %}Useful when you only want manually curated children for a partial.
Performance and cache
Dynamic nodes add cache tags for their source (section, category group, volume, or product type). Source changes invalidate cached trees automatically. See Performance & Caching and Navigation with Blitz.
Headless output
Projected children appear in craft.navigation.tree() with "isProjected": true. See Headless Trees and Expose a menu as JSON.