Channels & topics

Channels are the top-level scope for realtime messages. Each channel has one or more topics: typed message streams with schemas that provide end-to-end type safety from publishing to subscribing.

import { realtime } from "inngest";
import { z } from "zod";

const alerts = realtime.channel({
  name: "system:alerts",
  topics: {
    alert: { schema: z.object({ message: z.string(), severity: z.enum(["info", "warn", "error"]) }) },
  },
});

realtime.channel(options)

Creates a channel definition. The returned value is either a channel instance (static name) or a factory function (parameterized name).

  • Name
    name
    Type
    string | function
    Required
    required
    Description

    The channel name. Use a static string for a fixed channel, or a function that returns a string for parameterized channels. The function receives a single object argument with your parameters.

  • Name
    topics
    Type
    Record<string, { schema }>
    Required
    required
    Description

    An object mapping topic names to their configuration. Each topic must have a schema property: any Standard Schema (Zod, Valibot, ArkType) or staticSchema<T>() for type-only schemas.

import { realtime, staticSchema } from "inngest";
import { z } from "zod";

// Static channel
const alerts = realtime.channel({
  name: "system:alerts",
  topics: {
    alert: { schema: z.object({ message: z.string() }) },
  },
});

// Parameterized channel
const chat = realtime.channel({
  name: ({ threadId }: { threadId: string }) => `chat:${threadId}`,
  topics: {
    message: { schema: z.object({ text: z.string() }) },
    typing: { schema: staticSchema<{ userId: string }>() },
  },
});

Static channels

When name is a string, realtime.channel() returns a channel instance directly. You can use it without calling it as a function.

const alerts = realtime.channel({
  name: "system:alerts",
  topics: {
    alert: { schema: z.object({ message: z.string() }) },
  },
});

// Use directly, no instantiation needed
alerts.name;         // "system:alerts"
alerts.alert;        // TopicRef for the "alert" topic
alerts.alert.channel // "system:alerts"
alerts.alert.topic   // "alert"

Parameterized channels

When name is a function, realtime.channel() returns a factory. Call it with your parameters to get a channel instance.

const chat = realtime.channel({
  name: ({ threadId }: { threadId: string }) => `chat:${threadId}`,
  topics: {
    message: { schema: z.object({ text: z.string(), sender: z.string() }) },
    status: { schema: z.object({ typing: z.boolean() }) },
  },
});

// Instantiate with parameters
const ch = chat({ threadId: "abc123" });

ch.name;            // "chat:abc123"
ch.message;         // TopicRef for "message" on "chat:abc123"
ch.message.channel; // "chat:abc123"
ch.message.topic;   // "message"

Parameterized channels isolate subscribers to a specific instance. A subscriber to chat:abc123 won't receive messages published to chat:def456.

Topic schemas

Every topic requires a schema property. The schema serves two purposes:

  1. Type inference: TypeScript infers the data type for publishing and subscribing
  2. Runtime validation: data is validated against the schema when publishing and (optionally) when subscribing

Standard Schema libraries

Any library implementing the Standard Schema spec works: Zod, Valibot, ArkType, and others.

import { z } from "zod";

const ch = realtime.channel({
  name: "pipeline",
  topics: {
    status: { schema: z.object({ message: z.string() }) },
  },
});

staticSchema<T>()

For type-only schemas with zero runtime validation cost. Useful when you trust the data source and only want compile-time type checking.

import { realtime, staticSchema } from "inngest";

const ch = realtime.channel({
  name: "metrics",
  topics: {
    usage: { schema: staticSchema<{ tokens: number; latencyMs: number }>() },
  },
});

You can mix staticSchema and runtime schemas on the same channel:

const ch = realtime.channel({
  name: "pipeline",
  topics: {
    // Runtime validation with Zod
    status: { schema: z.object({ message: z.string() }) },
    // Type-only, no validation cost
    usage: { schema: staticSchema<{ tokens: number }>() },
  },
});

Topic accessors

Each topic defined on a channel becomes a property on the channel instance. These accessors return a TopicRef, a lightweight reference carrying the channel name, topic name, and schema config.

const ch = chat({ threadId: "abc123" });

// Each accessor is a TopicRef
ch.message.channel; // "chat:abc123"
ch.message.topic;   // "message"
ch.message.config;  // { schema: ... }

// Pass topic refs to publish and subscribe
await publish(ch.message, { text: "Hello!", sender: "alice" });

Topic refs are the primary way to reference a specific topic on a specific channel instance. They're used by publish, step.realtime.publish, and getClientSubscriptionToken.

Type inference

Channel definitions expose type utilities for extracting topic data types and parameter types.

const pipeline = realtime.channel({
  name: ({ runId }: { runId: string }) => `pipeline:${runId}`,
  topics: {
    status: { schema: z.object({ message: z.string() }) },
    tokens: { schema: staticSchema<{ token: string }>() },
  },
});

// Infer topic data types
type StatusData = typeof pipeline.$infer.status;  // { message: string }
type TokenData = typeof pipeline.$infer.tokens;   // { token: string }

// Infer channel parameters (parameterized channels only)
type Params = typeof pipeline.$params;             // { runId: string }

Portability

Channel definitions are plain objects with no server-side dependencies. Define them in a shared file and import them on both server and client:

src/inngest/channels.ts
import { realtime, staticSchema } from "inngest";
import { z } from "zod";

export const pipelineChannel = realtime.channel({
  name: ({ runId }: { runId: string }) => `pipeline:${runId}`,
  topics: {
    status: { schema: z.object({ message: z.string() }) },
    tokens: { schema: staticSchema<{ token: string }>() },
  },
});

Import from your function code, your API routes (for token minting), and your React components. The same definition provides type safety everywhere.