Skip to main content

Overview

This tutorial guides you through building a full-featured Todo API with create, read, update, and delete operations. You’ll learn how to use Motia’s streams for real-time updates and state management for data persistence. What you’ll learn:
  • Creating CRUD HTTP endpoints
  • Input validation with Zod schemas
  • Working with streams for real-time data
  • Managing state across operations
  • Path parameters and request bodies
  • Update operations with atomic updates

Prerequisites

Before starting, make sure you have:
  • Node.js version 19 or higher
  • Completed the Hello World tutorial
  • Basic understanding of REST APIs

Project Setup

1

Create project directory

mkdir todo-api
cd todo-api
npm init -y
2

Install dependencies

npm install motia zod
npm install -D typescript @types/node
Update package.json:
{
  "type": "module",
  "scripts": {
    "dev": "iii",
    "build": "motia build"
  }
}
3

Create directory structure

mkdir -p steps/todo

Building the API

Step 1: Define the Todo Stream

Streams provide real-time, reactive data storage. Create steps/todo/todo.stream.ts:
steps/todo/todo.stream.ts
import type { StreamConfig } from 'motia'
import { z } from 'zod'

const todoSchema = z.object({
  id: z.string(),
  description: z.string(),
  createdAt: z.string(),
  dueDate: z.string().optional(),
  completedAt: z.string().optional(),
})

export const config: StreamConfig = {
  baseConfig: { storageType: 'default' },
  name: 'todo',
  schema: todoSchema,

  onJoin: async (subscription, context, authContext) => {
    context.logger.info('Todo stream joined', { subscription, authContext })
    return { unauthorized: false }
  },

  onLeave: async (subscription, context, authContext) => {
    context.logger.info('Todo stream left', { subscription, authContext })
  },
}

export type Todo = z.infer<typeof todoSchema>
Key concepts:
  • Schema: Defines the structure of stream data
  • onJoin/onLeave: Hooks for subscription lifecycle events
  • Type export: Makes the Todo type available to other files

Step 2: Create Todo Endpoint

Create steps/todo/create-todo.step.ts:
steps/todo/create-todo.step.ts
import { type Handlers, http, type StepConfig } from 'motia'
import { z } from 'zod'
import type { Todo } from './todo.stream'

export const todoSchema = z.object({
  id: z.string(),
  description: z.string(),
  createdAt: z.string(),
  dueDate: z.string().optional(),
  completedAt: z.string().optional(),
})

