⚠️ Work in progress — createCMS is pre-1.0 and not production-ready (not tested in production). Expect breaking changes.
createCMS

Realtime

Live server-to-client delivery over SSE — real-time notification push, Upstash-backed.

Realtime is an optional layer that pushes events from the server to the browser over Server-Sent Events. Turn it on and notifications are pushed to each user the moment they're raised, instead of waiting for a poll. It's Upstash-backed — you configure it with your Upstash Redis credentials.

Enabling it

Pass your Upstash Redis credentials to the realtime option. Upstash Realtime rides Redis over HTTP (no sockets), so it works on serverless and edge runtimes:

import { createCMS } from '@createcms/core';

export const cms = createCMS({
  // …
  // authMiddleware must resolve the user from the request cookie (see below).
  realtime: {
    url: process.env.UPSTASH_REDIS_REST_URL!,
    token: process.env.UPSTASH_REDIS_REST_TOKEN!,
  },
});

@upstash/realtime and @upstash/redis are optional peer dependencies — install them when you enable realtime:

npm install @upstash/realtime @upstash/redis

Without realtime, nothing breaks: the /realtime route is never mounted, and notifications stay available through the durable listNotifications poll — you just don't get live push, and there is no built-in polling hook.

How it works

Enabling realtime mounts a single Server-Sent-Events route at /<basePath>/realtime (e.g. /api/cms/realtime). A client subscribes to one or more channels and receives every event published to them. The CMS's own channel is the per-user notification stream, notif:<userId> — a user's private inbox feed.

The browser only ever talks to that same-origin route — it opens an EventSource to /realtime, and the server is the broker: it authenticates the connection, subscribes to Upstash Redis (pub/sub) with server-side credentials, and relays each message to the browser as SSE. The client never connects to Upstash directly, so the url and token never leave the server.

The stream is best-effort: the durable store stays the source of truth, and the client reconciles with a normal fetch on connect. A dropped event only delays the live update until the next reconcile.

Security model

The /realtime route authenticates every connection and authorizes each requested channel against that identity. A user's private channel notif:<userId> is delivered only to that user — a caller can subscribe to their own and no one else's — and it fails closed: an unauthenticated connection is rejected for a private channel.

Authentication reuses your authMiddleware — it must resolve the user from the request cookie/session, because the browser's EventSource cannot send an Authorization header (it sends cookies only).

Because auth is cookie-based, the realtime route must be same-origin with your app (the common case — your app calls its own /api/cms). Cross-origin cookie auth over SSE is not supported.

Notifications

Real-time notifications are realtime-only and ride one shared connection. Wrap your app once in the RealtimeProvider (it points the connection at your CMS route with credentials), then call useNotifications anywhere:

// app root — one shared connection for every realtime hook
import { RealtimeProvider } from '@createcms/core/react/realtime';

<RealtimeProvider baseURL="/api/cms">{children}</RealtimeProvider>;
import { useNotifications } from '@createcms/core/react/realtime';

function NotificationBell() {
  const { notifications, unreadCount, isLive } = useNotifications(client, {
    userId: currentUser.id,
  });

  return <Badge count={unreadCount} live={isLive} items={notifications} />;
}

When realtime is set, every notification is pushed to its recipient's private channel automatically — all sources funnel through one dispatch. The hook seeds list + unread count from the listNotifications poll, then prepends live pushes de-duped by id; the provider replays anything missed across a reconnect. Pass the current user's id — the server authorizes it against the session, so a wrong id only yields the poll-seeded list, never another user's data.

Pass withUser: true to enrich each notification with actorUser — the user who triggered it, typed off your user config. The live push carries actorUser on the wire, so the actor's name/avatar appear the moment a push lands (no second poll). Every item also carries type, resourceType, resourceId, collection, and a free-form meta, which is everything you need to build a deep link. createNotificationRouter turns those into typed, per-type links:

import { createNotificationRouter } from '@createcms/core/react';
import type { cms } from './cms';

const router = createNotificationRouter<typeof cms>({
  mention: (n) => ({ href: `/threads/${n.meta.threadId}#${n.meta.messageId}` }),
  published: (n) => ({ href: `/${n.collection}/${n.resourceId}` }),
  fallback: (n) => ({ href: n.resourceId ? `/${n.collection}/${n.resourceId}` : null }),
});

const { href } = router.resolve(notification); // typed meta per type (incl. plugin types)

Without realtime there is no live connection — read the durable list yourself with client.notifications.listNotifications and poll on your own cadence. The A/B useLiveResults hook rides the same RealtimeProvider connection.

Disabling notifications

Set notifications: false to remove the feature entirely:

const cms = createCMS({
  // …
  notifications: false,
});

This is a hard, type-level disable: the notification tables aren't generated (regenerate your schema after toggling), the routes never register, and client.notifications plus cms.notify are absent from the inferred types — a stray call is a compile error, not a runtime surprise.

Use a literal false to disable — a widened boolean value keeps the types enabled (the runtime still honours it). notifications and realtime are independent: A/B live results can use realtime with notifications: false.

On this page