From 6feeb85f31cc32518faf776afb7f9af4e5fa2e4d Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:06:14 -0500 Subject: [PATCH 1/2] Add Remix quickstart template and documentation --- .../00200-quickstarts/00175-remix.md | 209 ++++++++++++++++++ templates/remix-ts/.template.json | 5 + templates/remix-ts/LICENSE | 1 + templates/remix-ts/app/providers.tsx | 55 +++++ templates/remix-ts/app/root.tsx | 33 +++ templates/remix-ts/app/routes/_index.tsx | 98 ++++++++ templates/remix-ts/package.json | 31 +++ templates/remix-ts/spacetimedb/package.json | 15 ++ templates/remix-ts/spacetimedb/src/index.ts | 33 +++ templates/remix-ts/spacetimedb/tsconfig.json | 12 + .../src/module_bindings/add_reducer.ts | 15 ++ .../remix-ts/src/module_bindings/index.ts | 135 +++++++++++ .../src/module_bindings/on_connect_reducer.ts | 13 ++ .../module_bindings/on_disconnect_reducer.ts | 13 ++ .../src/module_bindings/person_table.ts | 15 ++ .../src/module_bindings/person_type.ts | 15 ++ .../src/module_bindings/say_hello_reducer.ts | 13 ++ templates/remix-ts/tsconfig.json | 30 +++ templates/remix-ts/vite.config.ts | 25 +++ 19 files changed, 766 insertions(+) create mode 100644 docs/docs/00100-intro/00200-quickstarts/00175-remix.md create mode 100644 templates/remix-ts/.template.json create mode 100644 templates/remix-ts/LICENSE create mode 100644 templates/remix-ts/app/providers.tsx create mode 100644 templates/remix-ts/app/root.tsx create mode 100644 templates/remix-ts/app/routes/_index.tsx create mode 100644 templates/remix-ts/package.json create mode 100644 templates/remix-ts/spacetimedb/package.json create mode 100644 templates/remix-ts/spacetimedb/src/index.ts create mode 100644 templates/remix-ts/spacetimedb/tsconfig.json create mode 100644 templates/remix-ts/src/module_bindings/add_reducer.ts create mode 100644 templates/remix-ts/src/module_bindings/index.ts create mode 100644 templates/remix-ts/src/module_bindings/on_connect_reducer.ts create mode 100644 templates/remix-ts/src/module_bindings/on_disconnect_reducer.ts create mode 100644 templates/remix-ts/src/module_bindings/person_table.ts create mode 100644 templates/remix-ts/src/module_bindings/person_type.ts create mode 100644 templates/remix-ts/src/module_bindings/say_hello_reducer.ts create mode 100644 templates/remix-ts/tsconfig.json create mode 100644 templates/remix-ts/vite.config.ts diff --git a/docs/docs/00100-intro/00200-quickstarts/00175-remix.md b/docs/docs/00100-intro/00200-quickstarts/00175-remix.md new file mode 100644 index 00000000000..bc394598f60 --- /dev/null +++ b/docs/docs/00100-intro/00200-quickstarts/00175-remix.md @@ -0,0 +1,209 @@ +--- +title: Remix Quickstart +sidebar_label: Remix +slug: /quickstarts/remix +hide_table_of_contents: true +--- + +import { InstallCardLink } from "@site/src/components/InstallCardLink"; +import { StepByStep, Step, StepText, StepCode } from "@site/src/components/Steps"; + + +Get a SpacetimeDB Remix app running in under 5 minutes. + +## Prerequisites + +- [Node.js](https://nodejs.org/) 18+ installed +- [SpacetimeDB CLI](https://spacetimedb.com/install) installed + + + +--- + + + + + Run the `spacetime dev` command to create a new project with a SpacetimeDB module and Remix client. + + This will start the local SpacetimeDB server, publish your module, generate TypeScript bindings, and start the Remix development server. + + +```bash +spacetime dev --template remix-ts my-remix-app +``` + + + + + + Navigate to [http://localhost:3001](http://localhost:3001) to see your app running. + + Note: The Remix dev server runs on port 3001 to avoid conflict with SpacetimeDB on port 3000. + + + + + + Your project contains both server and client code using Remix with Vite. + + Edit `spacetimedb/src/index.ts` to add tables and reducers. Edit `app/routes/_index.tsx` to build your UI. + + +``` +my-remix-app/ +├── spacetimedb/ # Your SpacetimeDB module +│ └── src/ +│ └── index.ts # Server-side logic +├── app/ # Remix app +│ ├── root.tsx # Root layout with providers +│ ├── providers.tsx # SpacetimeDB provider (client-only) +│ └── routes/ +│ └── _index.tsx # Home page +├── src/ +│ └── module_bindings/ # Auto-generated types +└── package.json +``` + + + + + + Open `spacetimedb/src/index.ts` to see the module code. The template includes a `person` table and two reducers: `add` to insert a person, and `say_hello` to greet everyone. + + Tables store your data. Reducers are functions that modify data — they're the only way to write to the database. + + +```typescript +import { schema, table, t } from 'spacetimedb/server'; + +export const spacetimedb = schema( + table( + { name: 'person', public: true }, + { + name: t.string(), + } + ) +); + +spacetimedb.reducer('add', { name: t.string() }, (ctx, { name }) => { + ctx.db.person.insert({ name }); +}); + +spacetimedb.reducer('say_hello', (ctx) => { + for (const person of ctx.db.person.iter()) { + console.info(`Hello, ${person.name}!`); + } + console.info('Hello, World!'); +}); +``` + + + + + + Use the SpacetimeDB CLI to call reducers and query your data directly. + + +```bash +# Call the add reducer to insert a person +spacetime call my-remix-app add Alice + +# Query the person table +spacetime sql my-remix-app "SELECT * FROM person" + name +--------- + "Alice" + +# Call say_hello to greet everyone +spacetime call my-remix-app say_hello + +# View the module logs +spacetime logs my-remix-app +2025-01-13T12:00:00.000000Z INFO: Hello, Alice! +2025-01-13T12:00:00.000000Z INFO: Hello, World! +``` + + + + + + SpacetimeDB is client-side only — it cannot run during server-side rendering. The `app/providers.tsx` file handles this by deferring the SpacetimeDB connection until the component mounts on the client. + + The template uses environment variables for configuration. Set `VITE_SPACETIMEDB_HOST` and `VITE_SPACETIMEDB_DB_NAME` to override defaults. + + +```tsx +// app/providers.tsx +import { useMemo, useState, useEffect } from 'react'; +import { SpacetimeDBProvider } from 'spacetimedb/react'; +import { DbConnection } from '../src/module_bindings'; + +const HOST = import.meta.env.VITE_SPACETIMEDB_HOST ?? 'ws://localhost:3000'; +const DB_NAME = import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'my-remix-app'; + +export function Providers({ children }: { children: React.ReactNode }) { + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + const connectionBuilder = useMemo(() => { + if (typeof window === 'undefined') return null; + return DbConnection.builder() + .withUri(HOST) + .withModuleName(DB_NAME); + }, []); + + // During SSR, render children without provider + if (!isClient || !connectionBuilder) { + return <>{children}; + } + + return ( + + {children} + + ); +} +``` + + + + + + In your route components, use `useTable` to subscribe to table data and `useReducer` to call reducers. The SpacetimeDB hooks work seamlessly with Remix routes. + + +```tsx +// app/routes/_index.tsx +import { tables, reducers } from '../../src/module_bindings'; +import { useTable, useReducer } from 'spacetimedb/react'; + +export default function Index() { + // Subscribe to table data - returns [rows, isLoading] + const [people] = useTable(tables.person); + + // Get a function to call a reducer + const addPerson = useReducer(reducers.add); + + const handleAdd = () => { + // Call reducer with object syntax + addPerson({ name: 'Alice' }); + }; + + return ( +
    + {people.map((person, i) =>
  • {person.name}
  • )} +
