CWA 2 is a prerelease (2.0.0-alpha), heading for a beta. It runs production sites; configuration can still change between releases, always with upgrade notes. View versions and support
Component Helpers

Real-Time Updates in Components

How Mercure broadcasts live content changes to all open browser sessions automatically when a component is published.

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

  1. An admin publishes a component via the CMS
  2. The Symfony API sends an update to the Mercure hub (because mercure: true is on the entity)
  3. The hub broadcasts the update to all subscribed browser connections
  4. The Nuxt module receives the event and stages the new resource in the store — the rendered page is untouched
  5. A small "content is outdated" notice appears
  6. When the reader accepts, mergeNewResources() applies the staged resources and Vue re-renders

You write none of this plumbing. It's in the module.

Staging is deliberate: content is never swapped out from under someone mid-read. Nothing on the page changes until the update is accepted.

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.

The hub must be Mercure 1.0 (FrankenPHP 1.13 or later). The module subscribes with the Mercure 1.0 protocol, and a 0.x hub rejects the subscription, so live updates stop.

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 EventSource connection to MERCURE_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 against cwa.resources.currentIds client-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).

Admins not seeing live updates to drafts? Updates to drafts and to resources that aren't live yet are private. They only reach the topics listed in the admin's Mercure cookie, __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.
Because the subscription is a wildcard, every public update published anywhere on the site, and every private update the visitor's cookie allows, is delivered to every connected browser before being filtered. Worth knowing when sizing the hub.

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.

VIDEO: Two browser windows side by side, both signed in as admins on the same page. Typing a new title in one window's admin tab updates the heading in the other window a moment later, with no outdated notice

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.
IMAGE: The admin header with two coloured avatars (initials "AR" and "BK"), "+1 elsewhere" beside them, and the open list below: "Ana Ruiz · This page · Editing Title", "Ben Kay · This page · Selected Image", "Cara Moss · About us · Viewing", and a grey "You (other window) · Firefox on macOS" row

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.

IMAGE: A page in edit mode with a Title component outlined in teal and a teal flag "Ana Ruiz is editing", and in the open manager panel, the Title input outlined in purple with the flag "Ben Kay"

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.

The line only shows on a control bound with :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.
IMAGE: An admin tab's Title input holding the admin's own text, with "Ana Ruiz changed this · Use theirs / Keep mine" in amber beside it

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 happenedWhat the module doesMessage
You had unsaved changes to a draft that was published or deletedSaves them to a new draft of the live versionThe 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 versionDrops themThe component you were editing was removed by someone else. Your unsaved changes could not be saved.
You were cloning or moving itEnds clone or move modeThe component being cloned was removed by someone else. (or moved)
You had it selectedDeselects itThe draft you had selected was removed by someone else. (or component)

What Co-Editing Needs

  • api-components-bundle 2.0.0-alpha.36 or 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 sends If-Match and 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-Id and If-Match headers 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.
Allow 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

nuxt.config.ts
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:

  1. Check that MERCURE_PUBLIC_URL is reachable from browsers — not an internal Docker hostname
  2. Confirm mercure: true is on the PHP entity
  3. Open DevTools → Network → Filter by EventSource — you should see a persistent connection to the hub
  4. A 400 on the EventSource request means the hub and module disagree on the protocol: a 0.x hub rejects match=*, and a 1.0 hub outside compatibility mode rejects topic=*
  5. Check the hub logs for authentication errors, and the API log for could not be published
  6. The browser console warns Cannot initialize Mercure. Hub URL is not set. when no API response named a hub: check that the API sends a Link header with rel="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 (or useCwaResource) — both react to store updates
  • Bare $fetch calls are not reactive; use cwa.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.