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 supported in TypeScript and C# (.NET 10 only). Cross-language composition is not supported.

Writing a Submodule​

A submodule is a regular SpacetimeDB module. The same module can be published independently or included by another module; no special root/library build role is required.

Export the schema as the default export and export every function 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. The accessor name is used in module code and generated bindings; the canonical name is used in SQL and network calls.

// 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 accessor. The accessor does not change when you pin a different canonical name. Submodule views are also accessible in client subscriptions and SQL as <namespace>.<view_name>, using canonical names.

Access submodule tables through ctx.db.myauth. The activeSessions view is myauth.active_sessions in SQL.

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 consumer can call public submodule functions directly. A namespace is not a security boundary between ordinary helper calls.

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.schemaType>): bigint {
  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​

You can include multiple submodules under different namespaces. Tables and functions in separate namespaces can use the same names. A submodule can itself include other submodules.

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.

Environment variables​

Named submodules cannot declare a nonempty environment. Such declarations cause composition or publication to fail. A module with environment declarations can still be published independently as a root module.

In C#, dependencies registered in the default public scope are an exception: their declarations contribute to the root environment schema.

Named submodules have no separate environment-variable namespace, and their host-dispatched entry points cannot read the root module's environment. Root module code can pass configuration values to helpers explicitly. Ordinary helper calls retain the calling entry point's access, including calls to helpers defined in submodules.

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.active_sessions 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 under the namespace accessor.

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" '"abc123"'

The accessor verifyToken becomes verify_token on the wire under the default naming policy.

Both parts of the wire name are canonical: the namespace prefix and the function name after ..

Namespace Name Rules​

Each accessor and canonical namespace segment must be a valid SpacetimeDB identifier: start with a letter or underscore, continue with letters, digits, or underscores, maximum 63 UTF-8 bytes, case-insensitive for resolution. Two submodules in the same parent scope cannot share a canonical name, so myAuth and my_auth cannot both be registered there under the default policy. Different parent scopes can reuse the same child namespace name.

The reserved namespaces st, spacetimedb, and names starting with pg_ cannot be used. public is the default scope, not a named submodule namespace.

The schema key is the accessor name. The canonical name is derived from it unless explicitly set with name. A submodule can be registered under at most one namespace per consumer module.

Limitations​

Submodule routers are not applied automatically​

Routes from named submodules are ignored. Register handlers explicitly on the root router, as shown in the HTTP handler section. In C#, dependencies registered in public can contribute routes to root-level routing.

Row-level security belongs in the root​

Define row-level security filters in the root, referring to canonical qualified table names. Filters declared inside named submodules do not protect their tables.

Submodule filter declarations are accepted by the SDK, but the host does not install them. Export filters from the root schema instead, using qualified table names such as myauth.users.