Most teams stick to the backend framework they already know — until scaling pain hits. I’ll show you Motia patterns that save your future self (you’ll sleep better).
Typical stack (gets painful at scale)
- API framework (Express/Nest)
- queue/workers (BullMQ/Celery)
- cron scheduler
- workflow/orchestration
- state/cache (Redis)
- realtime layer (WebSocket/SSE)
- logging/observability glue
With Motia: API routes, background/cron jobs, workflows, events, shared state, and streams — all built in.
What is Motia?
It’s unified, composable, and intelligent by default. At least that’s how they put it in their manifesto. Motia has api routes, durable workflows, background and cron jobs, shared state, and events — the whole package.
And if you’re on the fence: Motia lets you build in both TypeScript and Python — under the same backend model. Yes: one framework, two languages.
Defining config object —Motia turns it into an API route.
export const config: ApiRouteConfig = {
name: 'HelloStep',
type: 'api',
path: '/hello',
method: 'GET',
emits: []
};
export const handler: Handlers['HelloStep'] = async (req, { logger }) => {
logger.info('Hello endpoint called');
return { status: 200, body: { message: 'Hello world!' } };
};
One more thing: logger produces structured logs and attaches runtime context, which makes debugging way less painful.
Wait, there is more
At the heart of Motia is a single primitive: the step. If you’ve used tools like Inngest or Trigger.dev, the concept will feel familiar. Think of a step as a unit of work — one discrete thing your backend does. A step runs, produces an output, and that output can either be returned or passed into the next step to build a workflow.
Splitting steps
By emitting a topic, you can defer sending emails to another step. This is a common practice: we typically rely on a third-party SMTP provider, and we want the registration flow to feel snappy — without slowing down the endpoint response.
// register.step.ts
export const config: ApiRouteConfig = {
name: 'RegisterStep',
type: 'api',
path: '/register',
method: 'POST',
emits: ['email.welcome'],
flows: ['emails']
};
export const handler: Handlers['RegisterStep'] = async (req, { emit, logger }) => {
logger.info('Register started');
const { name, email } = req.body
// register code
await emit({ topic: 'email.welcome', data: { email } });
logger.info('Welcome e-mail sent');
return { status: 200, body: { message: 'Registered' } };
};
Keeping state
Motia lets you keep state between steps. This is handy for caching data you’ve already processed (for example, responses from an external API), so you don’t have to recompute or pass it through every step. You can also use state to track progress when multiple asynchronous tasks are running in parallel.
// checkout.step.ts
import { ApiRouteConfig, Handlers } from 'motia'
export const config: ApiRouteConfig = {
name: 'CheckoutStep',
type: 'api',
path: '/checkout',
method: 'POST',
emits: ['checkout.summary'],
flows: ['checkout']
}
export const handler: Handlers['CheckoutStep'] = async (req, { emit, logger, state }) => {
logger.info('Checkout started');
const { userId, cart } = req.body
// cache cart in state
const cart = await state.set('carts', cartId, cart);
// we can use 'cart' state in 'checkout.summary'
// without explictly passing it here
await emit({ topic: 'checkout.summary' });
return { status: 201, body: 'Products bought' };
}
// summary.step.ts
export const config: EventConfig = {
name: 'CheckoutSummaryEmail',
type: 'event',
subscribes: ['checkout.summary'],
emits: ['email.send'],
flows: ['email']
}
export const handler: Handlers['CheckoutSummaryEmail'] = async (input, { logger, state, emit }) => {
logger.info('E-mail summary started');
const cart = await state.get('carts', cartId);
// summary code
await emit({ topic: 'email.send', data: { cart, template: 'checkout-summary' }})
}
Under the hood, state is stored as key–value data in a local file by default, but you can switch the state adapter to Redis when you need a shared store.
Durable streaming
When you’re building AI apps, you often want to stream the model output to the user. Motia makes this straightforward with streams. Under the hood, Motia uses websockets to deliver those updates to the client.
Streams need more setup. First we need to install npm install @motiadev/stream-client-react in our project. Then connect project to the server endpoint:
// layout.tsx
import { MotiaStreamProvider } from '@motiadev/stream-client-react'
function Layout() {
const authToken = useAuthToken() // e.g. from cookies or local storage
return (
<MotiaStreamProvider address="ws://localhost:3000" authToken={authToken}>
<App />
</MotiaStreamProvider>
)
}
// app.tsx
import { useStreamGroup } from '@motiadev/stream-client-react'
import { useChatEndpoints, type Message } from './hook/useTodoEndpoints'
function App() {
const { channelId } = useParams();
const { sendMessage } = useChatEndpoints()
// Subscribe to all messages in the 'channelId' group
const { data: messages } = useStreamGroup<Message>({
groupId: channelId,
streamName: 'chatMessage'
})
const handleSendMessage = async (message: string) => {
await sendMessage(message)
// No need to manually update UI - stream does it automatically!
}
return (
<div>
<h1>Messages</h1>
{messages.map((message) => (
<div key={message.id}>{message.message}</div>
))}
</div>
)
}
And on the backend, the step needs to look like this to support sending messages.
// chat-messages.stream.ts
import { StreamConfig } from 'motia'
import { z } from 'zod'
export const chatMessageSchema = z.object({
id: z.string(),
message: z.string(),
createdAt: z.string(),
})
export type ChatMessage = z.infer<typeof ChatMessageSchema>
export const config: StreamConfig = {
name: 'chatMessage',
schema: chatMessageSchema,
baseConfig: { storageType: 'default' },
}
// send-message.step.ts
import { ApiRouteConfig, Handlers } from 'motia'
import { z } from 'zod'
import { v4 as uuidv4 } from 'uuid'
import type { ChatMessage } from './chat-messages.stream'
export const config: ApiRouteConfig = {
type: 'api',
name: 'SendMessage',
method: 'POST',
path: '/send-message',
bodySchema: z.object({
channelId: z.string(),
message: z.string(),
}),
responseSchema: {
200: z.object({
id: z.string(),
message: z.string(),
createdAt: z.string(),
}),
400: z.object({ error: z.string() }),
},
emits: [],
}
export const handler: Handlers['SendMessage'] = async (req, { logger, streams }) => {
logger.info('Send message', { body: req.body })
const { message, channelId } = req.body
const messageId = uuidv4()
const newMessage: ChatMessage = {
id: messageId,
message,
createdAt: new Date().toISOString(),
}
// Store in the 'channelId' group - all clients watching this group see the update!
const messageSaved = await streams.chatMessage.set(channelId, messageId, newMessage)
logger.info('Message sent successfully', { channelId, messageId })
return { status: 200, body: messageSaved }
}
When we POST a message together with a channelId, the step writes the new message into the chatMessage stream under that channelId group. Any frontend client subscribed to the same channelId group will receive the update automatically.
Security note: if you create a stream like this, any client that knows the channelId can subscribe and listen to messages published to that groupId. Motia handles stream authentication via streamAuth. You can pass an Authorization token either through the Sec-WebSocket-Protocol header or as an authToken query parameter in the URL.
// motia.config.ts
import type { StreamAuthRequest } from '@motiadev/core'
import { config } from 'motia'
import { z } from 'zod'
const extractAuthToken = (request: StreamAuthRequest): string | undefined => {
const protocolHeader = request.headers['sec-websocket-protocol']
if (protocolHeader?.includes('Authorization')) {
const [, token] = protocolHeader.split(',')
if (token) {
return token.trim()
}
}
if (!request.url) return undefined
try {
const url = new URL(request.url, 'http://localhost')
return url.searchParams.get('authToken') ?? undefined
} catch {
return undefined
}
}
export default config({
streamAuth: {
contextSchema: z.toJSONSchema(z.object({
userId: z.string(),
plan: z.enum(['free', 'pro']),
})),
authenticate: async (request: StreamAuthRequest) => {
const token = extractAuthToken(request)
if (!token) return null
// look up the token in your auth system
const session = await mySessionStore.get(token)
if (!session) {
throw new Error(`Invalid token: ${token}`)
}
return session
},
},
});
and we need to update our chat-messages.stream.ts file with a canAccess function to check whether the current user is allowed to subscribe to that stream (and therefore to that groupId).
// chat-messages.stream.ts
export const config: StreamConfig = {
name: 'chatMessage',
schema: ChatMessageSchema,
baseConfig: { storageType: 'default' },
canAccess: ({ groupId, id, userId }, authContext) => {
if (!authContext) return false
// only allow users that have access to groupId
return isUserHasAccess(groupId, authContext.userId);
},
}
Motia plugins
Motia has a plugin system for extending Workbench (the dashboard UI).It also provides adapters that let you swap or customize core runtime pieces (for example, state or streams). Plugins are split into two groups: official and community.
- BullMQ — queues & background processing
- Redis — state, scheduling & real-time streams.
Right now, there’s only one plugin that adds WebSocket support, so the community traction isn’t there yet.
What Motia isn’t?
Motia isn’t battle-tested at scale. It has ~13k GitHub stars and a lively community, but it’s still relatively new. It hasn’t been pushed to its limits across many real-world products.
If you need a proven, enterprise-grade solution with a long track record, don’t choose Motia (yet).
Keep in mind
At the time of writing this Motia is licensed under Elastic License 2.0. Nothing is wrong with that. This means Motia is source-available not open-source — an important distinction if you plan to rely on it long-term. But, it’s totally understandable — big ambitions come with big cost of development.
Wrap-up
Motia’s pitch is simple: make backend work boring by building most common primitives into one model — steps. Instead of gluing together routing, workers, workflows, state, and realtime, you compose them with the same patterns.
In practice, that means:
- Events + Steps: to split slow work (email, webhooks) out of the request path
- State: to cache results and track progress across async flows
- Streams: to push live updates to clients (great for AI chat apps)
Motia is still relatively new, and it’s not battle-tested at massive scale yet. But if you’re tired of stitching tools together, it’s a promising way to keep your backend simple as it grows.


Top comments (0)