Generated banner for Neon Realtime
Generated logo for Neon Realtime

Neon Realtime

NeonOfficialPublished by Neon

  • Skill
  • Tool integration
  • Data and AI
  • Apache-2.0
  • No issues found

From the author

Neon Realtime makes any SQL query live. Build realtime, reactive apps and agents on Lakebase Postgres with live SQL queries and reactivity primitives. Use when app UI or agent state must update live from database rows (dashboards, feeds, collaboration, multi-user or multi-device sync), for optimistic mutations, or to replace polling or other realtime sync systems. Triggers include "live query", "subscribe", "sync engine", "useLiveQuery", "TanStack DB", "optimistic updates", "@neon/realtime". For push that doesn't come from database rows (presence, typing, LLM streams), use neon-functions.
— the description by Neon

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

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/neondatabase/neon-realtime/download
  2. Unzip 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 -y

Neon 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.

  1. In trusted backend code, define a live query (any read-only SQL, including joins, aggregates, ordering and limits), seal it with NEON_REALTIME_SECRET and expose it from an authenticated endpoint or server function. The client can't read or change a sealed query's SQL or parameters.

  2. 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.

  3. Writes go through your own API, which returns each write's txid for awaitTxId().

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.

UseFor
drizzleAdapter() from @neon/realtime-drizzle, or kyselyAdapter() from @neon/realtime-kyselyQueries written in Drizzle or Kysely
rawSql from @neon/realtime/serverAny other ORM, or none
useLiveQuery from @neon/realtime-reactReact. Binds one sealed query to a component's state
realtimeCollectionOptions from @neon/realtime-tanstackMany 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/clientOther frameworks
realtime.subscribe() from @neon/realtime/serverAgents 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, then neon deploy in my-app.

  • Enable it on the linked branch with neon realtime enable --allowed-origin <app origin>, or declare preview: { realtime: true } in neon.ts (neon config add realtime) and run neon 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 realtime writes DATABASE_URL, NEON_REALTIME_URL and NEON_REALTIME_SECRET. Expose only the URL to the browser, under your framework's public prefix (like VITE_). url options take NEON_REALTIME_URL; db takes the database name from DATABASE_URL.

  • Install @neon/realtime and 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 SELECT with no ; and $1 placeholders. It can't call now() or gen_random_uuid(), so pass such values as parameters, wrapping every value except strings, numbers, booleans, bigints and null in a pgParam helper (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. Set debugMode on createRealtime for full database errors (development only).

  • Sealed queries expire. Pass refreshQuery to the bindings or call renew(), 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 use rowId, which every rows update invalidates.

  • error is final, even after renew(). Fetch a fresh sealed query and subscribe again, with a new client if the connection failed. Watch onStateChange on direct subscriptions; useLiveQuery() reports errors through status, not by throwing or suspending.

  • When the session changes, leave with a full page load. It drops the Realtime client, any TanStack DbClient and 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 open db.transaction() just to select pg_current_xact_id(): that's four round trips holding a pooled connection. When a write genuinely needs several statements, put the txid in the RETURNING of 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 useState initializer rather than at module level, render initialData as live rows without a loading state, and never share a TanStack DbClient between requests.

  • Keep row shapes identical on both sides. JSON.stringify turns dates into strings, and int8 and numeric arrive as strings.

More Docs

TaskPage
Beta scope, regions, pricing, FAQ, other systemsOverview
Architecture and consistencyHow it works
A first live queryQuickstart
Every SDK exportTypeScript SDK reference
Full-stack apps with auth and SSRNext.js, TanStack
Live agent workflowsMastra
ORM adaptersDrizzle, Kysely

Files

references/queries.md

<!-- Generated by scripts/sync-skill-docs.mjs from website@docs/neon-auth-package-rename:bf3595986 (docs/realtime/guides/queries). Do not edit: change the docs and run npm run sync:docs. sha256:ece6ed71de561220661f3b36acb1c6721884a650d2229d65f96afd305b1afcfd -->

Canonical page: https://neon.com/docs/realtime/guides/queries.md

