Templating
One include
{% include 'live/_feed.twig' with { feed: entry.commentary } %}
That renders the feed on the server for the first paint and wires up the client that keeps it
current afterwards. commentary is your Live field's handle.
There is only one card
This is worth understanding before you change anything. When an update is published, Live renders
it through live/_update.twig and stores the HTML in the update's snapshot. That HTML is what
travels to the browser.
So an update that arrives three seconds after page load is byte-identical to one that was server-rendered — because it was server-rendered, by the same partial. There is no JavaScript template to keep in step with your Twig, so there is nothing to drift.
The consequence: a change to _update.twig applies to new updates straight away, and to updates
already published when you rebuild their snapshots:
php craft live/snapshots/rebuild --all
Taking over the markup
Copy either partial into your own templates directory:
templates/live/_feed.twig # the container, the state, the pinned update, the empty state
templates/live/_update.twig # one update
Craft looks in your templates directory before the plugin's, so a file you provide wins and the one you didn't copy keeps working. Nothing to register, nothing to turn off.
To keep the update template somewhere else entirely, set Update template (updateTemplate) in
the settings. Twig and the snapshot writer both use it, so the two can't disagree.
What the runtime needs from your markup
Rewrite as much as you like, but keep:
- the
<live-feed>element with itsdata-live-configattribute - a container marked
data-live-updatesholding the rendered updates data-seqanddata-revon each update — they are how the client knows what it already has- optionally, an element marked
data-live-count(kept up to date with the update count) and a button markeddata-live-new(the "3 new updates" pill — see below)
A minimal container of your own:
{% set feed = entry.commentary %}
<live-feed {{ craft.live.attributes(entry) }}>
<div data-live-updates>
{% for update in feed.updates.limit(50).all() %}
{% include 'live/_update.twig' with { update: update } only %}
{% endfor %}
</div>
</live-feed>
{% do craft.app.view.registerAssetBundle('justinholtweb\\live\\web\\assets\\feed\\FeedAsset') %}
craft.live.attributes() prints the config attribute ready-escaped, and prints nothing at all for
an element without a Live field.
The feed value
entry.<handle> returns the feed:
{% set feed = entry.commentary %}
{{ feed.state }} {# upcoming | live | paused | ended #}
{{ feed.stateLabel }} {# translated label for the state #}
{{ feed.isLive }}
{{ feed.isFollowable }} {# upcoming, live or paused — anything still coming #}
{{ feed.isEnded }}
{{ feed.count }}
{{ feed.seq }} {# the current sequence number #}
{{ feed.pinned }} {# the pinned update, or null #}
{{ feed.startedAt }}
{{ feed.endedAt }}
{% for update in feed.updates.limit(50).all() %}
{{ update.postedAt|date('H:i') }}
{{ update.bodyHtml }}
{{ update.type.name }}
{% endfor %}
{% for goal in feed.updates.type('goal').all() %}…{% endfor %}
{% for key in feed.highlights.all() %}…{% endfor %}
feed.updates is an element query, so the usual limit, offset and orderBy apply, and it only
returns published updates. feed.highlights is the same query narrowed to key moments.
An update
update.seq | Its sequence number in the post |
update.postedAt | When it was posted |
update.title | The headline, if its type has one or a title format |
update.body / update.bodyHtml | The purified HTML, and the same marked safe for output |
update.type | The update type — name, handle, color, icon |
update.pinned, update.highlight | Pinned, key moment |
update.author | The user who posted it |
update.meta.rev | Its revision, bumped on every edit |
update.<fieldHandle> | Any field from the type's field layout |
craft.live
The same ground without knowing a field handle — useful in listings and layouts:
{% set feed = craft.live.feed(entry) %} {# or craft.live.feed(entry, 'commentary') #}
{% set goals = craft.live.updates({ type: 'goal', limit: 10 }).all() %}
{% set onNow = craft.live.posts('live') %} {# head rows for every live post on this site #}
{{ craft.live.seq(entry) }}
{{ craft.live.isLive(entry) }}
{% set types = craft.live.types() %}
{% set goal = craft.live.type('goal') %}
craft.live.updates() is a bare update query across every post — "every goal today", a network
ticker. craft.live.posts() takes a state (null for all of them) and an optional site ID.
Caching the page
Cache it. Updates arrive over the top of the HTML rather than changing it, so nothing about a publish makes the page stale in a way a reader would notice. A publish doesn't touch the entry and, by default, doesn't clear its caches.
Where you want the server-rendered part current for a first paint, feed.seq is the cache key: it
changes on every publish and on nothing else.
{% cache using key "feed-#{entry.id}-#{entry.commentary.seq}" %}
{% include 'live/_feed.twig' with { feed: entry.commentary } %}
{% endcache %}
If you render a feed with no JavaScript at all and it must be right on every request, turn on
invalidateOwnerCaches and accept the cost the plugin is otherwise built to avoid.
The live-feed element
<live-feed> is a custom element with no dependencies, registered by the FeedAsset bundle that
_feed.twig loads. It:
- reads the config attribute — head URL, current
seq, state, poll interval, order and, on Pro with SSE on, the stream URL - polls
head.jsoneverypollIntervalseconds, with a cache-busting parameter that only changes once per interval, so every reader in the same window asks a CDN for the same URL - when
seqhas moved, fetches the updates it is missing and inserts their pre-rendered HTML; when arevit holds has moved, replaces that update in place; when aseqappears on theremovedlist, takes it down - stops polling when the post ends, pauses while the tab is hidden, and backs off (up to a minute) when the origin is failing
Readers part-way down a newest-first feed are not moved under their feet. New updates wait behind
the data-live-new button until they click it, which scrolls them back to the top.
Events
The element dispatches two bubbling events you can listen for:
document.addEventListener('live:update', (e) => {
// e.detail is the update payload — seq, rev, html…
});
document.addEventListener('live:state', (e) => {
// e.detail is the new head — e.g. e.detail.state === 'ended'
});
Use them for a scoreline, a "LIVE" badge in the site header, or analytics.