AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
Neon Realtime enables developers to create reactive applications and agents by transforming static SQL queries into live, streaming data sources. By utilizing a combination of server-side query sealing and client-side reactivity primitives, it allows UI components and agent states to update automatically as database rows change. This approach eliminates the need for manual polling or complex custom synchronization logic, providing a type-safe, performant way to keep frontends and backend services in sync with the underlying Postgres database.
What it does
- Converts standard SQL queries into live, reactive data streams.
- Provides secure query sealing to prevent unauthorized data access or modification.
- Supports optimistic UI updates to ensure immediate user feedback during data mutations.
- Integrates with popular ORMs like Drizzle and Kysely for seamless development.
- Offers React-specific hooks for binding live database state to UI components.
- Enables efficient synchronization for dashboards, feeds, and multi-user collaboration.
When to use it
- When building dashboards or feeds that require live data updates.
- When implementing collaborative features that need multi-user or multi-device synchronization.
- When you want to replace inefficient polling mechanisms with a push-based sync system.
- When developing optimistic UI patterns to improve perceived application performance.
- When building AI agents that need to react to real-time changes in database state.
Advantages
- Reduces network overhead by replacing polling with WebSocket-based streaming.
- Simplifies state management by keeping UI in sync with database transactions.
- Enhances user experience through instant, optimistic UI updates.
- Maintains security by authorizing and sealing queries on the backend.
- Works with existing SQL knowledge and familiar ORM workflows.
Usage examples
- “How do I make a list of messages in my chat app update in real-time?”
- You define a sealed SQL query on your backend that selects messages for a specific channel, then use the useLiveQuery hook in your React frontend to subscribe to that query and automatically update the message list as new rows are inserted.
- “How can I implement optimistic updates for a user action?”
- You perform the write operation through your backend, return the transaction ID, and use the awaitTxId utility on the client to wait for the database confirmation while immediately updating the local UI state.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/neondatabase/neon-realtime/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip neon-realtime.zip -d .agents/skills
Documentation by Neon
FIRST: Use the parent neon skill for a Neon overview, getting started with Neon, Neon development best practices, and more.
If the neon skill is not installed, fetch it from https://neon.com/docs/ai/skills/neon/SKILL.md or install it with:
neon skills -s neon -yNeon Realtime
Neon's Realtime makes any SQL query live. Subscribe in realtime to your Lakebase Postgres database. Develop apps and agents with end-to-end reactivity using type-safe live queries and optimistic mutations.
Realtime is new and in beta, so its APIs aren't in your training data. Start from the core pattern below, and read each section's reference file before writing that code.
How It Works
Realtime's layers compose: a live query engine inside Postgres, the Live SQL API (a WebSocket API), lower-level client SDKs (seal(), subscribe()), and reactivity primitives such as useLiveQuery.
In trusted backend code, define a live query (any read-only SQL, including joins, aggregates, ordering and limits), seal it with
NEON_REALTIME_SECRETand expose it from an authenticated endpoint or server function. The client can't read or change a sealed query's SQL or parameters.The client subscribes with the sealed query. It receives the full initial result, then each committed transaction's changes as one batch. Results stay consistent, but delivery of every change event isn't guaranteed.
Writes go through your own API, which returns each write's
txidforawaitTxId().
Core Pattern
The guides' chat app, with messages authorized through channel_members. The backend seals the query and returns each write's txid:
import { createRealtime, rawSql } from "@neon/realtime/server"
import { drizzleAdapter } from "@neon/realtime-drizzle"
import { and, desc, eq, getTableColumns, sql } from "drizzle-orm"
import { Hono } from "hono"
import { HTTPException } from "hono/http-exception"
import { requireUser } from "./auth"
import { db } from "./db"
import { channelMembers, messages, type Message } from "./schema"
export const realtime = createRealtime({ secret: process.env.NEON_REALTIME_SECRET!, db: "neondb", adapter: drizzleAdapter() })
// Each selected key must match its column name
export function messagesQuery(userId: string, channelId: number) {
return db
.select(getTableColumns(messages)).from(messages)
.innerJoin(channelMembers, eq(channelMembers.channel_id, messages.channel_id))
.where(and(eq(channelMembers.user_id, userId), eq(messages.channel_id, channelId)))
.orderBy(desc(messages.created_at))
.limit(50)
}
// Or raw SQL
const rawMessagesQuery = (userId: string, channelId: number) => rawSql<Message>(
`SELECT messages.author_id, messages.body, messages.channel_id, messages.created_at, messages.id FROM messages
INNER JOIN channel_members ON channel_members.channel_id = messages.channel_id WHERE channel_members.user_id = $1 AND messages.channel_id = $2 ORDER BY messages.created_at DESC LIMIT 50`,
[userId, channelId]
)
// The id of the transaction that made the change, read in the write's own RETURNING
const txid = sql<string>`pg_current_xact_id()::text`
const app = new Hono()
.post("/api/v1/messages/live", async (c) => {
const user = await requireUser(c)
const { channelId } = await c.req.json<{ channelId: number }>() // validate input
return c.json(await realtime.seal({ query: messagesQuery(user.id, channelId) }))
})
.post("/api/v1/messages", async (c) => {
const user = await requireUser(c)
const { id, channelId, body } = await c.req.json<{ id: string; channelId: number; body: string }>() // validate input
// One statement. The membership row supplies channel_id and author_id, so only a member's message is inserted.
// List every column in table order, alias each sql field, and cast untyped params (they resolve to text)
const [sent] = await db.insert(messages).select(
db.select({
id: sql`${id}::uuid`.as("id"), channel_id: channelMembers.channel_id, author_id: channelMembers.user_id,
body: sql`${body}`.as("body"), created_at: sql`now()`.as("created_at"),
})
.from(channelMembers)
.where(and(eq(channelMembers.channel_id, channelId), eq(channelMembers.user_id, user.id)))
).returning({ txid })
if (!sent) throw new HTTPException(403, { message: "Not a member of this channel" })
return c.json({ kind: "committed", txid: sent.txid })
})The React frontend subscribes and sends optimistic messages:
import { createRealtimeClient, type SealedLiveQuery } from "@neon/realtime/client"
import { drizzleParsers } from "@neon/realtime-drizzle/client"
import { useLiveQuery } from "@neon/realtime-react"
import { startTransition, useCallback, useOptimistic } from "react"
import type { Message } from "../backend/schema"
import { fetchSealedMessages, sendMessage } from "./api"
// One client per app, because subscriptions share its connection. Wrap the app in <RealtimeProvider client={client}>
export const client = createRealtimeClient({ url: import.meta.env.VITE_NEON_REALTIME_URL, parsers: { ...drizzleParsers } })
const NO_MESSAGES: readonly Message[] = []
type Props = { sealedQuery: SealedLiveQuery<Message>; channelId: number; userId: string }
export function Messages({ sealedQuery, channelId, userId }: Props) {
const refreshQuery = useCallback(() => fetchSealedMessages(channelId), [channelId])
const { data, status, error, utils } = useLiveQuery(sealedQuery, { refreshQuery })
// Once the sent message syncs, show only the synced row
const [messages, addOptimistic] = useOptimistic(data ?? NO_MESSAGES, (current, message: Message) =>
current.some(({ id }) => id === message.id) ? current : [message, ...current]
)
function send(body: string) {
// The server sets author and timestamp but keeps this id
const message = { id: crypto.randomUUID(), channel_id: channelId, author_id: userId, body, created_at: new Date() }
startTransition(async () => {
addOptimistic(message)
try {
const result = await sendMessage(message) // maps channel_id to channelId
if (result.kind === "committed") await utils.awaitTxId(result.txid, 10_000)
} catch { /* show "Couldn't confirm the change" */ }
})
}
if (status === "error") return <p role="alert">Live updates stopped: {error.message}</p>
if (data === undefined) return <p>Loading messages</p>
return (
<form action={(form) => send(String(form.get("body")))}>
<input name="body" required />
<ul>{messages.map((message) => <li key={message.id}>{message.body}</li>)}</ul>
</form>
)
}Choose the Stack
A live query replaces fetching app data from an API or another sync system. Reuse existing query code, and use the highest layer that fits rather than wiring changes into app state by hand.
| Use | For |
|---|---|
drizzleAdapter() from @neon/realtime-drizzle, or kyselyAdapter() from @neon/realtime-kysely | Queries written in Drizzle or Kysely |
rawSql from @neon/realtime/server | Any other ORM, or none |
useLiveQuery from @neon/realtime-react | React. Binds one sealed query to a component's state |
realtimeCollectionOptions from @neon/realtime-tanstack | Many views over the same data, or an app on TanStack DB. Oversync one broader query into a collection, then derive local live queries from it |
client.subscribe() from @neon/realtime/client | Other frameworks |
realtime.subscribe() from @neon/realtime/server | Agents and workers, in trusted backend code |
Migrating from Electric: Realtime is a drop-in replacement for Electric's role in your stack. Live SQL queries replace shapes, and realtimeCollectionOptions() replaces electricCollectionOptions().
Setup
New app:
neon bootstrap my-app --template realtime-react, thenneon deployinmy-app.Enable it on the linked branch with
neon realtime enable --allowed-origin <app origin>, or declarepreview: { realtime: true }inneon.ts(neon config add realtime) and runneon deploy. Child branches inherit it. It's unavailable on Claimable projects until claimed, or outside the supported regions.Tables need a stable replica identity; a primary key is enough. Leave row-level security off on every table a live query reads: Realtime rejects those subscriptions with
unsupported query: row security. For per-user data, authorize in the sealed query instead (see below).Environment:
neon env pull --service postgres --service realtimewritesDATABASE_URL,NEON_REALTIME_URLandNEON_REALTIME_SECRET. Expose only the URL to the browser, under your framework's public prefix (likeVITE_).urloptions takeNEON_REALTIME_URL;dbtakes the database name fromDATABASE_URL.Install
@neon/realtimeand the packages chosen above. Use these SDKs, not the still-evolving WebSocket protocol.
Define and Seal Queries
Read references/queries.md before writing query or sealing code, including Kysely and server functions.
Read-only and deterministic. A raw query is a single
SELECTwith no;and$1placeholders. It can't callnow()orgen_random_uuid(), so pass such values as parameters, wrapping every value except strings, numbers, booleans, bigints and null in apgParamhelper (pgParam.timestamptz(date)).Authorize in the query. Check the session before sealing, and join through your membership tables so revoked access drops out. Any web framework auth works, including Managed Better Auth, but not RLS yet. See Authorization.
A sealed query is a bearer token. Keep it out of URLs, logs and persistent storage, and never fill its parameters from unchecked input.
Check a query before wiring it in:
neon realtime subscribe "SELECT ..."prints the initial result, then each transaction's changes, until you stop it. With--output json, each change prints a full snapshot. SetdebugModeoncreateRealtimefor full database errors (development only).Sealed queries expire. Pass
refreshQueryto the bindings or callrenew(), sealing the same query and parameters, or the subscription errors. New parameters need a new sealed query and subscription. In Next.js, renew from a Route Handler, because server actions run one at a time.
Subscribe
Read references/reactivity.md before writing React or TanStack DB code. Read references/subscribing.md before subscribing directly with client.subscribe(sealed) (getSnapshot, onChange, onStateChange), or from agents and workers, which add url to createRealtime and await realtime.subscribe(query) to seal in-process.
One database per client. The first sealed query binds the connection to its
db.Select a stable, unique key for every row and key rows by it, including TanStack DB's
getKey: usually the primary key, the grouped columns for aggregates, or the combined keys for joins that repeat a row. Never userowId, which everyrowsupdate invalidates.erroris final, even afterrenew(). Fetch a fresh sealed query and subscribe again, with a new client if the connection failed. WatchonStateChangeon direct subscriptions;useLiveQuery()reports errors throughstatus, not by throwing or suspending.When the session changes, leave with a full page load. It drops the Realtime client, any TanStack
DbClientand the previous user's rows. The hook retries a failed renewal every second, so stop renewing once one fails as signed out. Check on every renewal and write that the session's user is the one the page was loaded for, or a write from the old page lands in the new user's account. See When the session changes.
Optimistic Writes
Realtime has no write path, so writes keep your backend's usual auth. Read references/mutations.md before writing mutations, for each layer.
Return the txid from the write itself:
.returning({ txid: sql<string>`pg_current_xact_id()::text` }), in one round trip on any Postgres driver. Write a batch in one statement, so there's one txid. Never opendb.transaction()just toselect pg_current_xact_id(): that's four round trips holding a pooled connection. When a write genuinely needs several statements, put the txid in theRETURNINGof a write that always changes a row.Always pass
awaitTxId()a timeout: it keeps waiting through reconnects and renewals, and never settles if the txid doesn't reach the query.A write that changes no rows has no txid, so skip
awaitTxId()and let the optimistic state drop.
Server-Side Rendering
Read references/ssr.md before rendering live queries on the server, for Next.js and TanStack Start.
Create the client in a
useStateinitializer rather than at module level, renderinitialDataas live rows without a loading state, and never share a TanStackDbClientbetween requests.Keep row shapes identical on both sides.
JSON.stringifyturns dates into strings, andint8andnumericarrive as strings.
More Docs
| Task | Page |
|---|---|
| Beta scope, regions, pricing, FAQ, other systems | Overview |
| Architecture and consistency | How it works |
| A first live query | Quickstart |
| Every SDK export | TypeScript SDK reference |
| Full-stack apps with auth and SSR | Next.js, TanStack |
| Live agent workflows | Mastra |
| ORM adapters | Drizzle, Kysely |
Files
- ATTRIBUTION.mdAdded by AutoGineer870 B
- LICENSELicence file added by AutoGineer11.1 KiB
- SKILL.md15.0 KiB
- references/mutations.md32.4 KiB
- references/queries.md24.1 KiB
- references/reactivity.md19.7 KiB
- references/ssr.md32.6 KiB
- references/subscribing.md25.3 KiB
references/reactivity.md
<!-- Generated by scripts/sync-skill-docs.mjs from website@docs/neon-auth-package-rename:bf3595986 (docs/realtime/guides/reactivity). Do not edit: change the docs and run npm run sync:docs. sha256:8af3b50922c0df744e30924ee5065e06209115ee44b509cdbdea1ac6c005eb7a -->
Canonical page: https://neon.com/docs/realtime/guides/reactivity.md
Summary: Bind live queries to React component state with useLiveQuery, change their parameters and handle errors, or sync a broader live query into a TanStack DB collection and derive instant, local queries from it.
Client-side reactivity
Bind live queries to component and agent state.
Contents
React
RealtimeProvider
useLiveQuery
Changing query parameters
Handling errors
TanStack DB
Seal a broader query
Create the collection
Query the collection
Manage optimistic state
Other frameworks
Build reactive apps and agents using higher-level reactivity primitives that manage live query subscriptions for you.
Important: Framework support
Realtime currently has integrations for React and TanStack DB.
The React binding keeps a component's state in step with a live query, and the TanStack DB binding syncs live queries into a reactive client-side store.
This page continues the running example from the queries and subscribing guides. It uses the client from frontend/client.ts and fetchSealedMessages() from frontend/api.ts.
React
The @neon/realtime-react binding exports a RealtimeProvider, a useLiveQuery() hook and a useRealtimeClient() hook that returns the provider's client. It works with React 18 and 19.
Install it alongside the client SDK:
npm install @neon/realtime-reactRealtimeProvider
Wrap your app in <RealtimeProvider>. This allows all of your useLiveQuery() subscriptions to use the same RealtimeClient and WebSocket connection.
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { RealtimeProvider } from "@neon/realtime-react"
import { client } from "./client"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RealtimeProvider client={client}>
{/* Your app here */}
</RealtimeProvider>
</StrictMode>
)client is the module singleton from frontend/client.ts, created once for the whole app. If you render on the server, create the client in the browser instead, as the server-side rendering guide explains.
useLiveQuery
useLiveQuery() takes a sealedQuery and a refreshQuery (and, optionally, initialData from SSR) and returns the query's current rows and state.
For example, here we define a Messages component with useLiveQuery:
import { useEffect } from "react"
import { useLiveQuery } from "@neon/realtime-react"
import type { SealedLiveQuery } from "@neon/realtime/client"
import type { Message } from "../backend/schema"
import { LocalTime } from "./LocalTime"
interface Props {
sealedQuery: SealedLiveQuery<Message>;
refreshQuery: () => Promise<SealedLiveQuery<Message>>;
initialData?: readonly Message[];
}
export function Messages({ sealedQuery, refreshQuery, initialData }: Props) {
const { data, status, error, utils } = useLiveQuery(sealedQuery, { refreshQuery, initialData })
useEffect(() => {
return utils.onUpdate((update) => {
if (update.type === "changes") {
console.debug(`Applied ${update.changes.length} changes from transactions ${update.batch.txids.join(", ")}`)
}
})
}, [utils])
if (status === "error") {
return <p role="alert">Live updates stopped: {error.message}</p>
}
if (data === undefined) {
return <p>Loading messages</p>
}
return (
<section>
{status === "stale" && <p role="status">Updating</p>}
<ul>
{data.map((message) => (
<li key={message.id}>
<LocalTime date={message.created_at} />
<p>{message.body}</p>
</li>
))}
</ul>
</section>
)
}Which uses this LocalTime component to format the time after hydration:
import { useEffect, useState } from "react"
export function LocalTime({ date }: { date: Date }) {
const [formatted, setFormatted] = useState<string>()
useEffect(() => setFormatted(date.toLocaleTimeString()), [date])
return <time dateTime={date.toISOString()}>{formatted}</time>
}useLiveQuery subscribes when the component mounts, re-renders it whenever the results change and unsubscribes when it unmounts.
In this example, the sealed query is a SealedLiveQuery<Message>, so data is a readonly Message[], with created_at decoded to a Date. It's undefined until the first result arrives. status is the subscription's state: the component shows a loading line until the first result and a small indicator while it's stale.
refreshQuery
refreshQuery is how the useLiveQuery hook renews the sealed query.
Sealed queries are short-lived, so the hook calls it 10 seconds before expiresAt. If a call fails, the hook retries every second until one succeeds. The callback has to return a sealed query for the same exact query, so Channel builds it from the same channelId the sealed query was sealed with, as changing query parameters shows. Changing only the callback doesn't restart the subscription, and useCallback keeps it stable across renders. Without refreshQuery, nothing renews the sealed query, and the rows go stale when it expires.
initialData
The hook also accepts an optional initialData parameter, for example from a server render. See the server-side rendering guide for more details.
utils
The utils in the return value give you access to the hook's underlying materialized subscription.
This provides getSnapshot(), getState(), awaitRows(), the onChange, onUpdate and onStateChange listeners, renew() and awaitTxId(). It's stable for the life of the subscription, so register listeners in an effect and return the remover, as the onUpdate listener does.
Note that a changes update has already been applied by the time its listener runs, and it carries the txids of the transactions in it, which the mutations guide uses to confirm optimistic writes.
utils omits unsubscribe(), because React owns cleanup. The hook unsubscribes when the component unmounts or its sealed query changes. getState() and the listeners report the state of the underlying subscription, so they can differ from the hook's status.
Changing query parameters
The hook takes a sealed query, not parameters. Parameters are sealed into the query on the server, where the client can't change them. To show a different channel, fetch a new sealed query for it and pass that in. A new sealedQuery is a new logical subscription: the hook cleans up the old one and subscribes to the new one.
Fetch the sealed query in the component that owns the parameter. useSealedQuery() fetches one whenever channelId changes, and Channel renders Messages once it has one:
import { useCallback, useEffect, useState } from "react"
import type { SealedLiveQuery } from "@neon/realtime/client"
import type { Message } from "../backend/schema"
import { fetchSealedMessages } from "./api"
import { Messages } from "./Messages"
interface SealedChannel {
channelId: number;
sealedQuery?: SealedLiveQuery<Message>;
error?: unknown;
}
function useSealedQuery(channelId: number): SealedChannel | undefined {
const [sealed, setSealed] = useState<SealedChannel>()
useEffect(() => {
let cancelled = false
fetchSealedMessages(channelId).then(
(sealedQuery) => {
if (!cancelled) setSealed({ channelId, sealedQuery })
},
(error: unknown) => {
if (!cancelled) setSealed({ channelId, error })
}
)
return () => {
cancelled = true
}
}, [channelId])
// Never hand the previous channel's sealed query to the new channel
return sealed?.channelId === channelId ? sealed : undefined
}
export function Channel({ channelId }: { channelId: number }) {
const sealed = useSealedQuery(channelId)
// Renewals must be for the same query, so refresh with the same channel
const refreshQuery = useCallback(() => fetchSealedMessages(channelId), [channelId])
if (sealed?.sealedQuery) {
return <Messages sealedQuery={sealed.sealedQuery} refreshQuery={refreshQuery} />
}
if (sealed?.error) {
return <p role="alert">Couldn't open this channel</p>
}
return <p>Loading messages</p>
}When the channel changes, sealed still holds the previous channel's sealed query until the new one arrives. Without the channelId check, Messages would get the old channel's sealed query with a refreshQuery for the new channel. That refreshQuery would then fetch the new channel's query to renew the old subscription, and the renewal would be rejected with status error. The cancelled flag covers a second race. It drops a response that arrives after the channel has changed, or after Strict Mode's extra effect run in development.
Handling errors
useLiveQuery() doesn't throw for subscription errors, and it doesn't suspend. It reports everything through status. Render loading and error states from it, as Messages does. The hook throws only when it's used outside a <RealtimeProvider>, when the sealed query is malformed or when the client is closed. Those errors reach the nearest error boundary, not status.
A failed renewal doesn't end in error. If your endpoint can't return a sealed query, for example because the user has signed out and it answers 401, the hook keeps retrying, and once the sealed query expires the status is stale until a renewal succeeds. So drive sign-out from your auth state: when the user signs out, unmount the components that subscribe. The status becomes error only when a renewal returns a malformed response or a sealed query for a different query. To recover, fetch a new sealed query and pass it in.
As with a parameter change, the new sealed query restarts the subscription. The subscribing guide covers recovery at the subscription level.
TanStack DB
Each useLiveQuery() above subscribes to one sealed query, so each view of the data needs its own sealed query and its own round trip to your server.
With TanStack DB, you can oversync a broader live query into a client-side collection, such as every message in every channel the user belongs to, and then derive as many local live queries from it as you need: filters, sorts and joins across collections.
Install the TanStack binding and TanStack DB's React package:
npm install @neon/realtime-tanstack @tanstack/react-dbSeal a broader query
Add inboxQuery() to backend/queries.ts. It's messagesQuery(), with the same membership join but no channel filter. So it returns the newest 500 messages across every channel that the user belongs to:
export function inboxQuery(userId: string) {
return db
.select(getTableColumns(messages))
.from(messages)
.innerJoin(channelMembers, eq(channelMembers.channel_id, messages.channel_id))
.where(eq(channelMembers.user_id, userId))
.orderBy(desc(messages.created_at))
.limit(500)
}Then expose it. In backend/server.ts, import inboxQuery alongside messagesQuery and chain one more route onto the app. It takes no body, because its only parameter comes from the verified token:
.post("/api/v1/inbox/live", async (c) => {
const user = await requireUser(c)
const sealedQuery = await realtime.seal({ query: inboxQuery(user.id) })
return c.json(sealedQuery)
})Create the collection
On the frontend, add fetchSealedInbox() to frontend/api.ts, next to fetchSealedMessages(). As before, the as restores the row type:
export async function fetchSealedInbox(): Promise<SealedLiveQuery<Message>> {
const res = await server.api.v1.inbox.live.$post()
if (!res.ok) {
throw new Error(`Could not fetch the inbox query (${res.status})`)
}
return (await res.json()) as SealedLiveQuery<Message>
}Then create the collection. realtimeCollectionOptions() takes the standard TanStack DB collection options plus the Realtime client and a sealed query, and returns options for createCollection():
import { createCollection } from "@tanstack/react-db"
import { realtimeCollectionOptions } from "@neon/realtime-tanstack"
import type { SealedLiveQuery } from "@neon/realtime/client"
import type { Message } from "../backend/schema"
import { fetchSealedInbox } from "./api"
import { client } from "./client"
export function createMessagesCollection(sealedQuery: SealedLiveQuery<Message>) {
return createCollection(
realtimeCollectionOptions({
id: "messages",
client,
query: sealedQuery,
refreshQuery: fetchSealedInbox,
getKey: (message) => message.id
})
)
}
export type MessagesCollection = ReturnType<typeof createMessagesCollection>getKey returns each message's primary key. TanStack DB keys rows by it, so use a stable, unique column, never a subscription's rowId. refreshQuery renews the sealed query the same way the React hook does. While renewals fail, the collection stays ready with its last rows. See the realtimeCollectionOptions() reference for the rest of the options.
The collection starts syncing as soon as a live query using it is mounted. It's in the loading state until the first result arrives, and ready from then on, with each committed transaction applied atomically. A ready collection stays ready while the subscription is connecting or stale, so there's no separate recovery flag to render. If the subscription ends in error, so does the collection.
Query the collection
Create the collection once, when the app starts. The collection takes the client directly, but Channel still calls useLiveQuery(), so keep both views inside RealtimeProvider:
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { RealtimeProvider } from "@neon/realtime-react"
import { fetchSealedInbox } from "./api"
import { authClient } from "./auth"
import { Channel } from "./Channel"
import { client } from "./client"
import { createMessagesCollection } from "./collections"
import { Inbox } from "./Inbox"
const root = createRoot(document.getElementById("root")!)
const { data: session } = await authClient.getSession()
if (!session) {
root.render(<p>Sign in to see the messages.</p>)
} else {
const messagesCollection = createMessagesCollection(await fetchSealedInbox())
root.render(
<StrictMode>
<RealtimeProvider client={client}>
<Channel channelId={2} />
<Inbox messagesCollection={messagesCollection} channelId={2} />
</RealtimeProvider>
</StrictMode>
)
}Sign-in isn't part of these guides: build the page with Better Auth UI, as the running example does by rendering <Auth> on /auth/* before it checks the session.
Then read from the collection with TanStack's own useLiveQuery(). It shares a name with the Realtime hook, so import it under an alias that won't clash in an app that uses both. This view shows one channel, newest first:
import { eq, useLiveQuery as useCollectionQuery } from "@tanstack/react-db"
import type { MessagesCollection } from "./collections"
interface Props {
messagesCollection: MessagesCollection;
channelId: number;
}
export function Inbox({ messagesCollection, channelId }: Props) {
const { data, isReady, isError } = useCollectionQuery({
query: (q) =>
q
.from({ message: messagesCollection })
.where(({ message }) => eq(message.channel_id, channelId))
.orderBy(({ message }) => message.created_at, "desc")
})
if (isError) {
return <p role="alert">Couldn't sync messages</p>
}
if (!isReady) {
return <p>Loading messages</p>
}
return (
<ul>
{data.map((message) => (
<li key={message.id}>{message.body}</li>
))}
</ul>
)
}Pass a different channelId and the query re-runs in the browser, against the rows the collection already holds. Local queries only see what the collection holds, so a channel whose messages fall outside the newest 500 shows fewer rows, or none. There's no new sealed query and no round trip to your server, unlike changing the parameters of a useLiveQuery() hook. Every view you add, like an unread count or a search across channels, is another local query over the same synced rows.
For server rendering, the server-side rendering guide seeds a collection on the server and hydrates it in the browser, and the TanStack example builds a full app with TanStack Start.
Manage optimistic state
Once you have a TanStack DB collection defined, you can also use it for local optimistic writes. It's often easier in a complex app to normalize data loading into a collection which can have optimistic state applied to it once, than to have to remember to apply optimistic state to a range of different live query subscriptions.
For writes through the collection, with optimistic state, see the mutations guide.
Other frameworks
Bindings for other frameworks are coming. Until then, you can use the current implementations as a reference to write your own.
The React binding is a thin layer over the client subscription: useSyncExternalStore plus the QueryRefreshController. Point a coding agent at its source and it can reproduce the binding for other frameworks, such as Solid, Svelte or Vue.
You can also see the source code for the TanStack DB collection here.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs network access
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- Neon
- Publisher
- Neon (Official)
- Source
- neondatabase/agent-skills · skills/neon-realtime · commit 2b0e2b6
- Licence
- Apache License 2.0 (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
- Also distributed in
- neondatabase/agent-skills
AutoGineer is not affiliated with or endorsed by Neon or Neon.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download neon-realtime.zip