DEV Community

Marcin
Marcin

Posted on • Originally published at marcin.codes

Motia: make backend development boring (and that's the point)

Motia: A New Paradigm in Backend Engineering

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?

Motia's marketing banner

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.

Motia's flow editor

Motia flow editor

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!' } };  
};
Enter fullscreen mode Exit fullscreen mode

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' } };  
};
Enter fullscreen mode Exit fullscreen mode

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' }})  
}
Enter fullscreen mode Exit fullscreen mode

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>  
  )  
}
Enter fullscreen mode Exit fullscreen mode

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 }  
}
Enter fullscreen mode Exit fullscreen mode

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  
    },  
  },  
});
Enter fullscreen mode Exit fullscreen mode

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);  
  },  
}
Enter fullscreen mode Exit fullscreen mode

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.

Official plugins 

  • BullMQ — queues & background processing
  • Redis — state, scheduling & real-time streams.

Community Plugins

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)