Standards Docs
Guides

NestJS Backend

Wire @stndrds/adapter-nestjs SchemaModule with all five adapters used by standards-platform.

This guide walks through wiring @stndrds/adapter-nestjs's SchemaModule with the five adapters used by standards-platform: Supabase, Meilisearch, Redis, BullMQ, and E2B.

Breaking in vNEXT: Actor is now the single canonical identity. userId is gone from TenantContext, services, repositories, and PolicyContext — use actorId. withTenantContext(tenantId, fn, actorId?) lost its userId argument. The TenantContextInterceptor auto-provisions an actor for authenticated users, so resolveUserId in the snippet below resolves an actor id (the parameter name is unchanged for compatibility, but downstream code reads it from actorId).

Full module setup

The snippet below mirrors standards-platform/apps/api/src/modules/shared/schema/schema.module.ts. Trim sections you don't need yet — start with Supabase only, then add the rest.

// @noverify
import {
  BullMQOutboxSignal,
  BullMQQueueAdapter,
  BullMQScheduleAdapter,
  BullMQWorkerAdapter,
} from "@stndrds/adapter-bullmq";
import { E2BSandboxProvider } from "@stndrds/adapter-e2b";
import { MeilisearchSearchAdapter } from "@stndrds/adapter-meilisearch";
import {
  API_KEY_AUTH_CONTEXT,
  SchemaModule as StandardSchemaModule,
} from "@stndrds/adapter-nestjs";
import { RedisCacheAdapter, RedisRealtimeTransport } from "@stndrds/adapter-redis";
import { SupabaseDatabaseAdapter } from "@stndrds/adapter-supabase";
import { formRegistry, registry, viewRegistry } from "@stndrds/schema";
import type { ApiKeyAuthContext } from "@stndrds/schema";
import type { User } from "@supabase/supabase-js";
import { SupabaseClient } from "@supabase/supabase-js";
import { Queue } from "bullmq";
import type { Database } from "@my-app/supabase"; // generated types

const redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379";
const parsedRedis = new URL(redisUrl);
const redisConnection = {
  host: parsedRedis.hostname,
  port: Number(parsedRedis.port) || 6379,
  ...(parsedRedis.password ? { password: parsedRedis.password } : {}),
};

const agentQueue = new Queue("agent-runs", { connection: redisConnection });
const eventQueue = new Queue("event-processing", { connection: redisConnection });

const supabase = new SupabaseClient<Database>(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
);

const searchAdapter = new MeilisearchSearchAdapter({
  host: process.env.MEILISEARCH_HOST ?? "",
  apiKey: process.env.MEILISEARCH_API_KEY ?? "",
  indexPrefix: "myapp",
});

const DEFAULT_TENANT_ID = "00000000-0000-0000-0000-000000000000";

export const SchemaModule = StandardSchemaModule.forRoot({
  // Database adapter — search is required when Meilisearch is enabled
  adapter: new SupabaseDatabaseAdapter(supabase, {
    search: searchAdapter,
  }),
  search: {
    reconcileOnStartup: true,
    reconcileIntervalMs: 2 * 60 * 60 * 1000,
    fullReindexThreshold: 0.2,
  },
  events: {
    enabled: true,
    outboxSignal: new BullMQOutboxSignal(eventQueue),
  },
  cache: {
    adapter: new RedisCacheAdapter({
      url: redisUrl,
      keyPrefix: "myapp:",
    }),
  },
  registry,
  viewRegistry,
  formRegistry,
  autoSync: true,
  auth: {
    requireUser: false,
    resolveUserId(request: Request & { user?: User }) {
      if (request.user?.id !== undefined) return request.user.id;
      const apiKeyCtx = (request as unknown as Record<string | symbol, unknown>)[
        API_KEY_AUTH_CONTEXT
      ] as ApiKeyAuthContext | undefined;
      return apiKeyCtx?.userId ?? apiKeyCtx?.createdBy ?? null;
    },
  },
  tenant: {
    mode: "multi",
    headerName: "X-Tenant-ID",
    defaultTenantId: DEFAULT_TENANT_ID,
    masterTenantId: DEFAULT_TENANT_ID,
  },
  objects: true,
  queue: new BullMQQueueAdapter(agentQueue),
  schedule: new BullMQScheduleAdapter(agentQueue),
  agents: {
    enabled: true,
    agentWorker: new BullMQWorkerAdapter({
      queueName: "agent-runs",
      connection: redisConnection,
      concurrency: 5,
    }),
    eventWorker: new BullMQWorkerAdapter({
      queueName: "event-processing",
      connection: redisConnection,
      concurrency: 10,
    }),
    streamTransport: new RedisRealtimeTransport(redisUrl),
    sandbox: {
      provider: new E2BSandboxProvider(),
      template: "stndrds-sandbox",
      maxConcurrent: 10,
    },
  },
  global: true,
});

Module imports

Add SchemaModule alongside these standard modules in your AppModule:

  • ConfigModule.forRoot — loads .env / .env.local and makes ConfigService available globally. Pass validate to fail-fast on missing vars.
  • SupabaseModule.forRootAsync (from nestjs-supabase-js) — injects a typed SupabaseClient into the DI container, driven by ConfigService.
  • ThrottlerModule.forRoot — rate-limits all routes. Pair with ThrottlerGuard in APP_GUARD.
  • SchemaModule (your export from above) — global; provides all standards services, object providers, and auto-generated CRUD routes.
  • TenantModule — resolves X-Tenant-ID header into the AsyncLocalStorage tenant context via TenantResolverMiddleware.

Auth guard

standards exposes ApiKeyAuthGuard from @stndrds/adapter-nestjs. Combine it with a Supabase JWT guard that extends BaseSupabaseAuthGuard (from nestjs-supabase-js):

// @noverify
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { IS_PUBLIC_KEY } from "@stndrds/adapter-nestjs";
import { BaseSupabaseAuthGuard } from "nestjs-supabase-js";
import { SupabaseClient } from "@supabase/supabase-js";

@Injectable()
export class SupabaseGuard extends BaseSupabaseAuthGuard implements CanActivate {
  constructor(
    supabaseClient: SupabaseClient,
    private readonly reflector: Reflector,
  ) {
    super(supabaseClient);
  }

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;
    return super.canActivate(context);
  }
}

Register both guards as APP_GUARD providers in AppModule. ApiKeyAuthGuard resolves last and populates API_KEY_AUTH_CONTEXT on the request; SupabaseGuard runs first and attaches request.user. The resolveUserId callback in SchemaModule.forRoot reads from whichever is present.

The standards-platform implementation lives in apps/api/src/modules/auth/supabase.guard.ts.

Env vars

VarRequiredDescription
SUPABASE_URLyesYour Supabase project URL
SUPABASE_SERVICE_ROLE_KEYyesService role key — never expose to client
MEILISEARCH_HOSTyes (for search)URL of your Meili instance
MEILISEARCH_API_KEYyes (for search)Admin key
REDIS_URLyes (for cache/queue)Redis connection string
E2B_API_KEYyes (for agents)E2B sandbox API key

For a minimal setup using only Supabase, follow the tutorial chapter 2. You can omit the BullMQ, Redis, Meilisearch, and E2B sections until you need those capabilities.

On this page