Summary: Define live queries in your backend with raw SQL or Drizzle, fill in their parameters from the authenticated request, and seal them so clients can subscribe without being able to read or change them.

Defining queries

Define live SQL queries that you can subscribe to.

Contents

  • Setup

  • Defining live queries

    • Table requirements

    • Using raw SQL

    • Using a query builder or ORM

  • Sealing queries

    • Renewing sealed queries

  • Authorization

    • Sealing authorized queries

    • Live authorization

    • Why not row-level security

  • Exposing queries

    • Exposing an endpoint

    • Using server functions

Realtime works by defining and subscribing to live SQL queries. Queries are designed to be defined securely in your backend code and subscribed‑to safely in your frontend.

Define live queries using raw SQL or a query builder or ORM. Seal the queries and expose them to your frontend via an API endpoint or a server function, using your normal authorization context.

Setup

The guides all share one running example: a chat app where users read the messages in the channels they belong to. You can see the source code in the examples repo or expand the steps below.

<details>

<summary>Setup steps</summary>

Install Drizzle with the node-postgres driver (pg), Hono, ws, and jose, which validates Better Auth tokens:

npm install drizzle-orm pg hono jose ws
npm install -D @types/pg @types/ws

Install the Realtime TypeScript SDK and its Drizzle adapter:

npm install @neon/realtime @neon/realtime-drizzle

Install the Managed Better Auth client for the frontend:

npm install @neon/auth

Add a neon.ts file that enables Managed Better Auth and Realtime on your branch:

import { defineConfig } from "@neon/config/v1"

export default defineConfig({
  auth: true,
  preview: { realtime: { allowedOrigins: ["http://localhost:5173"] } }
})

List every origin your browser app runs on in allowedOrigins, for example http://localhost:3000 for the SSR frameworks.

Install the config package, link the directory to your Neon project and deploy the config:

npm install -D @neon/config
neon link
neon deploy

neon deploy enables Managed Better Auth and Realtime, waits until they're ready, and writes the four server variables below to .env.local. If Realtime isn't ready before the deploy stops waiting, run neon env pull once it is.

These are the environment variables on the server:

And these in the frontend, with the same values as NEON_AUTH_BASE_URL and NEON_REALTIME_URL:

  • VITE_NEON_AUTH_URL

  • VITE_NEON_REALTIME_URL

Vite expands $VAR in env files, and neon env pull keeps lines it doesn't manage, so point them at the server variables:

echo 'VITE_NEON_AUTH_URL=$NEON_AUTH_BASE_URL' >> .env.local
echo 'VITE_NEON_REALTIME_URL=$NEON_REALTIME_URL' >> .env.local

Define the example schema and apply your usual Drizzle migrations:

import { integer, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core"

export const messages = pgTable(
  "messages", {
    id: uuid("id").primaryKey(),
    channel_id: integer("channel_id").notNull(),
    author_id: text("author_id").notNull(),
    body: text("body").notNull(),
    created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
  }
)

export const channelMembers = pgTable(
  "channel_members", {
    channel_id: integer("channel_id").notNull(),
    user_id: text("user_id").notNull()
  },
  (table) => [primaryKey({ columns: [table.channel_id, table.user_id] })]
)

export type Message = typeof messages.$inferSelect

Configure your Drizzle client (db):

import { drizzle } from "drizzle-orm/node-postgres"
import { Pool } from "pg"
import * as schema from "./schema"

const pool = new Pool({
  // Neon URLs say sslmode=require. Asking pg for verify-full keeps today's checks and skips its warning
  connectionString: process.env.DATABASE_URL!.replace("sslmode=require", "sslmode=verify-full")
})
export const db = drizzle(pool, { schema })

// So an idle connection error, for example when the compute scales to zero, doesn't crash the API
pool.on("error", (error: Error) => console.error(error))

Great, that's the main setup to follow along with the guides. Or for the full boilerplate, see the example source code on GitHub.

</details>

Defining live queries

Neon Realtime supports any deterministic SQL query that is supported by Postgres.

This includes where clauses, order by and limit, joins, aggregates, group by and distinct, functions (built-in and user-defined), extensions, window functions, sub-queries, etc.

The only limitations are that:

  1. queries must be read-only, i.e.: must not have side effects

  2. any functions used in your queries must be deterministic

So, for example, you can't SELECT over updated rows and you can't use functions like now() or gen_random_uuid().

Table requirements

Tables need a stable replica identity. If your tables have a primary key, they're fine.

Optional: REPLICA IDENTITY FULL:

Setting REPLICA IDENTITY FULL can make live queries marginally faster, at the cost of writing the whole old row to the Postgres WAL on every update and delete. That may save Realtime a lookup when it processes those changes. If latency matters for a write-heavy table, benchmark both settings with your own workload before you switch.

ALTER TABLE messages REPLICA IDENTITY FULL;
Using raw SQL

Create a RealtimeServer instance (secret is the NEON_REALTIME_SECRET generated when enabling Realtime and db should be the dbname in your Postgres connection string):

import { createRealtime } from "@neon/realtime/server"

export const realtime = createRealtime({
  secret: process.env.NEON_REALTIME_SECRET!,
  db: "neondb"
})

createRealtime() also takes a debugMode option.

Then define queries using rawSql. For example:

import { rawSql } from "@neon/realtime/server"

import type { Message } from "./schema"

export const allMessages = rawSql<Message>(
  `SELECT author_id, body, channel_id, created_at, id FROM messages`
)

A raw SQL query has to be a single SELECT statement with no ;. With at most 32 KiB of SQL and at most 256 parameters of up to 16 KiB each.

Query parameters

When your query has parameters, wrap it in a function so they can be provided when using it. Put placeholders (like $1, $2, etc.) in the SQL and pass the values in the array.

For example:

import { rawSql } from "@neon/realtime/server"

import type { Message } from "./schema"

export function messagesQuery(userId: string, channelId: number) {
  return 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]
  )
}

Warning: SQL injection

Never use literal interpolation for user input like `WHERE user_id = '${userId}'`. Always use placeholders like `WHERE user_id = $1` instead.

Parameter types

You can pass strings, numbers, booleans, bigints and null as they are. Other value types need a pgParam helper to specify the Postgres type. A Date could be a date, a timestamp or a timestamptz, so wrap it in pgParam.date(), pgParam.timestamp() or pgParam.timestamptz().

For example, this filters messages since a timestamp with timezone:

import { pgParam, rawSql } from "@neon/realtime/server"

import type { Message } from "./schema"