export const config = {
  name: 'CreateTodo',
  description: 'Create a new todo item',
  flows: ['todo-app'],
  triggers: [
    http('POST', '/todo', {
      bodySchema: z.object({ 
        description: z.string(), 
        dueDate: z.string().optional() 
      }),
      responseSchema: {
        200: todoSchema,
        400: z.object({ error: z.string() }),
      },
    }),
  ],
  enqueues: [],
  virtualEnqueues: ['todo-created'],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (
  { request },
  { logger, streams, state }
) => {
  logger.info('Creating new todo', { body: request.body })

  const { description, dueDate } = request.body || {}
  const todoId = `todo-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`

  if (!description) {
    return { status: 400, body: { error: 'Description is required' } }
  }

  const newTodo: Todo = {
    id: todoId,
    description,
    createdAt: new Date().toISOString(),
    dueDate,
  }

  // Store in stream for real-time updates
  const todo = await streams.todo.set('inbox', todoId, newTodo)

  // Store in state for persistence
  await state.set('todos', todoId, newTodo)

  logger.info('Todo created successfully', { todoId })

  return { status: 200, body: todo.new_value }
}
Key concepts:
  • http() helper: Shorthand for defining HTTP triggers
  • Body validation: Automatically validates request bodies
  • Streams vs State: Streams for real-time data, state for persistence
  • Virtual enqueues: Declares events without actual queue topics

Step 3: Update Todo Endpoint

Create steps/todo/update-todo.step.ts:
steps/todo/update-todo.step.ts
import type { Handlers, StepConfig, UpdateOp } from 'motia'
import { z } from 'zod'
import { todoSchema } from './create-todo.step'

export const config = {
  name: 'UpdateTodo',
  description: 'Update an existing todo item',
  flows: ['todo-app'],
  triggers: [
    {
      type: 'http',
      method: 'PUT',
      path: '/todo/:todoId',
      bodySchema: z.object({
        description: z.string().optional(),
        dueDate: z.string().optional(),
        checked: z.boolean().optional(),
      }),
      responseSchema: {
        200: todoSchema,
        404: z.object({ error: z.string() }),
      },
    },
  ],
  enqueues: [],
  virtualSubscribes: ['todo-created'],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (
  { request },
  { logger, streams }
) => {
  const { todoId } = request.pathParams || {}
  const body = request.body || {}

  logger.info('Updating todo', { todoId, body })

  const updateOps: UpdateOp[] = []

  if (body.checked !== undefined) {
    updateOps.push({ 
      type: 'set', 
      path: 'completedAt', 
      value: body.checked ? new Date().toISOString() : undefined 
    })
  }

  if (body.description !== undefined) {
    updateOps.push({ type: 'set', path: 'description', value: body.description })
  }

  if (body.dueDate !== undefined) {
    updateOps.push({ type: 'set', path: 'dueDate', value: body.dueDate })
  }

  if (updateOps.length === 0) {
    return { status: 400, body: { error: 'No fields to update' } }
  }

  const result = await streams.todo.update('inbox', todoId, updateOps)

  if (!result.old_value) {
    return { status: 404, body: { error: `Todo with id ${todoId} not found` } }
  }

  logger.info('Todo updated successfully', { todoId })

  return { status: 200, body: result.new_value }
}
Key concepts:
  • Path parameters: Extract values from URL paths
  • Update operations: Atomic updates with UpdateOp array
  • Conditional updates: Only apply provided fields
  • Error handling: Return appropriate status codes

Step 4: Delete Todo Endpoint

Create steps/todo/delete-todo.step.ts:
steps/todo/delete-todo.step.ts
import type { Handlers, StepConfig } from 'motia'
import { z } from 'zod'

export const config = {
  name: 'DeleteTodo',
  description: 'Delete a todo item',
  flows: ['todo-app'],
  triggers: [
    {
      type: 'http',
      method: 'DELETE',
      path: '/todo/:todoId',
      responseSchema: {
        200: z.object({ id: z.string() }),
        404: z.object({ error: z.string() }),
      },
    },
  ],
  enqueues: [],
  virtualSubscribes: ['todo-created'],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (
  { request },
  { logger, streams }
) => {
  const { todoId } = request.pathParams || {}

  logger.info('Deleting todo', { todoId })

  const existingTodo = await streams.todo.get('inbox', todoId)

  if (!existingTodo) {
    logger.warn('Todo not found', { todoId })
    return {
      status: 404,
      body: { error: `Todo with id ${todoId} not found` },
    }
  }

  await streams.todo.delete('inbox', todoId)

  logger.info('Todo deleted successfully', { todoId })

  return {
    status: 200,
    body: { id: todoId },
  }
}

Step 5: List Todos Endpoint

Create steps/todo/list-todos.step.ts:
steps/todo/list-todos.step.ts
import type { Handlers, StepConfig } from 'motia'
import { z } from 'zod'
import { todoSchema } from './create-todo.step'

export const config = {
  name: 'ListTodos',
  description: 'List all todo items',
  flows: ['todo-app'],
  triggers: [
    {
      type: 'http',
      method: 'GET',
      path: '/todos',
      responseSchema: {
        200: z.array(todoSchema),
      },
    },
  ],
  enqueues: [],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (_, { logger, state }) => {
  logger.info('Listing all todos')

  const todos = await state.list('todos')

  return {
    status: 200,
    body: todos,
  }
}

Running the Application

1

Start the server

npm run dev
2

Create a todo

curl -X POST http://localhost:3000/todo \
  -H "Content-Type: application/json" \
  -d '{"description": "Buy groceries", "dueDate": "2026-03-10"}'
Response:
{
  "id": "todo-1709568000000-abc123",
  "description": "Buy groceries",
  "createdAt": "2026-03-04T10:00:00.000Z",
  "dueDate": "2026-03-10"
}
3

List todos

curl http://localhost:3000/todos
4

Update a todo

curl -X PUT http://localhost:3000/todo/todo-1709568000000-abc123 \
  -H "Content-Type: application/json" \
  -d '{"checked": true}'
5

Delete a todo

curl -X DELETE http://localhost:3000/todo/todo-1709568000000-abc123

Testing

Create a simple test script test-api.sh:
test-api.sh
#!/bin/bash

API="http://localhost:3000"

echo "Creating todo..."
RESPONSE=$(curl -s -X POST $API/todo \
  -H "Content-Type: application/json" \
  -d '{"description": "Test todo", "dueDate": "2026-03-10"}')

TODO_ID=$(echo $RESPONSE | jq -r '.id')
echo "Created: $TODO_ID"

echo "\nListing todos..."
curl -s $API/todos | jq

echo "\nUpdating todo..."
curl -s -X PUT $API/todo/$TODO_ID \
  -H "Content-Type: application/json" \
  -d '{"checked": true}' | jq

echo "\nDeleting todo..."
curl -s -X DELETE $API/todo/$TODO_ID | jq
Run it:
chmod +x test-api.sh
./test-api.sh

Next Steps

Message Queue

Add background processing to your API

Scheduled Tasks

Learn to run periodic cleanup jobs

Streams Guide

Deep dive into real-time streams

State Management

Learn more about state operations

Build docs developers (and LLMs) love