Real-Time Updates in Components
CWA is live by default. When an admin publishes a component, every open browser session is told about it via the Mercure hub — no page reload, no polling, no WebSocket code on your end.
How It Works
- An admin publishes a component via the CMS
- The Symfony API sends an update to the Mercure hub (because
mercure: trueis on the entity) - The hub broadcasts the update to all subscribed browser connections
- The Nuxt module receives the event and stages the new resource in the store — the rendered page is untouched
- A small "content is outdated" notice appears
- When the reader accepts,
mergeNewResources()applies the staged resources and Vue re-renders
You write none of this plumbing. It's in the module.
A signed-in admin is the exception: another admin's saved change appears in place, with no notice. See Co-Editing in the Admin.
Prerequisites
PHP Side
Add mercure: true to the #[ApiResource] attribute on any entity you want to broadcast:
#[ApiResource(mercure: true)]
class Title extends AbstractComponent { ... }
Without this, the resource updates silently (the API saves the change, but no Mercure event fires).
The Hub
The API publishes to a Mercure hub. In the template, the hub runs inside the php container, so there is nothing extra to deploy. The browser connects to it at MERCURE_PUBLIC_URL.
Setting up the hub, its secrets and the subscriber cookie is covered in Real-Time Updates (Mercure) in the API section, and what visitors and admins each receive in Public and Admin Updates.
No Front-End Code Needed
The module subscribes to the Mercure hub in its Nuxt plugin, and filters what arrives against the resources on the current page. You never write EventSource or WebSocket code.
The subscription lifecycle:
- Once the page has hydrated: the module loads its Mercure client and opens a single
EventSourceconnection toMERCURE_PUBLIC_URL. The client isn't in a visitor's entry bundle, and navigation never waits for it - Topics: one wildcard subscription (
?match=*, Mercure 1.0's match-all) — incoming messages are then matched againstcwa.resources.currentIdsclient-side, and anything for another page is discarded - On navigation: the subscription URL is unchanged; only the client-side filter moves with you
- On sign-in: Mercure re-initialises so private topics (admin resources) are included
- On sign-out, or when the session ends, including during a navigation: Mercure re-initialises, so the browser doesn't keep a connection authorised with the admin's grants. The API expires the Mercure cookie on logout and on a failed
/me, so the new connection has no private topics - On a page-data update that changes which component a property holds: the module also re-fetches the on-screen dynamic positions, because the API publishes only the page data. See Dynamic Positions below
- On reconnect: the module sends the last event ID it saw as
last_event_id, and also fetches the page's resources again (see PWA › Offline and Real-Time Updates)
Dynamic Positions
A page-data update can change which component a property holds. The API publishes only the page data, so the module re-fetches the dynamic positions on screen itself:
- An admin is sent each position's
pageDataProperty, so it re-fetches only the positions bound to the changed property. - A visitor isn't told which property a position is bound to, so it re-fetches every dynamic position on screen.
Unpublished Components
The API leaves a component that isn't published yet out of a visitor's component position: the position has no component. The module renders nothing there, with no request, spinner or placeholder. An admin, who can see drafts, still gets the component. See Positions Holding an Unpublished Component.
When the component is published, the API publishes an update for each position that holds it. A visitor already on the page gets the position, and the module fetches the component it now points to, so it's staged like any other update and appears when the visitor clicks Update. A component scheduled to go live later sends no update at that moment: visitors see it on their next page load, and a cached page expires at that time (see How Long a Page Is Kept).
__Secure-mercure_access_token. The cookie is issued at sign-in and at each JWT refresh, so a change to the topics reaches an admin then.The Visitor Notification
When a Mercure update arrives for a resource on the current page, a small notice appears reading The content on this page is outdated, with an Update button. Clicking it calls mergeNewResources(), which applies the staged resources and re-renders.
There is no dismiss control — the notice disappears once the update is applied. An admin sees the notice only when co-editing is off (see Turning Co-Editing Off), rendered inside the admin header rather than floating over the page.
Co-Editing in the Admin
Two admins can edit the same page at once. When one of them saves a change, it appears on the other's screen straight away: in the page, in the manager panel and in the component's admin tabs. There's no notice and no Update button for an admin. Visitors still get The Visitor Notification.
Each admin's browser also tells the others where it is and what it has selected, so admins can see each other before their changes cross.
Who Else Is Here
The admin header shows an avatar for each other admin on the same page, in their colour, with their initials. A number on an avatar counts that admin's tabs. Beside the avatars, +N elsewhere counts the admins on other pages.
Click the avatars for the list. Each row shows the admin's name, their page (This page, or a link to theirs) and what they're doing: Viewing, Selected or Editing a component (named by its type), or Idle. Following a link saves your waiting changes first.
- Idle. An admin whose tab is hidden, or who hasn't used it for 5 minutes, is shown dimmed. Idle admins aren't outlined on the page.
- Your own other windows show as You (other window), in grey, with the browser and system, such as Chrome on macOS. They're never counted as another admin.
- Live editing paused. While the real-time connection is down, the header says so instead of showing avatars. You can keep editing; other admins' changes and who's here catch up when it returns.
What Others Have Selected
In edit mode, a component another admin on the page has selected is outlined in their colour, with a flag naming them, such as Ana Ruiz is editing. The flag fades after 3 seconds and shows again when you hover over the component.
In the manager panel, a field another admin has in focus is outlined the same way, with their name above it. Kit fields given :model do this by themselves; an editor of your own needs the steps in useCwaResourceModel › Your Own Field Editor.
The delete confirmation names anyone who has the component selected, such as Ana Ruiz is editing this.
When You're Both Changing the Same Field
If another admin saves a field that you've changed and haven't saved yet, your value stays in the control and isn't saved, and the page keeps the value from before either change. Beside the control a line reads Ana Ruiz changed this · Use theirs / Keep mine:
- Use theirs drops your change and shows their value.
- Keep mine saves your value over theirs.
Until you choose, your change waits. Publishing counts as Keep mine: your value is saved first. If a newer save from someone else no longer conflicts with yours, your change is saved straight away.
:model.<CwaUiFormInput>, <CwaUiFormToggle> and <CwaUiSelect> take the return of useCwaResourceModel as :model. A control bound with v-model="titleModel.model.value" still waits for a choice, but shows nothing, so the admin's change isn't saved until they publish. See useCwaResourceModel › Co-Editing Conflicts.The line names the admin once their browser has announced itself on the site; before that it says Someone. A change from your own other window says You (other window) changed this. A draft and its live version count as one: a change to either can conflict with your edit to the other.
The same choice appears when your save and theirs cross on the way to the API. Every admin save and delete sends the version of the resource it was based on, in an If-Match header, and the API refuses one based on an older version with a 412 (see API › Versions & Conflicts › Saving with If-Match). The module shows their value with Use theirs / Keep mine, on their draft if their save created one. A refused save that no field holds, such as a delete, shows an error instead: Someone else saved this first, so your change was not saved. Reload to see their version.
Updates can arrive out of order. The module drops an update older than the copy it already has, by the resource's version.
When Someone Removes What You're Working On
When another admin deletes a component, or publishes the draft you're editing (which deletes the draft), the module tells you with an error message:
| What happened | What the module does | Message |
|---|---|---|
| You had unsaved changes to a draft that was published or deleted | Saves them to a new draft of the live version | The draft you were editing was removed by someone else. Your unsaved changes were saved to a new draft. |
| You had unsaved changes to a component with no live version | Drops them | The component you were editing was removed by someone else. Your unsaved changes could not be saved. |
| You were cloning or moving it | Ends clone or move mode | The component being cloned was removed by someone else. (or moved) |
| You had it selected | Deselects it | The draft you had selected was removed by someone else. (or component) |
What Co-Editing Needs
- api-components-bundle
2.0.0-alpha.36or later. The module recognises its own saves coming back over Mercure by_metadata.lastWrite(see Real-Time Updates (Mercure) › Who saved a change:X-CWA-Client-Id), and announces who's here on the admin real-time channel (see Real-Time Updates (Mercure) › The Admin Real-Time Channel), both from bundle alpha.35. It sendsIf-Matchand orders updates by_metadata.version(see API › Versions & Conflicts › The Version Number), from bundle alpha.36. With bundle alpha.35, saves aren't checked against the version, so crossing saves overwrite each other. - The
X-CWA-Client-IdandIf-Matchheaders allowed by the API's CORS config, when the API is on another origin. - The admin real-time channel turned on in the API. If it's off, the module logs Real-time presence is turned off on the API, so other editors will not be shown. once and stops announcing. Saves still appear in place.
X-CWA-Client-Id and If-Match in CORS. Every write a signed-in admin makes (anything but a GET) sends X-CWA-Client-Id, a UUID for the browser tab. Admin saves and deletes also send If-Match once the API returns versions. If the API is on another origin and its CORS allow_headers doesn't list both headers, the browser blocks the preflight and every admin save fails. Add them as shown in Real-Time Updates (Mercure) › Who saved a change: X-CWA-Client-Id and API › Versions & Conflicts › CORS for ETag and If-Match. The template allows both. Visitors don't send either header.Turning Co-Editing Off
export default defineNuxtConfig({
cwa: {
realtime: {
enabled: false
}
}
})
Admins then get the outdated notice like visitors, their writes don't send X-CWA-Client-Id, and the header shows no one else. Saves still send If-Match, and a refused one is an error. See Nuxt Config › Config Key Reference.
Reacting in Your Own Components
onRemoteChange(iri, handler) calls your handler when another admin's save changes a resource, with the fields that changed. See onRemoteChange.
useCwaRealtime(iri) lists the other admins who have a component selected, and sends and hears your own events between them, such as caret positions. See useCwaRealtime.
The template's rich-text editor uses both. See Rich-Text Editor › Editing Together.
Troubleshooting
No live updates arriving in the browser:
- Check that
MERCURE_PUBLIC_URLis reachable from browsers — not an internal Docker hostname - Confirm
mercure: trueis on the PHP entity - Open DevTools → Network → Filter by
EventSource— you should see a persistent connection to the hub - A
400on theEventSourcerequest means the hub and module disagree on the protocol: a 0.x hub rejectsmatch=*, and a 1.0 hub outside compatibility mode rejectstopic=* - Check the hub logs for authentication errors, and the API log for
could not be published - The browser console warns
Cannot initialize Mercure. Hub URL is not set.when no API response named a hub: check that the API sends aLinkheader withrel="mercure". A response without one, such as the resource manifest, is normal.
A 401 from the hub, and signed-in users who get no private updates, are hub and cookie problems. See When Updates Don't Arrive and The Subscriber Cookie.
Updates arrive but the component doesn't re-render:
- Confirm the component uses
useCwaComponent(oruseCwaResource) — both react to store updates - Bare
$fetchcalls are not reactive; usecwa.resources.getResource(iri)for reactive lookups
Advanced: Acting on Other Updates
The module's own subscription is already a wildcard, so there is no extra topic to register — everything published to the hub reaches the browser. What the module doesn't do is act on messages for resources outside the current page: those are filtered out and dropped.
If you need to react to them (a live "new comment" counter, say), open your own EventSource in a Nuxt plugin alongside the module's, with withCredentials: true so the browser sends the Mercure cookie. Use the Mercure 1.0 parameters: match= for an exact topic, or match_urlpattern= for a URL Pattern such as https://www.example.com/_api/_/component_groups/:id. The real constraint is hub-side: private updates only reach you if the cookie grants their topic. The grants are listed as URL Patterns in _metadata.mercureSubscribeTopics on the user from GET /me (…/:id\? for published resources, …/:id\?draft=1 for drafts).
The resources store is reactive, so anything you write into it re-renders the components bound to that IRI, regardless of which connection delivered it. The module writes to the store with storeResource:
const cwa = useCwa()
// Stage it, exactly as the module's own Mercure handler does
cwa.resourcesManager.storeResource({ resource, isNew: true, path })
// Or write it straight into the current page state
cwa.resourcesManager.storeResource({ resource })
storeResource is internal to the module and can change without notice. It only updates the local store and makes no API request, so never use it to save content — use createResource / updateResource / deleteResource or useCwaResourceModel for that.There is no built-in CWA API for registering custom topics today.
Rich-Text Editor
The template's TipTap editor for HTML fields — load it only when an admin edits, keep it out of visitors' prefetch hints, choose its buttons, and edit together with other admins.
Forms
Build form components for Symfony Form types using CWA's form composables — covering every field type with Nuxt UI examples.