Building real-time applications demands instant data synchronization, a common challenge in modern web development. This guide provides a practical solution by showing you how to seamlessly integrate SpacetimeDB with your Next.js application.
Before you begin, you should have:
- an existing Next.js project set up. If you're starting from scratch, you can quickly create one using
npx create-next-app@latest. - generated client-side module types. If you haven't done so, please follow the "Generate your module types" section in the SpacetimeDB TypeScript SDK Quickstart guide before proceeding.
We'll cover establishing a robust WebSocket connection, managing its lifecycle, and utilizing React's useSyncExternalStore hook with the Observer pattern for efficient, reactive state management. By following this tutorial, you'll learn to create dynamic Next.js UIs that stay perfectly in sync with your SpacetimeDB backend, delivering a truly live experience for your users. Let's get started!
All the relevant code can be found on GitHub.
Setting Up the SpacetimeDB Connection in Next.js
To connect a Next.js application to SpacetimeDB, we’ll use the generated client, which provides a DbConnection class for building and managing a WebSocket connection. For this tutorial, we’ll implement a connection factory using the singleton pattern, ensuring the connection is created only once and reused throughout the client session.
The connection logic will live in a dedicated file, and we’ll also expose a function to retrieve the DbConnection instance. Since the connection should only be initialized on the client side, we’ll explicitly prevent it from being created during server-side rendering (SSR) by throwing an exception — which we’ll handle later in our application.
We’ll configure the connection using DbConnection.builder(), which provides a fluent API. Authentication is not covered in detail here, but this is where you would supply a token using the getAuthToken() method. Event handlers like onConnect, onDisconnect, and onConnectError will be wired up using utility methods, which we'll define later.
To ensure the connection can be gracefully shut down when the app is closed or refreshed, we also provide a disconnectDbConnection() function.
// /lib/spacetimedb/connectionFactory.ts
'use client'
import {DbConnection} from '@/module_bindings';
import {
onConnect,
onConnectError,
onDisconnect
} from "@/lib/spacetimedb/connectionHandlers";
import {
cleanupConnectionListener
} from "@/lib/spacetimedb/connectionEvents";
import {
cleanupSubscriptionListener
} from "@/lib/spacetimedb/subscriptionEvents";
let singletonConnection: DbConnection | null = null;
export const getDbConnection = (): DbConnection => {
const isSSR = typeof window === 'undefined';
if (isSSR) {
throw new Error('Cannot use SpacetimeDB on the server.');
}
if (singletonConnection) {
return singletonConnection;
}
singletonConnection = buildDbConnection()
return singletonConnection;
};
const buildDbConnection = () => {
console.log('[SpacetimeDB] Building connection...');
return DbConnection.builder()
.withUri('ws://localhost:3000')
.withModuleName('database-name')
.withToken(getAuthToken())
.onConnect(onConnect)
.onDisconnect(onDisconnect)
.onConnectError(onConnectError)
.build();
}
const getAuthToken = () => {
const token = localStorage.getItem('auth_token');
if(token)
return token;
return "";
}
export const disconnectDbConnection = () => {
if (singletonConnection) {
console.log('[SpacetimeDB] Disconnecting...');
singletonConnection.disconnect();
singletonConnection = null;
}
cleanupConnectionListener();
cleanupSubscriptionListener();
};
Tracking Connection and Subscription State
To reflect the state of our SpacetimeDB connection in the UI — like showing a “Connecting…” message or disabling features until data is ready — we’ll use the Observer pattern. This allows different parts of the app, such as your React stores, to subscribe to connection or subscription events and react accordingly.
We define a shared connectionStatus object to track the current state, including whether we’re connected, subscribed, or if an error has occurred. Alongside this, we maintain a set of listeners that will be notified whenever the connection state changes.
Since these listeners persist in memory, we expose a cleanup method that clears the sets when the app closes — preventing memory leaks and haunted WebSocket callbacks.
// /lib/spacetimedb/connectionEvents.ts
import {Identity} from "@clockworklabs/spacetimedb-sdk";
export const connectionStatus = {
isConnected: false,
isSubscribed: false,
error: null as Error | null,
identity: null as Identity | null,
};
const listeners = new Set<() => void>();
export const onConnectionChange = (callback: () => void) => {
listeners.add(callback);
return () => listeners.delete(callback);
};
export const notifyConnectionEstablished = () => {
listeners.forEach(callback => callback());
};
export const notifyConnectionDisconnected = () => {
listeners.forEach(callback => callback());
};
export const notifyConnectionError = () => {
listeners.forEach(callback => callback());
};
export const cleanupConnectionListener = () => {
listeners.clear();
}
We use a similar approach for tracking subscription status. Once we’ve successfully subscribed to all relevant data in our SpacetimeDB, we notify any interested stores that they can start fetching data. If an error occurs, we notify them of that too.
// /lib/spacetimedb/subscriptionEvents.ts
const listeners = new Set<() => void>();
export const onSubscriptionChange = (callback: () => void) => {
listeners.add(callback);
return () => listeners.delete(callback);
};
export const notifySubscriptionApplied = () => {
listeners.forEach(callback => callback());
};
export const notifySubscriptionError = () => {
listeners.forEach(callback => callback());
};
export const cleanupSubscriptionListener = () => {
listeners.clear();
}
Handling Connection Events
Next, we’ll define how to respond when the SpacetimeDB connection is established, disconnected, or encounters an error. These event handlers are critical for keeping your app state in sync with the database connection lifecycle.
When the connection is successfully established, we update the connectionStatus, store the authentication token, notify all listeners, and kick off the initial data subscriptions.
On disconnection or failure, we reset the status and notify listeners accordingly. This allows your UI to react — for example, by showing an offline message or retrying the connection later. For production applications, it's vital to implement robust error handling and reconnection strategies in the onDisconnect and onConnectError callbacks, as the SpacetimeDB client does not automatically retry connections.
// /lib/spacetimedb/connectionHandlers.ts
import {
DbConnection,
type ErrorContext,
RemoteReducers,
RemoteTables,
SetReducerFlags
} from '@/module_bindings';
import {
ErrorContextInterface,
Identity
} from '@clockworklabs/spacetimedb-sdk';
import {
connectionStatus,
notifyConnectionDisconnected,
notifyConnectionError,
notifyConnectionEstablished
} from '@/lib/spacetimedb/connectionEvents';
import {
notifySubscriptionApplied,
notifySubscriptionError
} from '@/lib/spacetimedb/subscriptionEvents';
export const onConnect = (
conn: DbConnection,
identity: Identity,
token: string) => {
console.log('[SpacetimeDB] Connection established.');
connectionStatus.isConnected = true;
connectionStatus.error = null;
connectionStatus.identity = identity;
localStorage.setItem('auth_token', token);
notifyConnectionEstablished();
subscribeToQueries(conn, ['SELECT * FROM User', 'SELECT * FROM Message'])
};
export const onDisconnect = () => {
console.warn('[SpacetimeDB] Disconnected.');
connectionStatus.isConnected = false;
connectionStatus.isSubscribed = false;
notifyConnectionDisconnected();
};
export const onConnectError = (ctx: ErrorContext, error: Error) => {
console.error('[SpacetimeDB] Connection Error:', error);
connectionStatus.isConnected = false;
connectionStatus.isSubscribed = false;
connectionStatus.error = error;
notifyConnectionError();
};
export const subscribeToQueries = (conn: DbConnection, queries: string[]) => {
conn
?.subscriptionBuilder()
.onApplied(() => {
console.log('[SpacetimeDB] Subscribed to queries.');
connectionStatus.isSubscribed = true;
notifySubscriptionApplied();
})
.onError((ctx: ErrorContextInterface<RemoteTables, RemoteReducers, SetReducerFlags>) => {
console.error('[SpacetimeDB] Error subscribing to SpacetimeDB ' + ctx.event)
connectionStatus.isSubscribed = false;
notifySubscriptionError();
})
.subscribe(queries);
};
Automatically Initializing the SpacetimeDB Connection
To ensure the SpacetimeDB connection is created as soon as the client loads, we wrap our app content in a provider component. This provider uses the useEffect hook to call getDbConnection() when the app initializes.
Because this logic is client-side only, we ensure it doesn’t run during server-side rendering. To properly clean up the connection, the useEffect hook also returns a call to disconnectDbConnection().
// /providers/spacetimedbProvider.tsx
"use client";
import { useEffect } from 'react';
import {
getDbConnection,
disconnectDbConnection
} from "@/lib/spacetimedb/connectionFactory";
const SpacetimeDBProvider = ({ children }: { children: React.ReactNode }) => {
useEffect(() => {
getDbConnection();
return () => {
disconnectDbConnection();
};
}, []);
// It doesn't need to render anything itself, just pass children through.
return <>{children}</>;
};
export default SpacetimeDBProvider;
We then include this provider in the root layout of the Next.js application to ensure that the connection logic runs at the top level of the app, no matter what page is loaded.
// /app/layout.tsx
import SpacetimeDBProvider from "@/providers/spacetimedbProvider";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<SpacetimeDBProvider>
{children}
</SpacetimeDBProvider>
</body>
</html>
);
}
This setup ensures that the database connection is initialized once, safely, and outside the rendering cycle.
Using SpacetimeDB Data in Next.js with a React Store
To access and reactively update data from SpacetimeDB in your React components, we'll create a dedicated data store. This store uses the Observer pattern to notify components when data changes, ensuring the UI stays in sync with the backend without direct polling or unnecessary re-renders.
In this example, we’ll implement a MessageStore to manage messages retrieved from SpacetimeDB.
Key Concepts:
-
Observer Pattern: Components can subscribe to the store via
subscribe(), and are notified throughemitChange()when data updates. -
Connection Management: The store lazily initializes a singleton
DbConnectionwhen first accessed. -
Data Syncing: We subscribe to SpacetimeDB's
insert,update, anddeleteevents for themessagetable. When any of these fire, we refresh our snapshot of data. - Reducer Passthrough: We expose the relevant reducers on the store, which can then be used by React components.
-
SSR Compatibility: Since Next.js may pre-render on the server,
getSnapshot()wraps connection logic in a try-catch. If executed on the server, it falls back toserverSnapshot— a shared empty array to prevent reference mismatches.
// /stores/messageStore.ts
import {Message, DbConnection} from "@/module_bindings";
import {getDbConnection} from '@/lib/spacetimedb/connectionFactory';
import {onSubscriptionChange} from "@/lib/spacetimedb/subscriptionEvents";
class MessageStore {
private listeners: Set<() => void> = new Set();
private connection: DbConnection | null = null;
private cachedSnapshot: Message[] = [];
private serverSnapshot: Message[] = [];
constructor() {
onSubscriptionChange(() => {
this.updateSnapshot();
});
}
public subscribe(onStoreChange: () => void) {
this.listeners.add(onStoreChange);
return () => {
// Cleanup on unmount
this.listeners.delete(onStoreChange);
};
}
public getSnapshot() {
try {
this.getConnection();
return this.cachedSnapshot;
} catch (error) {
const isNotSSR = typeof window !== 'undefined';
if(isNotSSR) {
// This would be an unexpected error on the client-side
console.error('Unexpected error while obtaining snapshot:', error);
}
return this.serverSnapshot;
}
}
public getServerSnapshot() {
// Return the same reference to prevent unnecessary SSR re-renders
return this.serverSnapshot;
}
public sendMessage(newMessage: string){
if (this.connection) {
this.connection.reducers.sendMessage(newMessage);
}
}
private getConnection(): DbConnection {
if (!this.connection) {
this.connection = getDbConnection();
this.connection.db.message.onInsert((ctx, row) => this.updateSnapshot());
this.connection.db.message.onDelete((ctx, row) => this.updateSnapshot());
}
return this.connection;
}
private updateSnapshot() {
if (this.connection) {
this.cachedSnapshot = Array.from(this.connection.db.message.iter());
this.emitChange();
}
}
private emitChange() {
for (const listener of this.listeners) {
listener();
}
}
}
export const messageStore = new MessageStore();
Using the Message Store in a React Hook
Our newly created messageStore can be consumed in React components via a custom hook: useMessages(). This hook uses useSyncExternalStore to ensure components update when the store’s data changes — efficiently and predictably.
Because React uses object identity to detect changes, we must return the shared serverSnapshot reference on the server. Returning a new array each time would break this logic, leading to unnecessary re-renders or even infinite loops.
// /hooks/useMessages.ts
import { useSyncExternalStore } from "react";
import { messageStore } from "@/stores/messageStore";
export function useMessages() {
const messages = useSyncExternalStore(
(callback) => messageStore.subscribe(callback),
() => messageStore.getSnapshot(),
() => messageStore.getServerSnapshot()
);
return messages;
}
Tracking Connection State with a Custom Hook
You can use the same store-based pattern to expose other SpacetimeDB tables, but let’s start by setting up a connectionStore to track the connection status. This allows us to update the UI in response to connection events, like showing loading indicators or handling errors.
Unlike data tables, the ConnectionStore simply reflects the values in the shared connectionStatus object. It subscribes to connection change events and re-emits updates to any registered listeners.
// /stores/connectionStore.ts
import {DbConnection} from "@/module_bindings";
import {
connectionStatus,
onConnectionChange
} from "@/lib/spacetimedb/connectionEvents";
class ConnectionStore {
private listeners: Set<() => void> = new Set();
private connection: DbConnection | null = null;
constructor() {
onConnectionChange(() => {
this.emitChange();
});
}
public subscribe(onStoreChange: () => void) {
this.listeners.add(onStoreChange);
return () => {
// Cleanup on unmount
this.listeners.delete(onStoreChange);
};
}
public getSnapshot() {
return connectionStatus;
}
public getServerSnapshot() {
return connectionStatus;
}
private emitChange() {
for (const listener of this.listeners) {
listener();
}
}
}
export const connectionStore = new ConnectionStore();
Creating a Hook for Connection Status
To access the connection state in your React components, we wrap our connectionStore in a custom hook: useConnection(). This works exactly like useMessages(), using useSyncExternalStore to ensure your components stay in sync with the live connection status.
// /hooks/useConnection.ts
import { useSyncExternalStore } from "react";
import { connectionStore } from "@/stores/connectionStore";
export function useConnection() {
const connection = useSyncExternalStore(
(callback) => connectionStore.subscribe(callback),
() => connectionStore.getSnapshot(),
() => connectionStore.getServerSnapshot()
);
return connection;
}
Now your components can access the connection state with a simple const { isConnected, isSubscribed, error } = useConnection();, and your app can respond instantly to connection changes without you having to duct tape state around every useEffect.
Putting It All Together: Displaying Messages in a Component
With the data stores and hooks in place, we can now use them in a React component. The MessageList component demonstrates how to consume the connection state and message data using useConnection() and useMessages().
This component:
- Displays an error if the connection failed
- Shows a loading message if the client is not yet connected
- Once connected, renders a list of all messages
- Calls the
sendMessagereducer when theSend Messagebutton is clicked
// /components/messageList.tsx
'use client'
import {useMessages} from "@/hooks/useMessages";
import {useConnection} from "@/hooks/useConnection";
import {messageStore} from "@/stores/messageStore";
export default function MessageList() {
const connection = useConnection();
const allMessages = useMessages();
if(connection.error)
return <div>Error: {connection.error.message}</div>;
if(!connection.isConnected || !connection.isSubscribed)
return <div>Not connected</div>;
return (
<div>
<h1>Message</h1>
{allMessages.map(message => (
<div key={message.sent.toDate().toISOString()}>
{message.text}
</div>
))}
<button onClick={() => messageStore.sendMessage("Message")}>
Send Message
</button>
</div>
);
}
And that’s it — you’ve successfully wired up SpacetimeDB to a Next.js app with reactive stores, real-time updates. With this foundation, you can now extend the same pattern to other tables, build more complex components, and make your app feel snappy and alive without drowning in manual state management.
Top comments (0)