Skip to main content

Building Your First App

Let’s build a complete todo list app from scratch and deploy it to Cubby.

What We’ll Build

A simple todo app with:
  • Add new todos
  • Mark todos complete
  • Delete todos
  • Data persisted in Postgres
  • User-specific data (each user sees only their todos)
Time: ~15 minutes

Prerequisites

  • Node.js 18 or later
  • Docker Desktop (for local development)
  • Cubby account and CLI installed
npm install -g cubbypro
cubby login

Step 1: Create the Project

cubby init my-todo-app
cd my-todo-app
npm install
This creates a Next.js + Prisma project configured for Cubby.

Step 2: Define the Data Model

Edit prisma/schema.prisma to add a Todo model:
model Todo {
  id        String   @id @default(cuid())
  title     String
  completed Boolean  @default(false)
  userId    String   // Owner's Cubby user ID
  createdAt DateTime @default(now())
}

// Keep the Example model or delete it
model Example {
  id        String   @id @default(cuid())
  name      String
  createdAt DateTime @default(now())
}

Step 3: Create the API Routes

List and Create Todos

Create app/api/todos/route.ts:
import { headers } from 'next/headers'
import { prisma } from '@/lib/db'
import { NextRequest, NextResponse } from 'next/server'

// GET /api/todos - List user's todos
export async function GET() {
  const h = await headers()
  const userId = h.get('X-Cubby-User-Id')!

  const todos = await prisma.todo.findMany({
    where: { userId },
    orderBy: { createdAt: 'desc' }
  })

  return NextResponse.json(todos)
}

// POST /api/todos - Create a todo
export async function POST(request: NextRequest) {
  const h = await headers()
  const userId = h.get('X-Cubby-User-Id')!
  const { title } = await request.json()

  if (!title || typeof title !== 'string') {
    return NextResponse.json({ error: 'Title required' }, { status: 400 })
  }

  const todo = await prisma.todo.create({
    data: { title, userId }
  })

  return NextResponse.json(todo, { status: 201 })
}

Update and Delete Todos

Create app/api/todos/[id]/route.ts:
import { headers } from 'next/headers'
import { prisma } from '@/lib/db'
import { NextRequest, NextResponse } from 'next/server'

type Params = { params: Promise<{ id: string }> }

// PUT /api/todos/[id] - Update a todo
export async function PUT(request: NextRequest, { params }: Params) {
  const h = await headers()
  const userId = h.get('X-Cubby-User-Id')!
  const { id } = await params
  const { completed } = await request.json()

  // Verify ownership
  const existing = await prisma.todo.findFirst({
    where: { id, userId }
  })
  if (!existing) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }

  const todo = await prisma.todo.update({
    where: { id },
    data: { completed }
  })

  return NextResponse.json(todo)
}

// DELETE /api/todos/[id] - Delete a todo
export async function DELETE(request: NextRequest, { params }: Params) {
  const h = await headers()
  const userId = h.get('X-Cubby-User-Id')!
  const { id } = await params

  // Verify ownership
  const existing = await prisma.todo.findFirst({
    where: { id, userId }
  })
  if (!existing) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }

  await prisma.todo.delete({ where: { id } })
  return NextResponse.json({ deleted: true })
}

Step 4: Build the UI

Replace app/page.tsx with:
'use client'

import { useState, useEffect } from 'react'

export const dynamic = 'force-dynamic'

interface Todo {
  id: string
  title: string
  completed: boolean
  createdAt: string
}

export default function Home() {
  const [todos, setTodos] = useState<Todo[]>([])
  const [newTodo, setNewTodo] = useState('')

  // Load todos on mount
  useEffect(() => {
    fetch('/api/todos')
      .then(res => res.json())
      .then(setTodos)
  }, [])

  // Add a new todo
  async function addTodo(e: React.FormEvent) {
    e.preventDefault()
    if (!newTodo.trim()) return

    const res = await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: newTodo })
    })
    const todo = await res.json()
    setTodos([todo, ...todos])
    setNewTodo('')
  }

  // Toggle completion
  async function toggleTodo(id: string, completed: boolean) {
    await fetch(`/api/todos/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ completed: !completed })
    })
    setTodos(todos.map(t =>
      t.id === id ? { ...t, completed: !completed } : t
    ))
  }

  // Delete a todo
  async function deleteTodo(id: string) {
    await fetch(`/api/todos/${id}`, { method: 'DELETE' })
    setTodos(todos.filter(t => t.id !== id))
  }

  return (
    <main style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
      <h1>My Todos</h1>

      <form onSubmit={addTodo} style={{ marginBottom: 20 }}>
        <input
          value={newTodo}
          onChange={e => setNewTodo(e.target.value)}
          placeholder="What needs to be done?"
          style={{ padding: 10, width: '70%', marginRight: 10 }}
        />
        <button type="submit" style={{ padding: '10px 20px' }}>
          Add
        </button>
      </form>

      <ul style={{ listStyle: 'none', padding: 0 }}>
        {todos.map(todo => (
          <li
            key={todo.id}
            style={{
              display: 'flex',
              alignItems: 'center',
              padding: 10,
              borderBottom: '1px solid #eee'
            }}
          >
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id, todo.completed)}
              style={{ marginRight: 10 }}
            />
            <span style={{
              flex: 1,
              textDecoration: todo.completed ? 'line-through' : 'none',
              color: todo.completed ? '#999' : 'inherit'
            }}>
              {todo.title}
            </span>
            <button
              onClick={() => deleteTodo(todo.id)}
              style={{ color: 'red', border: 'none', cursor: 'pointer' }}
            >
              Delete
            </button>
          </li>
        ))}
      </ul>

      {todos.length === 0 && (
        <p style={{ color: '#999', textAlign: 'center' }}>
          No todos yet. Add one above!
        </p>
      )}
    </main>
  )
}

Step 5: Test Locally

Start the development environment:
cubby dev
Open http://localhost:3000 and test:
  • Add a few todos
  • Mark some complete
  • Delete one
Data persists between page refreshes because it’s stored in the local Postgres database.

Step 6: Deploy to Production

cubby deploy
The CLI will:
  1. Package your code
  2. Build a Docker image
  3. Provision a Postgres database
  4. Run migrations
  5. Start your container
When complete, you’ll see:
Your app is live at:
https://my-todo-app.yourname.cubby.pro

Step 7: Test in Production

Visit your app URL. You’ll be prompted to log in with your Cubby account. After login:
  • Add some todos
  • Refresh the page - data persists
  • Share the app with a friend (they’ll see their own todos, not yours)

What’s Next?

  • Add a secret: Try integrating an API like OpenAI
    cubby secrets set OPENAI_API_KEY --env prod
    cubby deploy
    
  • Share your app: From the dashboard, share with other Cubby users
  • Add more features: Due dates, categories, search

Troubleshooting

”Build failed”

Run cubby check to see what’s wrong:
cubby check

“Not logged in”

cubby login

Database Errors

Reset your local database and try again:
cubby dev --reset