Skip to main content
Version: 2.0.0

Submodules

A submodule is a SpacetimeDB module that can be included in another module's database. The submodule's tables and functions register under a namespace you choose, keeping them separate from the consumer's own tables and from other submodules.

Submodules let you package reusable database logic as a library that any consumer can integrate without coordinating table names.

note

Submodules are currently supported in TypeScript only. Support for Rust, C#, and C++ is coming soon.

Writing a Submodule​

A submodule is a regular SpacetimeDB module. Nothing special marks a module as a submodule. Export the schema as the default export and export every function (reducers, procedures, views, HTTP handlers) the consumer needs to register.

// auth_lib/src/index.ts
import { schema, table, t, SyncResponse, Router, type ReducerCtx } from 'spacetimedb/server';

const users = table(
  { name: 'users', public: true },
  { identity: t.identity().primaryKey(), username: t.string() }
);

const sessions = table(
  { name: 'sessions' },
  {
    id: t.u64().primaryKey().autoInc(),
    userIdentity: t.identity(),
    token: t.string(),
  }
);

const spacetimedb = schema({ users, sessions });
export default spacetimedb;

export const verifyToken = spacetimedb.reducer(
  { token: t.string() },
  (ctx, { token }) => { /* ... */ }
);

export const sessionCount = spacetimedb.procedure(
  t.u64(),
  (ctx) => ctx.withTx(tx => tx.db.sessions.count())
);

export const activeSessions = spacetimedb.anonymousView(
  { name: 'active_sessions', public: true },
  t.array(sessions.rowType),
  (ctx) => [...ctx.db.sessions.iter()]
);

export const health = spacetimedb.httpHandler(
  (_ctx, _req) => new SyncResponse('ok')
);

export const router = spacetimedb.httpRouter(
  new Router().get('/health', health)
);

The same file can be published as a standalone database or used as a submodule by another module. The module does not need to declare which role it plays.

Using a Submodule​

The consumer controls the namespace name. Pass the submodule's module-namespace object under the alias you choose.

// my-database/src/index.ts
import { schema, table } from 'spacetimedb/server';
import * as authLib from 'auth_lib';

const players = table({ name: 'players', public: true }, { /* ... */ });

const spacetimedb = schema({
  players,
  myauth: authLib,   // register auth_lib under the namespace "myauth"
});
export default spacetimedb;

export default spacetimedb is the only JS export required from the consumer. Registering the submodule adds all of its reducers, procedures, views, scheduled tables, and HTTP handlers automatically.

Accessor and canonical namespace names​

Like a table, a namespace has two names. The key you register the submodule under is its accessor name: the name module code and generated client bindings use, such as ctx.db.myAuth or tables.myAuth.users. The canonical name is what the database stores and what SQL, subscriptions, the CLI, and the HTTP API use, such as my_auth.users. By default the canonical name is derived from the accessor name by the module's case conversion policy, exactly as it is for tables and reducers: myAuth in TypeScript becomes my_auth in the database.

To store a namespace under a specific canonical name instead, register the submodule as a { name, module } object. name is used verbatim, and the key remains the accessor name:

const spacetimedb = schema({
  players,
  myAuth: { name: 'myAuth', module: authLib },   // ctx.db.myAuth, stored as "myAuth"
  payments: { name: 'billing', module: paymentLib },   // ctx.db.payments, stored as "billing"
});
Changing a canonical namespace re-creates its tables

The canonical namespace is part of every table's identity. Publishing a module whose namespace canonicalizes differently than before, for example after renaming the key from myauth to myAuth without a name, is a migration that removes the tables under the old namespace and creates empty ones under the new namespace. To rename the key without touching the tables, pin the canonical name with name: changing only the key while keeping the same name only updates the accessor aliases.

Use import * as, not a default import
import authLib from 'auth_lib';        // ❌ misses all named exports
import * as authLib from 'auth_lib';   // ✅ correct

A default-only import exposes only the submodule's schema, not its named exports (reducers, procedures, views, handlers). The submodule walker requires the full module-namespace object. A clear error is raised if you use the wrong form.

Accessing Submodule Tables and Views​

Submodule tables appear under a namespace field on ctx.db. The field matches the accessor name you chose, regardless of the canonical name stored in the database. Views exported by a submodule behave like tables from the client's perspective: they are accessible as <namespace>.<view_name> in subscriptions and SQL queries, where both parts are canonical snake_case names — activeSessions in TypeScript is active_sessions in SQL, and a submodule registered as myAuth is my_auth. The generated client bindings expose the camelCase accessors instead, as shown below.

export const example = spacetimedb.reducer((ctx) => {
  // Consumer's own tables (no namespace)
  for (const player of ctx.db.players.iter()) { /* ... */ }

  // Submodule tables, registered as "myauth"
  const user = ctx.db.myauth.users.identity.find(ctx.sender);
  for (const session of ctx.db.myauth.sessions.iter()) { /* ... */ }
});

Calling Submodule Functions​

A submodule can expose reducers, procedures, views, and HTTP handlers that the consumer calls from its own functions. Because the submodule's context type and the consumer's context type are distinct, use ctx.as.<alias> to narrow the context before passing it to a submodule function.

From a Reducer​

Call a submodule reducer or a plain helper function typed against the submodule's schema using ctx.as.<alias>:

// auth_lib: plain helper function typed against the submodule's own schema
export function sessionCountHelper(ctx: ReducerCtx<typeof spacetimedb>): number {
  return ctx.db.sessions.count();
}

// my-database: call submodule reducer and helper from a consumer reducer
export const onLogin = spacetimedb.reducer({ token: t.string() }, (ctx, { token }) => {
  // call a submodule reducer
  authLib.verifyToken(ctx.as.myauth, { token });

  // call a submodule helper function
  const count = authLib.sessionCountHelper(ctx.as.myauth);
  console.log(`Active sessions: ${count}`);
});

