Integrations
Next.js: Server Components and a Client-Side Real-Time Layer
Where the client can and cannot live in the App Router, the token route that belongs on the server, and the hydration mismatch that catches everyone.
A WebSocket needs a browser. Server components do not have one. Most of the friction here comes from that single fact.
For a separate operational perspective on how teams organise and measure work, this resource.
For independent background and broader industry context, see Next.js.
What goes where
Server components: fetch initial data, render the shell, keep secrets. No Vaultrice client.
Client components: the Vaultrice client, subscriptions, live updates. Marked 'use client'.
Route handlers: the token endpoint. This is server-side and it is where your secret lives.
The pattern: server component renders with initial data, client component takes over for the live part.
The token route
// app/api/vaultrice-token/route.js
import { cookies } from 'next/headers'
export async function GET(request) {
const session = await getSession(cookies())
if (!session) return new Response(null, { status: 401 })
const objectId = new URL(request.url).searchParams.get('id')
if (!await mayAccess(session.user, objectId)) {
return new Response(null, { status: 403 })
}
const accessToken = await mintToken(/* per your setup */)
return Response.json({ accessToken })
}
Two checks, both required. Authenticating that someone is a user, then issuing a token that reaches any object, is the common half-implementation. See the three authentication methods.
Keep your keys in environment variables without the NEXT_PUBLIC_ prefix. Anything with that prefix is inlined into the client bundle, which is exactly the mistake this endpoint exists to prevent.
Server-rendered initial data, client-side updates
// app/doc/[id]/page.jsx — server component
import { getDocument } from '@/lib/db'
import LiveDocument from './LiveDocument'
export default async function Page({ params }) {
const doc = await getDocument(params.id) // your database
return <LiveDocument objectId={doc.syncId} initial={doc} />
}
// app/doc/[id]/LiveDocument.jsx
'use client'
import { useEffect, useState } from 'react'
import { NonLocalStorage } from '@vaultrice/sdk'
export default function LiveDocument({ objectId, initial }) {
const [title, setTitle] = useState(initial.title)
useEffect(() => {
const nls = new NonLocalStorage({
projectId: process.env.NEXT_PUBLIC_VAULTRICE_PROJECT_ID,
getAccessToken: async () => {
const res = await fetch(`/api/vaultrice-token?id=${objectId}`)
if (!res.ok) throw new Error('Token request failed')
const { accessToken } = await res.json()
return accessToken
}
}, objectId)
const handler = (item) => setTitle(item.value)
nls.on('setItem', 'title', handler)
// re-read, since the server data may already be stale
nls.getItem('title').then((item) => {
if (item?.value !== undefined) setTitle(item.value)
})
return () => nls.off?.('setItem', 'title', handler)
}, [objectId])
return <h1>{title}</h1>
}
The project id is public and belongs in a NEXT_PUBLIC_ variable. The secret does not.
Note the re-read. The server rendered from your database; the shared object may be ahead of it.
The hydration mismatch
The trap everyone hits.
The server renders the initial value. The client, after connecting, has the current one. If they differ, React complains and the output is unpredictable.
Do not render live state during SSR. Render the server-known value, then update after mount — which is what the code above does, since the effect runs only in the browser.
If a flash of stale content is unacceptable, render a skeleton until the first read completes. That trades a flash for a delay, and which is worse depends on your content.
Do not suppress hydration warnings to make it go away. The warning is telling you the two renders disagree, which is a real thing to resolve.
App Router specifics
'use client' marks the boundary, and everything imported below it goes to the client. Keep the SDK import inside that boundary or it lands in the server bundle.
Effects run once in production and twice in development with Strict Mode. A missing cleanup is invisible in production and duplicates events in development — which is confusing in the opposite direction to how people expect.
Route changes in the App Router do not reload the page, so a client created in a component that stays mounted persists across navigation. Usually what you want, and worth knowing when you are counting connections.
Pages Router
Same principles. getServerSideProps for initial data, the client in useEffect, the token endpoint in pages/api/.
The hydration concern is identical.
Edge runtime
Route handlers can run at the edge, which suits a token endpoint — small, fast, and close to the user.
Check that your session verification works there. Node-specific APIs are not available, and this is usually what forces a token route back to the Node runtime.
The short version
Server components have no browser — the client belongs in 'use client' components only.
The token route is a server route handler, with both an authentication and an authorisation check.
Never prefix a secret with NEXT_PUBLIC_ — that inlines it into the bundle.
Render server-known data, then update after mount. Do not render live state during SSR, and do not suppress the hydration warning.
And re-read after connecting, because the server rendered from your database and the object may be ahead.