+ ); +} +``` +
+
+
+ +## Next steps + +- See the [Chat App Tutorial](/tutorials/chat-app) for a complete example +- Read the [TypeScript SDK Reference](/sdks/typescript) for detailed API docs diff --git a/templates/remix-ts/.template.json b/templates/remix-ts/.template.json new file mode 100644 index 00000000000..924245a1bc9 --- /dev/null +++ b/templates/remix-ts/.template.json @@ -0,0 +1,5 @@ +{ + "description": "Remix with TypeScript server", + "client_lang": "typescript", + "server_lang": "typescript" +} diff --git a/templates/remix-ts/LICENSE b/templates/remix-ts/LICENSE new file mode 100644 index 00000000000..039e117dde2 --- /dev/null +++ b/templates/remix-ts/LICENSE @@ -0,0 +1 @@ +../../licenses/apache2.txt \ No newline at end of file diff --git a/templates/remix-ts/app/providers.tsx b/templates/remix-ts/app/providers.tsx new file mode 100644 index 00000000000..fba62148d07 --- /dev/null +++ b/templates/remix-ts/app/providers.tsx @@ -0,0 +1,55 @@ +import { useMemo, useState, useEffect } from 'react'; +import { SpacetimeDBProvider } from 'spacetimedb/react'; +import { DbConnection, ErrorContext } from '../src/module_bindings'; +import { Identity } from 'spacetimedb'; + +const HOST = import.meta.env.VITE_SPACETIMEDB_HOST ?? 'ws://localhost:3000'; +const DB_NAME = import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'remix-ts'; + +const onConnect = (_conn: DbConnection, identity: Identity, token: string) => { + if (typeof window !== 'undefined') { + localStorage.setItem('auth_token', token); + } + console.log( + 'Connected to SpacetimeDB with identity:', + identity.toHexString() + ); +}; + +const onDisconnect = () => { + console.log('Disconnected from SpacetimeDB'); +}; + +const onConnectError = (_ctx: ErrorContext, err: Error) => { + console.log('Error connecting to SpacetimeDB:', err); +}; + +export function Providers({ children }: { children: React.ReactNode }) { + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + const connectionBuilder = useMemo(() => { + if (typeof window === 'undefined') return null; + return DbConnection.builder() + .withUri(HOST) + .withModuleName(DB_NAME) + .withToken(localStorage.getItem('auth_token') || undefined) + .onConnect(onConnect) + .onDisconnect(onDisconnect) + .onConnectError(onConnectError); + }, []); + + // During SSR or before hydration, render children without provider + if (!isClient || !connectionBuilder) { + return <>{children}; + } + + return ( + + {children} + + ); +} diff --git a/templates/remix-ts/app/root.tsx b/templates/remix-ts/app/root.tsx new file mode 100644 index 00000000000..d2e614e0741 --- /dev/null +++ b/templates/remix-ts/app/root.tsx @@ -0,0 +1,33 @@ +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from '@remix-run/react'; +import type { LinksFunction } from '@remix-run/node'; +import { Providers } from './providers'; + +export const links: LinksFunction = () => []; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} diff --git a/templates/remix-ts/app/routes/_index.tsx b/templates/remix-ts/app/routes/_index.tsx new file mode 100644 index 00000000000..ab9ec565d64 --- /dev/null +++ b/templates/remix-ts/app/routes/_index.tsx @@ -0,0 +1,98 @@ +import { useState, useEffect } from 'react'; +import type { MetaFunction } from '@remix-run/node'; +import { tables, reducers } from '../../src/module_bindings'; +import { useSpacetimeDB, useTable, useReducer } from 'spacetimedb/react'; + +export const meta: MetaFunction = () => { + return [ + { title: 'SpacetimeDB Remix App' }, + { name: 'description', content: 'A Remix app powered by SpacetimeDB' }, + ]; +}; + +// Client-only component that uses SpacetimeDB hooks +function SpacetimeDBContent() { + const [name, setName] = useState(''); + + const conn = useSpacetimeDB(); + const { isActive: connected } = conn; + + // Subscribe to all people in the database + // useTable returns [rows, isLoading] tuple + const [people] = useTable(tables.person); + + const addReducer = useReducer(reducers.add); + + const addPerson = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !connected) return; + + // Call the add reducer with object syntax + addReducer({ name: name }); + setName(''); + }; + + return ( + <> +
+ Status:{' '} + + {connected ? 'Connected' : 'Disconnected'} + +
+ +
+ setName(e.target.value)} + style={{ padding: '0.5rem', marginRight: '0.5rem' }} + disabled={!connected} + /> + +
+ +
+

People ({people.length})

+ {people.length === 0 ? ( +

No people yet. Add someone above!

+ ) : ( +
    + {people.map((person, index) => ( +
  • {person.name}
  • + ))} +
+ )} +
+ + ); +} + +export default function Index() { + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + return ( +
+

SpacetimeDB Remix App

+ + {isClient ? ( + + ) : ( +
+ Status: Loading... +
+ )} +
+ ); +} diff --git a/templates/remix-ts/package.json b/templates/remix-ts/package.json new file mode 100644 index 00000000000..03f4c2f8b98 --- /dev/null +++ b/templates/remix-ts/package.json @@ -0,0 +1,31 @@ +{ + "name": "@clockworklabs/remix-ts", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "npx remix vite:dev --port 3001", + "build": "npx remix vite:build", + "start": "npx remix-serve ./build/server/index.js", + "generate": "pnpm --dir spacetimedb install --ignore-workspace && cargo run -p gen-bindings -- --out-dir src/module_bindings --project-path spacetimedb && prettier --write src/module_bindings", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb", + "spacetime:publish:local": "spacetime publish --project-path spacetimedb --server local", + "spacetime:publish": "spacetime publish --project-path spacetimedb --server maincloud" + }, + "dependencies": { + "@remix-run/node": "^2.16.0", + "@remix-run/react": "^2.16.0", + "@remix-run/serve": "^2.16.0", + "isbot": "^5.1.17", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@remix-run/dev": "^2.16.0", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "typescript": "~5.6.2", + "vite": "^5.4.0" + } +} diff --git a/templates/remix-ts/spacetimedb/package.json b/templates/remix-ts/spacetimedb/package.json new file mode 100644 index 00000000000..214ccc569bf --- /dev/null +++ b/templates/remix-ts/spacetimedb/package.json @@ -0,0 +1,15 @@ +{ + "name": "spacetime-module", + "version": "1.0.0", + "description": "", + "scripts": { + "build": "spacetime build", + "publish": "spacetime publish" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "spacetimedb": "1.*" + } +} diff --git a/templates/remix-ts/spacetimedb/src/index.ts b/templates/remix-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..3a5ddbc8257 --- /dev/null +++ b/templates/remix-ts/spacetimedb/src/index.ts @@ -0,0 +1,33 @@ +import { schema, table, t } from 'spacetimedb/server'; + +export const spacetimedb = schema( + table( + { name: 'person', public: true }, + { + name: t.string(), + } + ) +); + +spacetimedb.init(_ctx => { + // Called when the module is initially published +}); + +spacetimedb.clientConnected(_ctx => { + // Called every time a new client connects +}); + +spacetimedb.clientDisconnected(_ctx => { + // Called every time a client disconnects +}); + +spacetimedb.reducer('add', { name: t.string() }, (ctx, { name }) => { + ctx.db.person.insert({ name }); +}); + +spacetimedb.reducer('say_hello', ctx => { + for (const person of ctx.db.person.iter()) { + console.info(`Hello, ${person.name}!`); + } + console.info('Hello, World!'); +}); diff --git a/templates/remix-ts/spacetimedb/tsconfig.json b/templates/remix-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..812c3b98cb1 --- /dev/null +++ b/templates/remix-ts/spacetimedb/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/templates/remix-ts/src/module_bindings/add_reducer.ts b/templates/remix-ts/src/module_bindings/add_reducer.ts new file mode 100644 index 00000000000..85081559c7d --- /dev/null +++ b/templates/remix-ts/src/module_bindings/add_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default { + name: __t.string(), +}; diff --git a/templates/remix-ts/src/module_bindings/index.ts b/templates/remix-ts/src/module_bindings/index.ts new file mode 100644 index 00000000000..bc073933509 --- /dev/null +++ b/templates/remix-ts/src/module_bindings/index.ts @@ -0,0 +1,135 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 1.11.2 (commit dc5997d48f7c472faf2756b07c012bcf28edc50b). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from 'spacetimedb'; + +// Import and reexport all reducer arg types +import OnConnectReducer from './on_connect_reducer'; +export { OnConnectReducer }; +import OnDisconnectReducer from './on_disconnect_reducer'; +export { OnDisconnectReducer }; +import AddReducer from './add_reducer'; +export { AddReducer }; +import SayHelloReducer from './say_hello_reducer'; +export { SayHelloReducer }; + +// Import and reexport all procedure arg types + +// Import and reexport all table handle types +import PersonRow from './person_table'; +export { PersonRow }; + +// Import and reexport all types +import Person from './person_type'; +export { Person }; + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema( + __table( + { + name: 'person', + indexes: [], + constraints: [], + }, + PersonRow + ) +); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema('add', AddReducer), + __reducerSchema('say_hello', SayHelloReducer) +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures(); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: '1.11.2' as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. */ +export const tables = __convertToAccessorMap(tablesSchema.schemaType.tables); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap( + reducersSchema.reducersType.reducers +); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface< + typeof REMOTE_MODULE +>; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface< + typeof REMOTE_MODULE +>; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl< + typeof REMOTE_MODULE +> {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder( + REMOTE_MODULE, + (config: __DbConnectionConfig) => + new DbConnection(config) + ); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} diff --git a/templates/remix-ts/src/module_bindings/on_connect_reducer.ts b/templates/remix-ts/src/module_bindings/on_connect_reducer.ts new file mode 100644 index 00000000000..2ca99c88fea --- /dev/null +++ b/templates/remix-ts/src/module_bindings/on_connect_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default {}; diff --git a/templates/remix-ts/src/module_bindings/on_disconnect_reducer.ts b/templates/remix-ts/src/module_bindings/on_disconnect_reducer.ts new file mode 100644 index 00000000000..2ca99c88fea --- /dev/null +++ b/templates/remix-ts/src/module_bindings/on_disconnect_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default {}; diff --git a/templates/remix-ts/src/module_bindings/person_table.ts b/templates/remix-ts/src/module_bindings/person_table.ts new file mode 100644 index 00000000000..0f70f74f617 --- /dev/null +++ b/templates/remix-ts/src/module_bindings/person_table.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default __t.row({ + name: __t.string(), +}); diff --git a/templates/remix-ts/src/module_bindings/person_type.ts b/templates/remix-ts/src/module_bindings/person_type.ts new file mode 100644 index 00000000000..1156775a3cf --- /dev/null +++ b/templates/remix-ts/src/module_bindings/person_type.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default __t.object('Person', { + name: __t.string(), +}); diff --git a/templates/remix-ts/src/module_bindings/say_hello_reducer.ts b/templates/remix-ts/src/module_bindings/say_hello_reducer.ts new file mode 100644 index 00000000000..2ca99c88fea --- /dev/null +++ b/templates/remix-ts/src/module_bindings/say_hello_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default {}; diff --git a/templates/remix-ts/tsconfig.json b/templates/remix-ts/tsconfig.json new file mode 100644 index 00000000000..a8e947275c9 --- /dev/null +++ b/templates/remix-ts/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["@remix-run/node", "vite/client"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "paths": { + "~/*": ["./app/*"] + }, + "rootDirs": [".", "./.server"] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "**/.server/**/*.ts", + "**/.server/**/*.tsx", + "**/.client/**/*.ts", + "**/.client/**/*.tsx" + ], + "exclude": ["node_modules", "build"] +} diff --git a/templates/remix-ts/vite.config.ts b/templates/remix-ts/vite.config.ts new file mode 100644 index 00000000000..332b10963f3 --- /dev/null +++ b/templates/remix-ts/vite.config.ts @@ -0,0 +1,25 @@ +import { vitePlugin as remix } from '@remix-run/dev'; +import { defineConfig } from 'vite'; + +declare module '@remix-run/node' { + interface Future { + v3_singleFetch: true; + } +} + +export default defineConfig({ + plugins: [ + remix({ + future: { + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, + v3_singleFetch: true, + v3_lazyRouteDiscovery: true, + }, + }), + ], + server: { + port: 3001, // Avoid conflict with SpacetimeDB on port 3000 + }, +}); From 28178e3604f9cfb943ef14ad908e4b0b6588c41f Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:32:34 -0500 Subject: [PATCH 2/2] ssr --- .../00200-quickstarts/00175-remix.md | 98 +++++++++---------- .../remix-ts/app/lib/spacetimedb.server.ts | 49 ++++++++++ templates/remix-ts/app/providers.tsx | 55 ----------- templates/remix-ts/app/root.tsx | 57 ++++++++++- templates/remix-ts/app/routes/_index.tsx | 63 +++++++++--- templates/remix-ts/package.json | 2 +- templates/remix-ts/vite.config.ts | 3 - 7 files changed, 203 insertions(+), 124 deletions(-) create mode 100644 templates/remix-ts/app/lib/spacetimedb.server.ts delete mode 100644 templates/remix-ts/app/providers.tsx diff --git a/docs/docs/00100-intro/00200-quickstarts/00175-remix.md b/docs/docs/00100-intro/00200-quickstarts/00175-remix.md index bc394598f60..01aecf0440f 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00175-remix.md +++ b/docs/docs/00100-intro/00200-quickstarts/00175-remix.md @@ -36,9 +36,9 @@ spacetime dev --template remix-ts my-remix-app - Navigate to [http://localhost:3001](http://localhost:3001) to see your app running. + Navigate to [http://localhost:5173](http://localhost:5173) to see your app running. - Note: The Remix dev server runs on port 3001 to avoid conflict with SpacetimeDB on port 3000. + The `spacetime dev` command automatically configures your app to connect to SpacetimeDB via environment variables. @@ -53,12 +53,13 @@ spacetime dev --template remix-ts my-remix-app my-remix-app/ ├── spacetimedb/ # Your SpacetimeDB module │ └── src/ -│ └── index.ts # Server-side logic +│ └── index.ts # SpacetimeDB module logic ├── app/ # Remix app -│ ├── root.tsx # Root layout with providers -│ ├── providers.tsx # SpacetimeDB provider (client-only) +│ ├── root.tsx # Root layout with SpacetimeDB provider +│ ├── lib/ +│ │ └── spacetimedb.server.ts # Server-side data fetching │ └── routes/ -│ └── _index.tsx # Home page +│ └── _index.tsx # Home page with loader ├── src/ │ └── module_bindings/ # Auto-generated types └── package.json @@ -125,76 +126,71 @@ spacetime logs my-remix-app - + - SpacetimeDB is client-side only — it cannot run during server-side rendering. The `app/providers.tsx` file handles this by deferring the SpacetimeDB connection until the component mounts on the client. + The SpacetimeDB SDK works both server-side and client-side. The template uses Remix loaders for SSR: - The template uses environment variables for configuration. Set `VITE_SPACETIMEDB_HOST` and `VITE_SPACETIMEDB_DB_NAME` to override defaults. + - **Loader**: Fetches initial data from SpacetimeDB during server rendering + - **Client**: Maintains a real-time WebSocket connection for live updates + + The `app/lib/spacetimedb.server.ts` file provides a utility for server-side data fetching. ```tsx -// app/providers.tsx -import { useMemo, useState, useEffect } from 'react'; -import { SpacetimeDBProvider } from 'spacetimedb/react'; -import { DbConnection } from '../src/module_bindings'; - -const HOST = import.meta.env.VITE_SPACETIMEDB_HOST ?? 'ws://localhost:3000'; -const DB_NAME = import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'my-remix-app'; - -export function Providers({ children }: { children: React.ReactNode }) { - const [isClient, setIsClient] = useState(false); - - useEffect(() => { - setIsClient(true); - }, []); - - const connectionBuilder = useMemo(() => { - if (typeof window === 'undefined') return null; - return DbConnection.builder() - .withUri(HOST) - .withModuleName(DB_NAME); - }, []); - - // During SSR, render children without provider - if (!isClient || !connectionBuilder) { - return <>{children}; - } - - return ( - - {children} - - ); +// app/lib/spacetimedb.server.ts +import { DbConnection } from '../../src/module_bindings'; + +export async function fetchPeople() { + return new Promise((resolve, reject) => { + const connection = DbConnection.builder() + .withUri(process.env.SPACETIMEDB_HOST!) + .withModuleName(process.env.SPACETIMEDB_DB_NAME!) + .onConnect(conn => { + conn.subscriptionBuilder() + .onApplied(() => { + const people = Array.from(conn.db.person.iter()); + conn.disconnect(); + resolve(people); + }) + .subscribe('SELECT * FROM person'); + }) + .build(); + }); } ``` - + - In your route components, use `useTable` to subscribe to table data and `useReducer` to call reducers. The SpacetimeDB hooks work seamlessly with Remix routes. + Use Remix loaders to fetch initial data server-side, then React hooks for real-time updates. The loader data is passed to the component and displayed immediately while the client connects. ```tsx // app/routes/_index.tsx +import { useLoaderData } from '@remix-run/react'; import { tables, reducers } from '../../src/module_bindings'; import { useTable, useReducer } from 'spacetimedb/react'; +import { fetchPeople } from '../lib/spacetimedb.server'; + +export async function loader() { + const people = await fetchPeople(); + return { initialPeople: people }; +} export default function Index() { - // Subscribe to table data - returns [rows, isLoading] - const [people] = useTable(tables.person); + const { initialPeople } = useLoaderData(); - // Get a function to call a reducer + // Real-time data from WebSocket subscription + const [people, isLoading] = useTable(tables.person); const addPerson = useReducer(reducers.add); - const handleAdd = () => { - // Call reducer with object syntax - addPerson({ name: 'Alice' }); - }; + // Use server data until client is connected + const displayPeople = isLoading ? initialPeople : people; return (
    - {people.map((person, i) =>
  • {person.name}
  • )} + {displayPeople.map((person, i) =>
  • {person.name}
  • )}
); } diff --git a/templates/remix-ts/app/lib/spacetimedb.server.ts b/templates/remix-ts/app/lib/spacetimedb.server.ts new file mode 100644 index 00000000000..7af31ba73c2 --- /dev/null +++ b/templates/remix-ts/app/lib/spacetimedb.server.ts @@ -0,0 +1,49 @@ +import { DbConnection, Person } from '../../src/module_bindings'; +import type { Infer } from 'spacetimedb'; + +const HOST = process.env.SPACETIMEDB_HOST ?? 'wss://maincloud.spacetimedb.com'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'remix-ts'; + +export type PersonData = Infer; + +/** + * Fetches the initial list of people from SpacetimeDB. + * This function is designed for use in Remix loaders. + * + * It establishes a WebSocket connection, subscribes to the person table, + * waits for the initial data, and then disconnects. + */ +export async function fetchPeople(): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error('SpacetimeDB connection timeout')); + }, 10000); + + const connection = DbConnection.builder() + .withUri(HOST) + .withModuleName(DB_NAME) + .onConnect(conn => { + // Subscribe to all people + conn + .subscriptionBuilder() + .onApplied(() => { + clearTimeout(timeoutId); + // Get all people from the cache + const people = Array.from(conn.db.person.iter()); + conn.disconnect(); + resolve(people); + }) + .onError((_ctx, error) => { + clearTimeout(timeoutId); + conn.disconnect(); + reject(error); + }) + .subscribe('SELECT * FROM person'); + }) + .onConnectError((_ctx, error) => { + clearTimeout(timeoutId); + reject(error); + }) + .build(); + }); +} diff --git a/templates/remix-ts/app/providers.tsx b/templates/remix-ts/app/providers.tsx deleted file mode 100644 index fba62148d07..00000000000 --- a/templates/remix-ts/app/providers.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useMemo, useState, useEffect } from 'react'; -import { SpacetimeDBProvider } from 'spacetimedb/react'; -import { DbConnection, ErrorContext } from '../src/module_bindings'; -import { Identity } from 'spacetimedb'; - -const HOST = import.meta.env.VITE_SPACETIMEDB_HOST ?? 'ws://localhost:3000'; -const DB_NAME = import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'remix-ts'; - -const onConnect = (_conn: DbConnection, identity: Identity, token: string) => { - if (typeof window !== 'undefined') { - localStorage.setItem('auth_token', token); - } - console.log( - 'Connected to SpacetimeDB with identity:', - identity.toHexString() - ); -}; - -const onDisconnect = () => { - console.log('Disconnected from SpacetimeDB'); -}; - -const onConnectError = (_ctx: ErrorContext, err: Error) => { - console.log('Error connecting to SpacetimeDB:', err); -}; - -export function Providers({ children }: { children: React.ReactNode }) { - const [isClient, setIsClient] = useState(false); - - useEffect(() => { - setIsClient(true); - }, []); - - const connectionBuilder = useMemo(() => { - if (typeof window === 'undefined') return null; - return DbConnection.builder() - .withUri(HOST) - .withModuleName(DB_NAME) - .withToken(localStorage.getItem('auth_token') || undefined) - .onConnect(onConnect) - .onDisconnect(onDisconnect) - .onConnectError(onConnectError); - }, []); - - // During SSR or before hydration, render children without provider - if (!isClient || !connectionBuilder) { - return <>{children}; - } - - return ( - - {children} - - ); -} diff --git a/templates/remix-ts/app/root.tsx b/templates/remix-ts/app/root.tsx index d2e614e0741..2ba4a6ef748 100644 --- a/templates/remix-ts/app/root.tsx +++ b/templates/remix-ts/app/root.tsx @@ -1,3 +1,4 @@ +import { useMemo, useState, useEffect } from 'react'; import { Links, Meta, @@ -6,10 +7,64 @@ import { ScrollRestoration, } from '@remix-run/react'; import type { LinksFunction } from '@remix-run/node'; -import { Providers } from './providers'; +import { SpacetimeDBProvider } from 'spacetimedb/react'; +import { DbConnection, ErrorContext } from '../src/module_bindings'; +import { Identity } from 'spacetimedb'; + +const HOST = + import.meta.env.VITE_SPACETIMEDB_HOST ?? 'wss://maincloud.spacetimedb.com'; +const DB_NAME = import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'remix-ts'; + +const onConnect = (_conn: DbConnection, identity: Identity, token: string) => { + if (typeof window !== 'undefined') { + localStorage.setItem('auth_token', token); + } + console.log( + 'Connected to SpacetimeDB with identity:', + identity.toHexString() + ); +}; + +const onDisconnect = () => { + console.log('Disconnected from SpacetimeDB'); +}; + +const onConnectError = (_ctx: ErrorContext, err: Error) => { + console.log('Error connecting to SpacetimeDB:', err); +}; export const links: LinksFunction = () => []; +function Providers({ children }: { children: React.ReactNode }) { + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + const connectionBuilder = useMemo(() => { + if (typeof window === 'undefined') return null; + return DbConnection.builder() + .withUri(HOST) + .withModuleName(DB_NAME) + .withToken(localStorage.getItem('auth_token') || undefined) + .onConnect(onConnect) + .onDisconnect(onDisconnect) + .onConnectError(onConnectError); + }, []); + + // During SSR or before hydration, render children without provider + if (!isClient || !connectionBuilder) { + return <>{children}; + } + + return ( + + {children} + + ); +} + export function Layout({ children }: { children: React.ReactNode }) { return ( diff --git a/templates/remix-ts/app/routes/_index.tsx b/templates/remix-ts/app/routes/_index.tsx index ab9ec565d64..b9878a6f6d7 100644 --- a/templates/remix-ts/app/routes/_index.tsx +++ b/templates/remix-ts/app/routes/_index.tsx @@ -1,7 +1,9 @@ import { useState, useEffect } from 'react'; import type { MetaFunction } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; import { tables, reducers } from '../../src/module_bindings'; import { useSpacetimeDB, useTable, useReducer } from 'spacetimedb/react'; +import { fetchPeople, type PersonData } from '../lib/spacetimedb.server'; export const meta: MetaFunction = () => { return [ @@ -10,24 +12,44 @@ export const meta: MetaFunction = () => { ]; }; -// Client-only component that uses SpacetimeDB hooks -function SpacetimeDBContent() { +export async function loader() { + try { + const people = await fetchPeople(); + return { initialPeople: people }; + } catch (error) { + // If server-side fetch fails, the client will still work + console.error('Failed to fetch initial data:', error); + return { initialPeople: [] as PersonData[] }; + } +} + +// Client component that uses SpacetimeDB hooks for real-time updates +function PersonList({ initialPeople }: { initialPeople: PersonData[] }) { const [name, setName] = useState(''); + const [isHydrated, setIsHydrated] = useState(false); const conn = useSpacetimeDB(); const { isActive: connected } = conn; // Subscribe to all people in the database - // useTable returns [rows, isLoading] tuple - const [people] = useTable(tables.person); + const [people, isLoading] = useTable(tables.person); const addReducer = useReducer(reducers.add); + // Once connected and loaded, we're hydrated with real-time data + useEffect(() => { + if (connected && !isLoading) { + setIsHydrated(true); + } + }, [connected, isLoading]); + + // Use server-rendered data until client is hydrated with real-time data + const displayPeople = isHydrated ? people : initialPeople; + const addPerson = (e: React.FormEvent) => { e.preventDefault(); if (!name.trim() || !connected) return; - // Call the add reducer with object syntax addReducer({ name: name }); setName(''); }; @@ -37,7 +59,7 @@ function SpacetimeDBContent() {
Status:{' '} - {connected ? 'Connected' : 'Disconnected'} + {connected ? 'Connected' : 'Connecting...'}
@@ -60,12 +82,12 @@ function SpacetimeDBContent() {
-

People ({people.length})

- {people.length === 0 ? ( +

People ({displayPeople.length})

+ {displayPeople.length === 0 ? (

No people yet. Add someone above!

) : (
    - {people.map((person, index) => ( + {displayPeople.map((person, index) => (
  • {person.name}
  • ))}
@@ -76,6 +98,7 @@ function SpacetimeDBContent() { } export default function Index() { + const { initialPeople } = useLoaderData(); const [isClient, setIsClient] = useState(false); useEffect(() => { @@ -87,11 +110,25 @@ export default function Index() {

SpacetimeDB Remix App

{isClient ? ( - + ) : ( -
- Status: Loading... -
+ <> +
+ Status: Loading... +
+
+

People ({initialPeople.length})

+ {initialPeople.length === 0 ? ( +

No people yet. Add someone above!

+ ) : ( +
    + {initialPeople.map((person, index) => ( +
  • {person.name}
  • + ))} +
+ )} +
+ )} ); diff --git a/templates/remix-ts/package.json b/templates/remix-ts/package.json index 03f4c2f8b98..01731be377a 100644 --- a/templates/remix-ts/package.json +++ b/templates/remix-ts/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "type": "module", "scripts": { - "dev": "npx remix vite:dev --port 3001", + "dev": "npx remix vite:dev", "build": "npx remix vite:build", "start": "npx remix-serve ./build/server/index.js", "generate": "pnpm --dir spacetimedb install --ignore-workspace && cargo run -p gen-bindings -- --out-dir src/module_bindings --project-path spacetimedb && prettier --write src/module_bindings", diff --git a/templates/remix-ts/vite.config.ts b/templates/remix-ts/vite.config.ts index 332b10963f3..f40a255c67a 100644 --- a/templates/remix-ts/vite.config.ts +++ b/templates/remix-ts/vite.config.ts @@ -19,7 +19,4 @@ export default defineConfig({ }, }), ], - server: { - port: 3001, // Avoid conflict with SpacetimeDB on port 3000 - }, });