ctx.as.myauth is a ReducerCtx scoped to the myauth namespace. It shares the same sender, timestamp, and connectionId as the parent context, but its ctx.db points at ctx.db.myauth.

For reducers registered through the submodule's own schema (via schema.reducer(...)), the host passes a scoped context automatically when invoked directly. ctx.as is only needed when the consumer calls a submodule function explicitly.

From a Procedure​

Use ctx.as.<alias> to pass a submodule-scoped ProcedureCtx to a submodule procedure. To call a submodule reducer from inside a procedure, open a transaction first with ctx.withTx and then narrow with tx.as.<alias>:

// call a submodule procedure
export const stats = spacetimedb.procedure(
  t.u64(),
  (ctx) => authLib.sessionCount(ctx.as.myauth)
);

// call a submodule reducer inside a withTx block
export const transactAndCount = spacetimedb.procedure(
  { token: t.string() },
  t.u64(),
  (ctx, { token }) => {
    ctx.withTx(tx => {
      // tx is a root ReducerCtx; narrow to the submodule namespace
      authLib.verifyToken(tx.as.myauth, { token });
    });
    return authLib.sessionCount(ctx.as.myauth);
  }
);

From an HTTP Handler​

Delegate to a submodule's HTTP handler by passing ctx.as.<alias> and the request to the submodule handler function, then register it on the consumer's router:

import { Router } from 'spacetimedb/server';

// delegate the /health route to the submodule's handler
export const healthCheck = spacetimedb.httpHandler((ctx, req) => {
  return authLib.health(ctx.as.myauth, req);
});

export const router = spacetimedb.httpRouter(
  new Router().get('/health', healthCheck)
);

Multiple and Nested Submodules​

Multiple submodules compose freely. Each gets its own namespace, so name collisions between submodules are impossible.

import * as authLib    from 'auth_lib';
import * as paymentLib from 'payment_lib';

const spacetimedb = schema({
  players,
  myauth:   authLib,
  payments: paymentLib,
});
export default spacetimedb;

A submodule can itself include other submodules using the same syntax. The nested submodule's tables appear under a two-level path in the top-level consumer:

// auth_lib includes session_lib as "sessions"
const authSchema = schema({ users, sessions: sessionLib });
export default authSchema;

// consumer includes auth_lib as "myauth"
// session_lib's tables are at ctx.db.myauth.sessions.<table>

Lifecycle reducers (init, clientConnected, clientDisconnected) are an exception: these are only allowed for the root module. Modules containing submodules which define lifecycle reducers will fail to publish.

Client Subscriptions​

Client subscriptions use the same namespace structure as server-side access. Submodule tables and views are queried as <namespace>.<name>, using canonical names on the wire and accessor names in generated bindings.

conn.subscriptionBuilder().subscribe(tables => [
  tables.players.build(),                   // public.players
  tables.myauth.users.build(),              // myauth.users
  tables.myauth.activeSessions.build(),     // myauth.activeSessions view
]);

Calling Submodule Reducers and Procedures from the Client​

Submodule reducers and procedures are identified by their fully-qualified name, using . as the separator between namespace and function name.

Client SDK​

In generated bindings, submodule tables, views, reducers, and procedures appear as nested objects under the namespace alias. The tables, reducers, and procedures exports all reflect the same nesting.

React hooks:

import { tables, reducers, procedures } from './module_bindings';
import { useTable, useReducer, useProcedure } from 'spacetimedb/react';

// subscribe to a submodule table and view
const [users] = useTable(tables.myauth.users);
const [activeSessions] = useTable(tables.myauth.activeSessions);

// call a submodule reducer
const verifyToken = useReducer(reducers.myauth.verifyToken);
verifyToken({ token: 'abc123' });

// call a submodule procedure
const sessionCount = useProcedure(procedures.myauth.sessionCount);
sessionCount().then(count => console.log(`Sessions: ${count}`));

Vanilla (non-React):

import { DbConnection, tables, reducers, procedures } from './module_bindings';

const conn = DbConnection.builder()
  .withUri(SPACETIMEDB_URI)
  .withDatabaseName('my-database')
  .onConnect(ctx => {
    ctx.subscriptionBuilder()
      .subscribe([tables.myauth.users, tables.myauth.activeSessions]);
  })
  .build();

conn.reducers.myauth.verifyToken({ token: 'abc123' });

HTTP API​

POST /v1/database/my-database/call/myauth.verify_token

CLI​

spacetime call my-database "myauth.verify_token" '{"token": "abc123"}'

Both parts of the wire name are canonical: the namespace prefix is the canonical namespace name (myauth here, or my_auth for a submodule registered as myAuth), and the function name after . is the canonical snake_case form of the submodule's export name -- verifyToken in TypeScript is verify_token on the wire. Generated client bindings expose the camelCase accessors instead, as shown above.

Namespace Name Rules​

Both the accessor name (the key you register the submodule under) and the canonical name (derived from it, or given explicitly via name) must be valid SpacetimeDB identifiers: start with a letter or underscore, continue with letters, digits, or underscores, maximum 63 characters, case-insensitive for resolution. Two submodules cannot share a canonical name, so myAuth and my_auth cannot both be registered under the default policy. A submodule can be registered under at most one namespace per consumer module.

The reserved namespaces public, st, spacetimedb, and pg_* cannot be used as submodule namespaces.

Limitations​

Submodule routers are not applied automatically​

A submodule can define its own httpRouter, but when used as a submodule that router is ignored. Only the consumer's root router is used. To expose a submodule's HTTP handlers, register them explicitly on the consumer's router using ctx.as.<alias> as shown in the HTTP handler section.