export function messagesSinceQuery(userId: string, channelId: number, since: Date) {
  return 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 AND messages.created_at > $3
     ORDER BY messages.created_at DESC
     LIMIT 50`,
    [userId, channelId, pgParam.timestamptz(since)]
  )
}

There are helpers for dates, JSON, bytea and arrays, such as pgParam.array("text", ["a"]). pgParam.text() covers any other named type, for example pgParam.text("uuid", id).

Using a query builder or ORM

Realtime supports an adapter pattern to convert any typed query object to raw SQL. This works by passing an adapter to createRealtime.

The rest of these guides use the Drizzle versions of backend/realtime.ts and backend/queries.ts below in place of the raw SQL files above.

For example, configure Realtime to use the drizzleAdapter:

import { createRealtime } from "@neon/realtime/server"
import { drizzleAdapter } from "@neon/realtime-drizzle"

export const realtime = createRealtime({
  secret: process.env.NEON_REALTIME_SECRET!,
  db: "neondb",
  adapter: drizzleAdapter()
})

You can now define queries using Drizzle:

import { and, desc, eq, getTableColumns } from "drizzle-orm"

import { db } from "./db"
import { channelMembers, messages } from "./schema"

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)
}

See the Drizzle and Kysely examples for more details.

Tip: Column name casing

The Drizzle adapter needs each selected key to match its column name. That's why the schema uses snake_case keys and the join selects getTableColumns(messages).

The Drizzle example shows how to alias fields to match the column key.

Sealing queries

Once you've defined a query, if you're using it in trusted backend code, you can just subscribe to it right away. However, most of the time, you'll want to subscribe to live queries in an untrusted environment, such as a frontend app or agent runtime.

This is where sealing comes in. Sealing signs and encrypts the exact query, its parameters and the target database into a short-lived capability (similar to a JWT), so it can be used safely in an untrusted environment.

Warning: Keep your Realtime secret on the server

Anyone with NEON_REALTIME_SECRET can seal any query. Treat it like your DATABASE_URL, and never ship it in a browser bundle. In a Vite app, that means never giving it a VITE_ prefix. To replace it, run neon realtime rotate-secret.

To seal a query you pass your query instance, e.g.:

const query = messagesQuery(userId, channelId)

To the seal method of the RealtimeServer instance you created above:

const sealedQuery = await realtime.seal({ query })

It's local Web Crypto work with no network call, so it's cheap to run on every request. It returns a SealedLiveQuery<Row>, which has three fields:

  • capability: the encrypted query

  • expiresAt: when it expires, in Unix milliseconds

  • queryFingerprint: a stable identity for the exact query

The client can subscribe to a sealed query, but it can't read or change it. Your secret, your SQL and the parameter values bound to it are not readable anywhere in it. The Live SQL API verifies the capability itself, so subscribing doesn't need a call back to your server.

Important: Sealed queries are bearer tokens

Sealed queries are typically user-specific and sealed per request, where they can bake in parameters like the authenticated user ID.

Anyone holding a sealed query can subscribe to it until it expires. Send it over HTTPS, and keep it out of URLs, logs and persistent browser storage.

Renewing sealed queries

Sealed queries are short-lived (currently 60 seconds). To keep a subscription running, renew its sealed query before expiresAt, or the subscription goes stale when it expires. The reactivity bindings renew for you when you pass them a refreshQuery function. The subscribing guide shows how to renew manually.

A renewal has to be for the same query. Different parameter values make a different query, with a different fingerprint. So, for example, to switch channels in the chat app, you would seal the new query and subscribe again.

Authorization

Once you've sealed a query, you'll want to get it to your frontend to subscribe to.

You do this by exposing the query from a request handler in your backend. So that's where you authenticate the user and decide what they can see.

Sealing authorized queries

The flow is as follows:

  1. read the auth context from the request

  2. authorize access using arbitrary business logic (e.g.: your auth middleware)

  3. construct the query, passing in any relevant parameters from the auth context (e.g.: filtering or joining on the authenticated userId)

  4. seal the query so these can't be changed

This allows you to authorize live query subscriptions using existing web auth logic, middleware and frameworks, such as Neon Auth.

Live authorization

In our running example, messagesQuery() joins through channel_members and filters on the userId, so it only returns messages from channels the user belongs to:

.where(and(eq(channelMembers.user_id, userId), eq(messages.channel_id, channelId)))

The join is part of the live query, so it stays live. If you remove someone from a channel, they'll immediately lose access to its messages.

That's why Realtime authorizes the query, not the connection. Your request handler checks who's asking and seals a query that only matches the rows that the user is allowed to see. The authorization is defined at request time but it dynamically adapts to your data in realtime.

Warning: Never trust user input from the client

Take authorization context from a verified session or token and validate everything else (like channelId) that the client sends in the request data.

Why not row-level security

Postgres row-level security (RLS) is often slow and becomes a bottleneck. It's also awkward to develop with, because your access rules live in migrations, rather than your application code.

It's simpler and more efficient to reuse the auth logic you already have in your backend.

Many existing apps do use RLS, though. So, while it isn't our recommended approach to auth, we have designed Neon Realtime's architecture to be able to support it and we aim to implement support before GA.

Exposing queries

There are two main ways to expose a sealed, authorized query to your frontend:

  1. exposing an endpoint from your backend API

  2. using server functions (often with SSR)

These are both request handlers. They run in a trusted environment in your backend with auth context available from the request.

With Vite and Hono, the browser's auth client talks to Managed Better Auth directly, so its session cookie never reaches your API. The browser sends the session's JWT as a bearer token, and the API verifies it. Next.js and TanStack Start mount Managed Better Auth on your app's own origin, so server code reads the session from its cookie and the browser holds no token. Either way, the Realtime connection carries no user credential: the sealed query is the authorization.

Exposing an endpoint

Expose the sealedQuery from an API endpoint, using any web framework.

Our running example uses Hono and Managed Better Auth. The client sends its auth token in an Authorization: Bearer header. The JWT is verified against your Managed Better Auth JWKS in a requireUser helper:

import type { Context } from "hono"
import { HTTPException } from "hono/http-exception"
import { createRemoteJWKSet, jwtVerify } from "jose"

const authUrl = process.env.NEON_AUTH_BASE_URL!
const origin = new URL(authUrl).origin
const jwks = createRemoteJWKSet(new URL(`${authUrl}/.well-known/jwks.json`))

async function verifyUserId(token: string): Promise<string | undefined> {
  try {
    const { payload } = await jwtVerify(token, jwks, { issuer: origin, audience: origin })

    return payload.sub
  } catch {
    return undefined
  }
}

export async function getUser(headers: Headers): Promise<{ id: string } | undefined> {
  const header = headers.get("Authorization")
  const token = header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined
  const userId = token ? await verifyUserId(token) : undefined

  return userId ? { id: userId } : undefined
}

export async function requireUser(c: Context): Promise<{ id: string }> {
  const user = await getUser(c.req.raw.headers)

  if (!user) {
    throw new HTTPException(401, { message: "Unauthorized" })
  }

  return user
}

We then use this auth helper in a route to securely build and expose the sealedQuery:

import { Hono } from "hono"
import { validator } from "hono/validator"

import { requireUser } from "./auth"
import { messagesQuery } from "./queries"
import { realtime } from "./realtime"

const app = new Hono().post(
  "/api/v1/messages/live",
  validator("json", (value, c) => {
    const channelId: unknown = value?.channelId

    if (typeof channelId !== "number" || !Number.isInteger(channelId)) {
      return c.json({ error: "channelId must be an integer" }, 400)
    }

    return { channelId }
  }),
  async (c) => {
    const user = await requireUser(c)
    const { channelId } = c.req.valid("json")

    const query = messagesQuery(user.id, channelId)
    const sealedQuery = await realtime.seal({ query })

    return c.json(sealedQuery)
  }
)

export type AppType = typeof app

export default app

The validator() middleware checks the JSON body before the handler runs and types it, so the frontend's hc client knows this endpoint takes { channelId: number }. The handler takes the user's ID from requireUser() and the channelId from the validated body, builds the query, seals it and returns it as JSON. Chain every route onto new Hono() in one expression, because AppType only carries the routes chained into it.

seal() doesn't depend on request or response types and a sealed query is plain JSON. The subscribing guide shows how to fetch it from the client and subscribe to it.

Using server functions

If you're using a framework that supports server functions, you can return the sealedQuery from one. For example, with TanStack Start you can create a server function.

The server function reads the session from the Managed Better Auth cookie with requireUser, a server-only middleware. The SSR guide sets it up. The server function then seals the query for context.user.id:

import { createServerFn } from "@tanstack/react-start"

import { messagesQuery } from "./backend/queries"
import { realtime } from "./backend/realtime"
import { requireUser } from "./session-middleware"

export const sealMessages = createServerFn({ method: "POST" })
  .middleware([requireUser])
  .validator((data: { channelId: number }) => {
    if (!Number.isInteger(data.channelId)) {
      throw new Error("channelId must be an integer")
    }

    return data
  })
  .handler(({ data, context }) =>
    realtime.seal({ query: messagesQuery(context.user.id, data.channelId) })
  )

The browser calls sealMessages to get the sealed query, and calls it again to renew it.

To seal the query while the page renders on the server, see the Server-side rendering guide and the Next.js and TanStack examples.

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.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download neon-realtime.zip