We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide46 min read

Convex

Convex is a reactive backend with real-time database, serverless functions, file storage, and built-in authentication. Complete guide to building real-time applications.

Convex - Reaktywny Backend dla Nowoczesnych Aplikacji

Czym jest Convex?

Convex to reaktywny backend-as-a-service, który automatycznie synchronizuje dane między serwerem a klientem w czasie rzeczywistym. W przeciwieństwie do tradycyjnych baz danych, gdzie musisz ręcznie implementować WebSockety, polling lub Server-Sent Events, Convex robi to automatycznie - każda zmiana w bazie danych natychmiast propaguje do wszystkich podłączonych klientów.

Convex został stworzony przez zespół inżynierów z Dropbox i Google, którzy rozumieli frustrację związaną z budowaniem real-time aplikacji. Zamiast składać razem wiele usług (baza danych, serverless functions, file storage, caching), Convex oferuje zintegrowane rozwiązanie z TypeScript-first podejściem.

Kluczowa filozofia Convex to "reactive by default" - nie musisz myśleć o synchronizacji danych, cachingu czy invalidacji. Definiujesz funkcje (queries, mutations, actions), a Convex automatycznie zarządza stanem i aktualizacjami w czasie rzeczywistym.

Dlaczego Convex?

Kluczowe zalety platformy

  1. Reaktywność z automatu - Zmiany propagują do UI bez dodatkowego kodu
  2. TypeScript End-to-End - Pełna type safety od bazy po frontend
  3. Zero konfiguracji WebSocket - Real-time bez boilerplate
  4. ACID Transactions - Gwarancja spójności danych
  5. Serverless Functions - Queries, Mutations, Actions
  6. File Storage - Wbudowane przechowywanie plikĂłw
  7. Scheduled Functions - Cron jobs i delayed execution
  8. Built-in Auth - Integracja z Clerk, Auth0, własna auth

Convex vs Firebase vs Supabase vs PocketBase

CechaConvexFirebaseSupabasePocketBase
Real-timeâś… Natywneâś… Firestoreâś… Postgresâś… SSE
Type Safety✅ Full TypeScript❌ RuntimeCzęściowe❌
Transactions✅ ACID❌ Limited✅✅
SQL❌ Custom queries❌✅✅ SQLite
Self-hosting❌❌✅✅
Free tier1GB, 1M callsGenerous500MBUnlimited
Serverless Functions✅ WbudowaneCloud FunctionsEdge Functions❌
File Storageâś…âś…âś…âś…
PricingPay-as-you-goPay-as-you-goFixed tiersFree

Kiedy wybrać Convex?

Convex jest idealny gdy:

  • Budujesz aplikacje real-time (czaty, kolaboracja, dashboardy)
  • ZaleĹĽy Ci na type safety end-to-end
  • Chcesz uniknąć boilerplate WebSocket/polling
  • Potrzebujesz transakcji ACID
  • TwĂłj zespół preferuje TypeScript
  • Szybko prototypujesz MVP

RozwaĹĽ alternatywy gdy:

  • Potrzebujesz SQL i zĹ‚oĹĽonych zapytaĹ„ (Supabase)
  • Wymagany jest self-hosting (PocketBase, Supabase)
  • Masz istniejÄ…cÄ… bazÄ™ PostgreSQL/MySQL
  • Potrzebujesz peĹ‚nej kontroli nad infrastrukturÄ…

Instalacja i konfiguracja

Nowy projekt

Code
Bash
# StwĂłrz nowy projekt Convex
npm create convex@latest

# Lub dodaj do istniejÄ…cego projektu
npm install convex

# Inicjalizacja
npx convex dev

Integracja z Next.js

Code
Bash
# Instalacja
npm install convex

# Inicjalizacja (tworzy folder convex/)
npx convex dev
TSconvex/_generated/api.d.ts
TypeScript
// convex/_generated/api.d.ts - automatycznie generowane typy

// app/ConvexClientProvider.tsx
'use client'

import { ConvexProvider, ConvexReactClient } from 'convex/react'
import { ReactNode } from 'react'

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!)

export function ConvexClientProvider({ children }: { children: ReactNode }) {
  return (
    <ConvexProvider client={convex}>
      {children}
    </ConvexProvider>
  )
}

// app/layout.tsx
import { ConvexClientProvider } from './ConvexClientProvider'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ConvexClientProvider>
          {children}
        </ConvexClientProvider>
      </body>
    </html>
  )
}

Integracja z React (Vite)

TSsrc/main.tsx
TypeScript
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ConvexProvider, ConvexReactClient } from 'convex/react'
import App from './App'

const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL)

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <ConvexProvider client={convex}>
      <App />
    </ConvexProvider>
  </React.StrictMode>
)

Struktura projektu

Code
TEXT
my-app/
├── convex/
│   ├── _generated/          # Auto-generated (API types, server)
│   │   ├── api.d.ts
│   │   ├── api.js
│   │   ├── dataModel.d.ts
│   │   └── server.d.ts
│   ├── schema.ts            # Database schema
│   ├── posts.ts             # Posts functions
│   ├── users.ts             # Users functions
│   ├── files.ts             # File handling
│   └── http.ts              # HTTP endpoints
├── src/
│   └── ...
├── convex.json              # Convex configuration
└── .env.local               # CONVEX_DEPLOYMENT, NEXT_PUBLIC_CONVEX_URL

Schema - Definiowanie Struktury Danych

Podstawowa schema

TSconvex/schema.ts
TypeScript
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'

export default defineSchema({
  // Tabela uĹĽytkownikĂłw
  users: defineTable({
    name: v.string(),
    email: v.string(),
    avatarUrl: v.optional(v.string()),
    role: v.union(v.literal('admin'), v.literal('user'), v.literal('guest')),
    createdAt: v.number(),
  })
    .index('by_email', ['email'])
    .index('by_role', ['role']),

  // Tabela postĂłw
  posts: defineTable({
    title: v.string(),
    content: v.string(),
    slug: v.string(),
    authorId: v.id('users'),
    published: v.boolean(),
    publishedAt: v.optional(v.number()),
    tags: v.array(v.string()),
    viewCount: v.number(),
  })
    .index('by_author', ['authorId'])
    .index('by_slug', ['slug'])
    .index('by_published', ['published', 'publishedAt'])
    .searchIndex('search_posts', {
      searchField: 'title',
      filterFields: ['published', 'authorId'],
    }),

  // Tabela komentarzy
  comments: defineTable({
    postId: v.id('posts'),
    authorId: v.id('users'),
    content: v.string(),
    createdAt: v.number(),
    parentId: v.optional(v.id('comments')), // Nested comments
  })
    .index('by_post', ['postId'])
    .index('by_author', ['authorId']),

  // Tabela plikĂłw
  files: defineTable({
    storageId: v.id('_storage'),
    name: v.string(),
    type: v.string(),
    size: v.number(),
    uploadedBy: v.id('users'),
    postId: v.optional(v.id('posts')),
  })
    .index('by_user', ['uploadedBy'])
    .index('by_post', ['postId']),
})

Typy wartości (validators)

Code
TypeScript
import { v } from 'convex/values'

// Primitive types
v.string()              // string
v.number()              // number
v.boolean()             // boolean
v.null()                // null
v.int64()               // bigint
v.bytes()               // ArrayBuffer

// Complex types
v.array(v.string())     // string[]
v.object({ name: v.string() })  // { name: string }
v.id('users')           // Id<"users">

// Optional
v.optional(v.string())  // string | undefined

// Union types
v.union(
  v.literal('draft'),
  v.literal('published'),
  v.literal('archived')
)  // 'draft' | 'published' | 'archived'

// Any type (use sparingly)
v.any()

Zaawansowana schema z relacjami

TSconvex/schema.ts
TypeScript
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'

// Reusable validators
const timestamps = {
  createdAt: v.number(),
  updatedAt: v.number(),
}

const addressValidator = v.object({
  street: v.string(),
  city: v.string(),
  country: v.string(),
  zipCode: v.string(),
})

export default defineSchema({
  organizations: defineTable({
    name: v.string(),
    slug: v.string(),
    plan: v.union(
      v.literal('free'),
      v.literal('pro'),
      v.literal('enterprise')
    ),
    settings: v.object({
      allowPublicProjects: v.boolean(),
      maxMembers: v.number(),
    }),
    ...timestamps,
  }).index('by_slug', ['slug']),

  members: defineTable({
    organizationId: v.id('organizations'),
    userId: v.id('users'),
    role: v.union(
      v.literal('owner'),
      v.literal('admin'),
      v.literal('member')
    ),
    ...timestamps,
  })
    .index('by_org', ['organizationId'])
    .index('by_user', ['userId'])
    .index('by_org_user', ['organizationId', 'userId']),

  projects: defineTable({
    organizationId: v.id('organizations'),
    name: v.string(),
    description: v.optional(v.string()),
    visibility: v.union(v.literal('public'), v.literal('private')),
    settings: v.object({
      enableComments: v.boolean(),
      enableNotifications: v.boolean(),
    }),
    ...timestamps,
  })
    .index('by_org', ['organizationId'])
    .index('by_org_visibility', ['organizationId', 'visibility']),

  users: defineTable({
    clerkId: v.string(), // External auth ID
    email: v.string(),
    name: v.string(),
    avatarUrl: v.optional(v.string()),
    address: v.optional(addressValidator),
    ...timestamps,
  })
    .index('by_clerk_id', ['clerkId'])
    .index('by_email', ['email']),
})

Queries - Pobieranie Danych

Podstawowe query

TSconvex/posts.ts
TypeScript
// convex/posts.ts
import { query } from './_generated/server'
import { v } from 'convex/values'

// Proste query bez argumentĂłw
export const list = query({
  handler: async (ctx) => {
    // ctx.db to database interface
    return await ctx.db
      .query('posts')
      .filter((q) => q.eq(q.field('published'), true))
      .order('desc')
      .collect()
  },
})

// Query z argumentami
export const getById = query({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    return await ctx.db.get(args.id)
  },
})

// Query z paginacjÄ…
export const listPaginated = query({
  args: {
    paginationOpts: v.object({
      numItems: v.number(),
      cursor: v.optional(v.string()),
    }),
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('posts')
      .filter((q) => q.eq(q.field('published'), true))
      .order('desc')
      .paginate(args.paginationOpts)
  },
})

UĹĽywanie indeksĂłw

TSconvex/posts.ts
TypeScript
// convex/posts.ts
import { query } from './_generated/server'
import { v } from 'convex/values'

// Query uĹĽywajÄ…ce indeksu
export const getByAuthor = query({
  args: { authorId: v.id('users') },
  handler: async (ctx, args) => {
    // withIndex jest DUŻO szybsze niż filter
    return await ctx.db
      .query('posts')
      .withIndex('by_author', (q) => q.eq('authorId', args.authorId))
      .order('desc')
      .collect()
  },
})

// Compound index query
export const getPublishedByAuthor = query({
  args: { authorId: v.id('users') },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('posts')
      .withIndex('by_author', (q) => q.eq('authorId', args.authorId))
      .filter((q) => q.eq(q.field('published'), true))
      .collect()
  },
})

// Range query z indeksem
export const getRecentPublished = query({
  args: { since: v.number() },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('posts')
      .withIndex('by_published', (q) =>
        q.eq('published', true).gte('publishedAt', args.since)
      )
      .order('desc')
      .take(20)
  },
})

Full-text Search

TSconvex/posts.ts
TypeScript
// convex/posts.ts
export const search = query({
  args: {
    searchTerm: v.string(),
    published: v.optional(v.boolean()),
  },
  handler: async (ctx, args) => {
    let searchQuery = ctx.db
      .query('posts')
      .withSearchIndex('search_posts', (q) => {
        let search = q.search('title', args.searchTerm)

        if (args.published !== undefined) {
          search = search.eq('published', args.published)
        }

        return search
      })

    return await searchQuery.take(20)
  },
})

Query z relacjami (joins)

TSconvex/posts.ts
TypeScript
// convex/posts.ts
export const listWithAuthors = query({
  handler: async (ctx) => {
    const posts = await ctx.db
      .query('posts')
      .filter((q) => q.eq(q.field('published'), true))
      .order('desc')
      .take(20)

    // "Join" - pobierz autorĂłw dla kaĹĽdego posta
    return await Promise.all(
      posts.map(async (post) => {
        const author = await ctx.db.get(post.authorId)
        return {
          ...post,
          author: author ? { name: author.name, avatarUrl: author.avatarUrl } : null,
        }
      })
    )
  },
})

// Bardziej złożony join z komentarzami
export const getPostWithComments = query({
  args: { postId: v.id('posts') },
  handler: async (ctx, args) => {
    const post = await ctx.db.get(args.postId)
    if (!post) return null

    const [author, comments] = await Promise.all([
      ctx.db.get(post.authorId),
      ctx.db
        .query('comments')
        .withIndex('by_post', (q) => q.eq('postId', args.postId))
        .order('desc')
        .collect(),
    ])

    // Pobierz autorĂłw komentarzy
    const commentsWithAuthors = await Promise.all(
      comments.map(async (comment) => {
        const commentAuthor = await ctx.db.get(comment.authorId)
        return {
          ...comment,
          author: commentAuthor,
        }
      })
    )

    return {
      ...post,
      author,
      comments: commentsWithAuthors,
    }
  },
})

Mutations - Modyfikowanie Danych

Podstawowe mutations

TSconvex/posts.ts
TypeScript
// convex/posts.ts
import { mutation } from './_generated/server'
import { v } from 'convex/values'

// Tworzenie
export const create = mutation({
  args: {
    title: v.string(),
    content: v.string(),
    authorId: v.id('users'),
    tags: v.optional(v.array(v.string())),
  },
  handler: async (ctx, args) => {
    const slug = args.title.toLowerCase().replace(/\s+/g, '-')

    const postId = await ctx.db.insert('posts', {
      title: args.title,
      content: args.content,
      slug,
      authorId: args.authorId,
      published: false,
      tags: args.tags || [],
      viewCount: 0,
    })

    return postId
  },
})

// Aktualizacja
export const update = mutation({
  args: {
    id: v.id('posts'),
    title: v.optional(v.string()),
    content: v.optional(v.string()),
    tags: v.optional(v.array(v.string())),
  },
  handler: async (ctx, args) => {
    const { id, ...updates } = args

    // Pobierz istniejÄ…cy post
    const existing = await ctx.db.get(id)
    if (!existing) {
      throw new Error('Post not found')
    }

    // Zaktualizuj tylko podane pola
    const updateData: Partial<typeof existing> = {}
    if (updates.title !== undefined) {
      updateData.title = updates.title
      updateData.slug = updates.title.toLowerCase().replace(/\s+/g, '-')
    }
    if (updates.content !== undefined) updateData.content = updates.content
    if (updates.tags !== undefined) updateData.tags = updates.tags

    await ctx.db.patch(id, updateData)
  },
})

// Usuwanie
export const remove = mutation({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    // Najpierw usuń powiązane komentarze
    const comments = await ctx.db
      .query('comments')
      .withIndex('by_post', (q) => q.eq('postId', args.id))
      .collect()

    for (const comment of comments) {
      await ctx.db.delete(comment._id)
    }

    // Usuń post
    await ctx.db.delete(args.id)
  },
})

Mutations z walidacjÄ… i autoryzacjÄ…

TSconvex/posts.ts
TypeScript
// convex/posts.ts
import { mutation } from './_generated/server'
import { v } from 'convex/values'

export const publish = mutation({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    // Pobierz tożsamość użytkownika
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) {
      throw new Error('Not authenticated')
    }

    // ZnajdĹş uĹĽytkownika w bazie
    const user = await ctx.db
      .query('users')
      .withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
      .unique()

    if (!user) {
      throw new Error('User not found')
    }

    // Pobierz post
    const post = await ctx.db.get(args.id)
    if (!post) {
      throw new Error('Post not found')
    }

    // SprawdĹş czy uĹĽytkownik jest autorem
    if (post.authorId !== user._id) {
      throw new Error('Not authorized')
    }

    // SprawdĹş czy post nie jest juĹĽ opublikowany
    if (post.published) {
      throw new Error('Post is already published')
    }

    // Walidacja treści
    if (post.content.length < 100) {
      throw new Error('Post content must be at least 100 characters')
    }

    // Publikuj
    await ctx.db.patch(args.id, {
      published: true,
      publishedAt: Date.now(),
    })

    return { success: true }
  },
})

Transakcje (automatyczne w Convex)

Code
TypeScript
// Wszystkie mutations w Convex sÄ… transakcjami ACID
export const transferCredits = mutation({
  args: {
    fromUserId: v.id('users'),
    toUserId: v.id('users'),
    amount: v.number(),
  },
  handler: async (ctx, args) => {
    const fromUser = await ctx.db.get(args.fromUserId)
    const toUser = await ctx.db.get(args.toUserId)

    if (!fromUser || !toUser) {
      throw new Error('User not found')
    }

    if (fromUser.credits < args.amount) {
      throw new Error('Insufficient credits')
    }

    // Te operacje sÄ… atomowe - albo obie siÄ™ wykonajÄ…, albo ĹĽadna
    await ctx.db.patch(args.fromUserId, {
      credits: fromUser.credits - args.amount,
    })

    await ctx.db.patch(args.toUserId, {
      credits: toUser.credits + args.amount,
    })

    // Zapisz historiÄ™ transakcji
    await ctx.db.insert('transactions', {
      fromUserId: args.fromUserId,
      toUserId: args.toUserId,
      amount: args.amount,
      createdAt: Date.now(),
    })

    return { success: true }
  },
})

Actions - Zewnętrzne API i Side Effects

Actions służą do operacji, które nie powinny być w mutation (zewnętrzne API, niedeterministyczne operacje).

TSconvex/actions.ts
TypeScript
// convex/actions.ts
import { action } from './_generated/server'
import { v } from 'convex/values'
import { api } from './_generated/api'

// Action wywołujące zewnętrzne API
export const sendEmail = action({
  args: {
    to: v.string(),
    subject: v.string(),
    body: v.string(),
  },
  handler: async (ctx, args) => {
    // Zewnętrzne API - nie może być w mutation
    const response = await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        from: 'noreply@myapp.com',
        to: args.to,
        subject: args.subject,
        html: args.body,
      }),
    })

    if (!response.ok) {
      throw new Error('Failed to send email')
    }

    return { success: true }
  },
})

// Action z wywołaniem mutation
export const createPostWithAIContent = action({
  args: {
    title: v.string(),
    topic: v.string(),
    authorId: v.id('users'),
  },
  handler: async (ctx, args) => {
    // Wygeneruj treść za pomocą AI
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'gpt-4',
        messages: [
          {
            role: 'user',
            content: `Write a blog post about: ${args.topic}`,
          },
        ],
      }),
    })

    const data = await response.json()
    const content = data.choices[0].message.content

    // Wywołaj mutation żeby zapisać do bazy
    const postId = await ctx.runMutation(api.posts.create, {
      title: args.title,
      content,
      authorId: args.authorId,
    })

    return { postId, content }
  },
})

// Action z wywołaniem query
export const generateReport = action({
  args: { userId: v.id('users') },
  handler: async (ctx, args) => {
    // Pobierz dane z query
    const posts = await ctx.runQuery(api.posts.getByAuthor, {
      authorId: args.userId,
    })

    // Wygeneruj raport
    const report = {
      totalPosts: posts.length,
      publishedPosts: posts.filter((p) => p.published).length,
      totalViews: posts.reduce((sum, p) => sum + p.viewCount, 0),
      generatedAt: new Date().toISOString(),
    }

    // Wyślij email z raportem
    await ctx.runAction(api.actions.sendEmail, {
      to: 'user@example.com',
      subject: 'Your Monthly Report',
      body: `<h1>Report</h1><pre>${JSON.stringify(report, null, 2)}</pre>`,
    })

    return report
  },
})

UĹĽywanie Convex w React

useQuery - reaktywne pobieranie

TScomponents/PostList.tsx
TypeScript
// components/PostList.tsx
import { useQuery } from 'convex/react'
import { api } from '../convex/_generated/api'

export function PostList() {
  // Automatycznie reaktywne - aktualizuje siÄ™ gdy dane siÄ™ zmieniajÄ…
  const posts = useQuery(api.posts.list)

  // Loading state
  if (posts === undefined) {
    return <div>Loading...</div>
  }

  // Empty state
  if (posts.length === 0) {
    return <div>No posts yet</div>
  }

  return (
    <ul>
      {posts.map((post) => (
        <li key={post._id}>
          <h2>{post.title}</h2>
          <p>{post.content.substring(0, 100)}...</p>
        </li>
      ))}
    </ul>
  )
}

// Z argumentami
export function UserPosts({ userId }: { userId: Id<'users'> }) {
  const posts = useQuery(api.posts.getByAuthor, { authorId: userId })

  // Conditional query - skip gdy userId undefined
  // const posts = useQuery(
  //   userId ? api.posts.getByAuthor : 'skip',
  //   userId ? { authorId: userId } : undefined
  // )

  if (posts === undefined) return <div>Loading...</div>

  return (
    <div>
      <h2>Posts by user</h2>
      {posts.map((post) => (
        <PostCard key={post._id} post={post} />
      ))}
    </div>
  )
}

useMutation - modyfikowanie danych

TScomponents/CreatePostForm.tsx
TypeScript
// components/CreatePostForm.tsx
import { useMutation } from 'convex/react'
import { api } from '../convex/_generated/api'
import { useState } from 'react'

export function CreatePostForm({ authorId }: { authorId: Id<'users'> }) {
  const [title, setTitle] = useState('')
  const [content, setContent] = useState('')
  const [isSubmitting, setIsSubmitting] = useState(false)

  const createPost = useMutation(api.posts.create)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setIsSubmitting(true)

    try {
      await createPost({
        title,
        content,
        authorId,
      })

      // Reset form
      setTitle('')
      setContent('')
    } catch (error) {
      console.error('Failed to create post:', error)
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Title"
        required
      />
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Content"
        required
      />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

useAction - wywołanie actions

TScomponents/GenerateWithAI.tsx
TypeScript
// components/GenerateWithAI.tsx
import { useAction } from 'convex/react'
import { api } from '../convex/_generated/api'
import { useState } from 'react'

export function GenerateWithAI({ authorId }: { authorId: Id<'users'> }) {
  const [title, setTitle] = useState('')
  const [topic, setTopic] = useState('')
  const [isGenerating, setIsGenerating] = useState(false)
  const [result, setResult] = useState<string | null>(null)

  const generatePost = useAction(api.actions.createPostWithAIContent)

  const handleGenerate = async () => {
    setIsGenerating(true)
    try {
      const result = await generatePost({
        title,
        topic,
        authorId,
      })
      setResult(result.content)
    } catch (error) {
      console.error('Failed to generate:', error)
    } finally {
      setIsGenerating(false)
    }
  }

  return (
    <div>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Post title"
      />
      <input
        value={topic}
        onChange={(e) => setTopic(e.target.value)}
        placeholder="Topic for AI"
      />
      <button onClick={handleGenerate} disabled={isGenerating}>
        {isGenerating ? 'Generating...' : 'Generate with AI'}
      </button>
      {result && (
        <div>
          <h3>Generated content:</h3>
          <p>{result}</p>
        </div>
      )}
    </div>
  )
}

Paginacja z usePaginatedQuery

TScomponents/InfinitePostList.tsx
TypeScript
// components/InfinitePostList.tsx
import { usePaginatedQuery } from 'convex/react'
import { api } from '../convex/_generated/api'

export function InfinitePostList() {
  const { results, status, loadMore } = usePaginatedQuery(
    api.posts.listPaginated,
    {},
    { initialNumItems: 10 }
  )

  return (
    <div>
      {results.map((post) => (
        <PostCard key={post._id} post={post} />
      ))}

      {status === 'CanLoadMore' && (
        <button onClick={() => loadMore(10)}>Load More</button>
      )}

      {status === 'LoadingMore' && <div>Loading more...</div>}

      {status === 'Exhausted' && <div>No more posts</div>}
    </div>
  )
}

Optimistic Updates

TScomponents/LikeButton.tsx
TypeScript
// components/LikeButton.tsx
import { useMutation, useQuery } from 'convex/react'
import { api } from '../convex/_generated/api'
import { useState } from 'react'

export function LikeButton({ postId }: { postId: Id<'posts'> }) {
  const post = useQuery(api.posts.getById, { id: postId })
  const toggleLike = useMutation(api.posts.toggleLike)
  const [optimisticLiked, setOptimisticLiked] = useState<boolean | null>(null)

  if (!post) return null

  const isLiked = optimisticLiked ?? post.isLikedByCurrentUser

  const handleClick = async () => {
    // Optimistic update
    setOptimisticLiked(!isLiked)

    try {
      await toggleLike({ postId })
    } catch (error) {
      // Rollback on error
      setOptimisticLiked(null)
    } finally {
      // Let real data take over
      setOptimisticLiked(null)
    }
  }

  return (
    <button
      onClick={handleClick}
      className={isLiked ? 'liked' : ''}
    >
      {isLiked ? '❤️' : '🤍'} {post.likesCount}
    </button>
  )
}

File Storage

Upload plikĂłw

TSconvex/files.ts
TypeScript
// convex/files.ts
import { mutation, query } from './_generated/server'
import { v } from 'convex/values'

// Generuj URL do uploadu
export const generateUploadUrl = mutation({
  handler: async (ctx) => {
    return await ctx.storage.generateUploadUrl()
  },
})

// Zapisz metadane pliku po uploadzie
export const saveFile = mutation({
  args: {
    storageId: v.id('_storage'),
    name: v.string(),
    type: v.string(),
    size: v.number(),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Not authenticated')

    const user = await ctx.db
      .query('users')
      .withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
      .unique()

    if (!user) throw new Error('User not found')

    return await ctx.db.insert('files', {
      storageId: args.storageId,
      name: args.name,
      type: args.type,
      size: args.size,
      uploadedBy: user._id,
    })
  },
})

// Pobierz URL do pliku
export const getFileUrl = query({
  args: { storageId: v.id('_storage') },
  handler: async (ctx, args) => {
    return await ctx.storage.getUrl(args.storageId)
  },
})

// Usuń plik
export const deleteFile = mutation({
  args: { fileId: v.id('files') },
  handler: async (ctx, args) => {
    const file = await ctx.db.get(args.fileId)
    if (!file) throw new Error('File not found')

    // Usuń z storage
    await ctx.storage.delete(file.storageId)

    // Usuń metadane
    await ctx.db.delete(args.fileId)
  },
})

React component do uploadu

TScomponents/FileUpload.tsx
TypeScript
// components/FileUpload.tsx
import { useMutation } from 'convex/react'
import { api } from '../convex/_generated/api'
import { useState, useRef } from 'react'

export function FileUpload() {
  const [isUploading, setIsUploading] = useState(false)
  const [progress, setProgress] = useState(0)
  const fileInputRef = useRef<HTMLInputElement>(null)

  const generateUploadUrl = useMutation(api.files.generateUploadUrl)
  const saveFile = useMutation(api.files.saveFile)

  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    setIsUploading(true)
    setProgress(0)

    try {
      // 1. Pobierz URL do uploadu
      const uploadUrl = await generateUploadUrl()

      // 2. Upload pliku
      const response = await fetch(uploadUrl, {
        method: 'POST',
        headers: { 'Content-Type': file.type },
        body: file,
      })

      if (!response.ok) throw new Error('Upload failed')

      const { storageId } = await response.json()

      // 3. Zapisz metadane
      await saveFile({
        storageId,
        name: file.name,
        type: file.type,
        size: file.size,
      })

      setProgress(100)
    } catch (error) {
      console.error('Upload failed:', error)
    } finally {
      setIsUploading(false)
      if (fileInputRef.current) {
        fileInputRef.current.value = ''
      }
    }
  }

  return (
    <div>
      <input
        ref={fileInputRef}
        type="file"
        onChange={handleUpload}
        disabled={isUploading}
      />
      {isUploading && (
        <div>
          <progress value={progress} max="100" />
          <span>{progress}%</span>
        </div>
      )}
    </div>
  )
}

// Wyświetlanie obrazu
export function ImageDisplay({ storageId }: { storageId: Id<'_storage'> }) {
  const url = useQuery(api.files.getFileUrl, { storageId })

  if (!url) return <div>Loading...</div>

  return <img src={url} alt="" />
}

Autentykacja

Integracja z Clerk

TSconvex/auth.config.ts
TypeScript
// convex/auth.config.ts
export default {
  providers: [
    {
      domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
      applicationID: 'convex',
    },
  ],
}

// convex/users.ts
import { mutation, query } from './_generated/server'
import { v } from 'convex/values'

// UtwĂłrz lub zaktualizuj uĹĽytkownika po zalogowaniu
export const upsertUser = mutation({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Not authenticated')

    // SprawdĹş czy uĹĽytkownik istnieje
    const existingUser = await ctx.db
      .query('users')
      .withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
      .unique()

    if (existingUser) {
      // Aktualizuj dane
      await ctx.db.patch(existingUser._id, {
        name: identity.name ?? existingUser.name,
        email: identity.email ?? existingUser.email,
        avatarUrl: identity.pictureUrl ?? existingUser.avatarUrl,
        updatedAt: Date.now(),
      })
      return existingUser._id
    }

    // UtwĂłrz nowego uĹĽytkownika
    return await ctx.db.insert('users', {
      clerkId: identity.subject,
      name: identity.name ?? 'Anonymous',
      email: identity.email ?? '',
      avatarUrl: identity.pictureUrl,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    })
  },
})

// Pobierz aktualnego uĹĽytkownika
export const currentUser = query({
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) return null

    return await ctx.db
      .query('users')
      .withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
      .unique()
  },
})

React Provider z Clerk

TSapp/providers.tsx
TypeScript
// app/providers.tsx
'use client'

import { ClerkProvider, useAuth } from '@clerk/nextjs'
import { ConvexProviderWithClerk } from 'convex/react-clerk'
import { ConvexReactClient } from 'convex/react'

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!)

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <ConvexProviderWithClerk client={convex} useAuth={useAuth}>
        {children}
      </ConvexProviderWithClerk>
    </ClerkProvider>
  )
}

// Hook do synchronizacji uĹĽytkownika
// components/UserSync.tsx
import { useMutation } from 'convex/react'
import { useUser } from '@clerk/nextjs'
import { useEffect } from 'react'
import { api } from '../convex/_generated/api'

export function UserSync() {
  const { isSignedIn } = useUser()
  const upsertUser = useMutation(api.users.upsertUser)

  useEffect(() => {
    if (isSignedIn) {
      upsertUser()
    }
  }, [isSignedIn, upsertUser])

  return null
}

Scheduled Functions (Cron Jobs)

TSconvex/crons.ts
TypeScript
// convex/crons.ts
import { cronJobs } from 'convex/server'
import { internal } from './_generated/api'

const crons = cronJobs()

// Codziennie o 9:00 UTC
crons.daily(
  'daily-report',
  { hourUTC: 9, minuteUTC: 0 },
  internal.reports.generateDaily
)

// Co godzinÄ™
crons.hourly(
  'cleanup-expired',
  { minuteUTC: 0 },
  internal.cleanup.expiredSessions
)

// Co minutÄ™
crons.interval(
  'health-check',
  { minutes: 1 },
  internal.monitoring.healthCheck
)

// Cron expression
crons.cron(
  'weekly-digest',
  '0 10 * * 1', // Poniedziałki o 10:00
  internal.emails.sendWeeklyDigest
)

export default crons

// convex/cleanup.ts
import { internalMutation } from './_generated/server'

export const expiredSessions = internalMutation({
  handler: async (ctx) => {
    const expiredTime = Date.now() - 24 * 60 * 60 * 1000 // 24h ago

    const expiredSessions = await ctx.db
      .query('sessions')
      .filter((q) => q.lt(q.field('lastActiveAt'), expiredTime))
      .collect()

    for (const session of expiredSessions) {
      await ctx.db.delete(session._id)
    }

    console.log(`Cleaned up ${expiredSessions.length} expired sessions`)
  },
})

HTTP Endpoints

TSconvex/http.ts
TypeScript
// convex/http.ts
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
import { api } from './_generated/api'

const http = httpRouter()

// Webhook endpoint
http.route({
  path: '/webhooks/stripe',
  method: 'POST',
  handler: httpAction(async (ctx, request) => {
    const body = await request.text()
    const signature = request.headers.get('stripe-signature')

    if (!signature) {
      return new Response('Missing signature', { status: 400 })
    }

    // Verify webhook (simplified)
    // In production, use stripe.webhooks.constructEvent()

    const event = JSON.parse(body)

    switch (event.type) {
      case 'checkout.session.completed':
        await ctx.runMutation(api.payments.handleCheckoutComplete, {
          sessionId: event.data.object.id,
        })
        break
      case 'customer.subscription.updated':
        await ctx.runMutation(api.subscriptions.handleUpdate, {
          subscriptionId: event.data.object.id,
        })
        break
    }

    return new Response('OK', { status: 200 })
  }),
})

// Public API endpoint
http.route({
  path: '/api/posts',
  method: 'GET',
  handler: httpAction(async (ctx, request) => {
    const posts = await ctx.runQuery(api.posts.listPublic)

    return new Response(JSON.stringify(posts), {
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
      },
    })
  }),
})

// RSS Feed
http.route({
  path: '/rss.xml',
  method: 'GET',
  handler: httpAction(async (ctx) => {
    const posts = await ctx.runQuery(api.posts.listPublic)

    const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>My Blog</title>
    <link>https://myblog.com</link>
    ${posts.map(post => `
    <item>
      <title>${post.title}</title>
      <link>https://myblog.com/posts/${post.slug}</link>
      <pubDate>${new Date(post.publishedAt!).toUTCString()}</pubDate>
    </item>`).join('')}
  </channel>
</rss>`

    return new Response(rss, {
      headers: { 'Content-Type': 'application/xml' },
    })
  }),
})

export default http

Best Practices

Organizacja kodu

Code
TEXT
convex/
├── _generated/        # Auto-generated (don't edit)
├── schema.ts          # Database schema
├── auth.config.ts     # Auth configuration
├── http.ts            # HTTP endpoints
├── crons.ts           # Scheduled jobs
│
├── posts/             # Feature: Posts
│   ├── queries.ts
│   ├── mutations.ts
│   └── actions.ts
│
├── users/             # Feature: Users
│   ├── queries.ts
│   └── mutations.ts
│
├── files/             # Feature: File storage
│   ├── queries.ts
│   └── mutations.ts
│
└── lib/               # Shared utilities
    ├── validators.ts
    └── helpers.ts

Helpers i reusable validators

TSconvex/lib/validators.ts
TypeScript
// convex/lib/validators.ts
import { v } from 'convex/values'

export const paginationValidator = v.object({
  numItems: v.optional(v.number()),
  cursor: v.optional(v.string()),
})

export const timestampsValidator = {
  createdAt: v.number(),
  updatedAt: v.number(),
}

// convex/lib/helpers.ts
import { QueryCtx, MutationCtx } from './_generated/server'

export async function requireAuth(ctx: QueryCtx | MutationCtx) {
  const identity = await ctx.auth.getUserIdentity()
  if (!identity) {
    throw new Error('Not authenticated')
  }
  return identity
}

export async function getCurrentUser(ctx: QueryCtx | MutationCtx) {
  const identity = await ctx.auth.getUserIdentity()
  if (!identity) return null

  return await ctx.db
    .query('users')
    .withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
    .unique()
}

export async function requireCurrentUser(ctx: QueryCtx | MutationCtx) {
  const user = await getCurrentUser(ctx)
  if (!user) {
    throw new Error('User not found')
  }
  return user
}

Error handling

TSconvex/lib/errors.ts
TypeScript
// convex/lib/errors.ts
export class ConvexError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number = 400
  ) {
    super(message)
    this.name = 'ConvexError'
  }
}

export const Errors = {
  NOT_AUTHENTICATED: new ConvexError('Not authenticated', 'NOT_AUTHENTICATED', 401),
  NOT_AUTHORIZED: new ConvexError('Not authorized', 'NOT_AUTHORIZED', 403),
  NOT_FOUND: (resource: string) =>
    new ConvexError(`${resource} not found`, 'NOT_FOUND', 404),
  VALIDATION_ERROR: (message: string) =>
    new ConvexError(message, 'VALIDATION_ERROR', 400),
}

// Usage in mutations
export const deletePost = mutation({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    const user = await requireCurrentUser(ctx)
    const post = await ctx.db.get(args.id)

    if (!post) {
      throw Errors.NOT_FOUND('Post')
    }

    if (post.authorId !== user._id) {
      throw Errors.NOT_AUTHORIZED
    }

    await ctx.db.delete(args.id)
  },
})

Cennik

PlanStorageFunction CallsBandwidthCena
Free1GB1M/miesiÄ…c1GB$0
Pro10GB25M/miesiÄ…c25GB$25/miesiÄ…c
Team50GB100M/miesiÄ…c100GB$100/miesiÄ…c
EnterpriseCustomCustomCustomKontakt

FAQ - Najczęściej zadawane pytania

Czym Convex różni się od Firebase?

Convex oferuje pełną type safety z TypeScript, transakcje ACID i bardziej intuicyjne API. Firebase używa NoSQL z ograniczonymi query, Convex ma elastyczne filtrowanie i indeksy.

Czy mogę self-hostować Convex?

Obecnie nie. Convex jest tylko cloud-hosted. Jeśli potrzebujesz self-hosting, rozważ Supabase lub PocketBase.

Jak skaluje siÄ™ Convex?

Convex automatycznie skaluje się w zależności od obciążenia. Nie musisz zarządzać instancjami czy replikami. Funkcje wykonują się w izolowanych środowiskach.

Czy Convex wspiera relacje między tabelami?

Tak, ale nie ma wbudowanych JOINów jak w SQL. Używasz ctx.db.get() do pobierania powiązanych dokumentów. W praktyce jest to bardzo wydajne dzięki cachingowi.

Jak migrować schemat?

Convex wspiera migracje przez convex dev --once. Możesz też napisać mutation do migracji danych. Convex automatycznie waliduje schemat podczas deployu.

Podsumowanie

Convex to potężna platforma backend dla deweloperów, którzy chcą budować aplikacje real-time bez boilerplate. Kluczowe cechy:

  • Reaktywność z automatu - Zmiany propagujÄ… do UI bez dodatkowego kodu
  • TypeScript End-to-End - PeĹ‚na type safety od bazy po frontend
  • Transakcje ACID - Gwarancja spĂłjnoĹ›ci danych
  • Serverless Functions - Queries, Mutations, Actions bez infrastruktury
  • Integracje - Clerk, Auth0, Stripe i wiele innych

Convex jest idealny do budowania chatów, dashboardów, aplikacji kolaboracyjnych i wszędzie tam, gdzie real-time synchronizacja jest kluczowa.


Convex - Reactive Backend for Modern Applications

What is Convex?

Convex is a reactive backend-as-a-service that automatically synchronizes data between server and client in real-time. Unlike traditional databases where you have to manually implement WebSockets, polling, or Server-Sent Events, Convex does this automatically - every change in the database instantly propagates to all connected clients.

Convex was created by a team of engineers from Dropbox and Google who understood the frustration of building real-time applications. Instead of piecing together multiple services (database, serverless functions, file storage, caching), Convex offers an integrated solution with a TypeScript-first approach.

The core philosophy of Convex is "reactive by default" - you don't have to think about data synchronization, caching, or invalidation. You define functions (queries, mutations, actions), and Convex automatically manages state and real-time updates.

Why Convex?

Key platform advantages

  1. Reactivity out of the box - Changes propagate to the UI without any extra code
  2. TypeScript End-to-End - Full type safety from database to frontend
  3. Zero WebSocket configuration - Real-time without boilerplate
  4. ACID Transactions - Data consistency guarantee
  5. Serverless Functions - Queries, Mutations, Actions
  6. File Storage - Built-in file storage
  7. Scheduled Functions - Cron jobs and delayed execution
  8. Built-in Auth - Integration with Clerk, Auth0, custom auth

Convex vs Firebase vs Supabase vs PocketBase

FeatureConvexFirebaseSupabasePocketBase
Real-timeNativeFirestorePostgresSSE
Type SafetyFull TypeScriptRuntime onlyPartialNone
TransactionsACIDLimitedYesYes
SQLCustom queriesNoYesSQLite
Self-hostingNoNoYesYes
Free tier1GB, 1M callsGenerous500MBUnlimited
Serverless FunctionsBuilt-inCloud FunctionsEdge FunctionsNo
File StorageYesYesYesYes
PricingPay-as-you-goPay-as-you-goFixed tiersFree

When to choose Convex?

Convex is ideal when:

  • You are building real-time applications (chats, collaboration, dashboards)
  • You care about end-to-end type safety
  • You want to avoid WebSocket/polling boilerplate
  • You need ACID transactions
  • Your team prefers TypeScript
  • You are quickly prototyping an MVP

Consider alternatives when:

  • You need SQL and complex queries (Supabase)
  • Self-hosting is required (PocketBase, Supabase)
  • You have an existing PostgreSQL/MySQL database
  • You need full control over infrastructure

Installation and configuration

New project

Code
Bash
# Create a new Convex project
npm create convex@latest

# Or add to an existing project
npm install convex

# Initialization
npx convex dev

Integration with Next.js

Code
Bash
# Installation
npm install convex

# Initialization (creates convex/ folder)
npx convex dev
TSconvex/_generated/api.d.ts
TypeScript
// convex/_generated/api.d.ts - automatically generated types

// app/ConvexClientProvider.tsx
'use client'

import { ConvexProvider, ConvexReactClient } from 'convex/react'
import { ReactNode } from 'react'

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!)

export function ConvexClientProvider({ children }: { children: ReactNode }) {
  return (
    <ConvexProvider client={convex}>
      {children}
    </ConvexProvider>
  )
}

// app/layout.tsx
import { ConvexClientProvider } from './ConvexClientProvider'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ConvexClientProvider>
          {children}
        </ConvexClientProvider>
      </body>
    </html>
  )
}

Integration with React (Vite)

TSsrc/main.tsx
TypeScript
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ConvexProvider, ConvexReactClient } from 'convex/react'
import App from './App'

const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL)

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <ConvexProvider client={convex}>
      <App />
    </ConvexProvider>
  </React.StrictMode>
)

Project structure

Code
TEXT
my-app/
├── convex/
│   ├── _generated/          # Auto-generated (API types, server)
│   │   ├── api.d.ts
│   │   ├── api.js
│   │   ├── dataModel.d.ts
│   │   └── server.d.ts
│   ├── schema.ts            # Database schema
│   ├── posts.ts             # Posts functions
│   ├── users.ts             # Users functions
│   ├── files.ts             # File handling
│   └── http.ts              # HTTP endpoints
├── src/
│   └── ...
├── convex.json              # Convex configuration
└── .env.local               # CONVEX_DEPLOYMENT, NEXT_PUBLIC_CONVEX_URL

Schema - Defining data structure

Basic schema

TSconvex/schema.ts
TypeScript
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'

export default defineSchema({
  // Users table
  users: defineTable({
    name: v.string(),
    email: v.string(),
    avatarUrl: v.optional(v.string()),
    role: v.union(v.literal('admin'), v.literal('user'), v.literal('guest')),
    createdAt: v.number(),
  })
    .index('by_email', ['email'])
    .index('by_role', ['role']),

  // Posts table
  posts: defineTable({
    title: v.string(),
    content: v.string(),
    slug: v.string(),
    authorId: v.id('users'),
    published: v.boolean(),
    publishedAt: v.optional(v.number()),
    tags: v.array(v.string()),
    viewCount: v.number(),
  })
    .index('by_author', ['authorId'])
    .index('by_slug', ['slug'])
    .index('by_published', ['published', 'publishedAt'])
    .searchIndex('search_posts', {
      searchField: 'title',
      filterFields: ['published', 'authorId'],
    }),

  // Comments table
  comments: defineTable({
    postId: v.id('posts'),
    authorId: v.id('users'),
    content: v.string(),
    createdAt: v.number(),
    parentId: v.optional(v.id('comments')), // Nested comments
  })
    .index('by_post', ['postId'])
    .index('by_author', ['authorId']),

  // Files table
  files: defineTable({
    storageId: v.id('_storage'),
    name: v.string(),
    type: v.string(),
    size: v.number(),
    uploadedBy: v.id('users'),
    postId: v.optional(v.id('posts')),
  })
    .index('by_user', ['uploadedBy'])
    .index('by_post', ['postId']),
})

Value types (validators)

Code
TypeScript
import { v } from 'convex/values'

// Primitive types
v.string()              // string
v.number()              // number
v.boolean()             // boolean
v.null()                // null
v.int64()               // bigint
v.bytes()               // ArrayBuffer

// Complex types
v.array(v.string())     // string[]
v.object({ name: v.string() })  // { name: string }
v.id('users')           // Id<"users">

// Optional
v.optional(v.string())  // string | undefined

// Union types
v.union(
  v.literal('draft'),
  v.literal('published'),
  v.literal('archived')
)  // 'draft' | 'published' | 'archived'

// Any type (use sparingly)
v.any()

Advanced schema with relations

TSconvex/schema.ts
TypeScript
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'

// Reusable validators
const timestamps = {
  createdAt: v.number(),
  updatedAt: v.number(),
}

const addressValidator = v.object({
  street: v.string(),
  city: v.string(),
  country: v.string(),
  zipCode: v.string(),
})

export default defineSchema({
  organizations: defineTable({
    name: v.string(),
    slug: v.string(),
    plan: v.union(
      v.literal('free'),
      v.literal('pro'),
      v.literal('enterprise')
    ),
    settings: v.object({
      allowPublicProjects: v.boolean(),
      maxMembers: v.number(),
    }),
    ...timestamps,
  }).index('by_slug', ['slug']),

  members: defineTable({
    organizationId: v.id('organizations'),
    userId: v.id('users'),
    role: v.union(
      v.literal('owner'),
      v.literal('admin'),
      v.literal('member')
    ),
    ...timestamps,
  })
    .index('by_org', ['organizationId'])
    .index('by_user', ['userId'])
    .index('by_org_user', ['organizationId', 'userId']),

  projects: defineTable({
    organizationId: v.id('organizations'),
    name: v.string(),
    description: v.optional(v.string()),
    visibility: v.union(v.literal('public'), v.literal('private')),
    settings: v.object({
      enableComments: v.boolean(),
      enableNotifications: v.boolean(),
    }),
    ...timestamps,
  })
    .index('by_org', ['organizationId'])
    .index('by_org_visibility', ['organizationId', 'visibility']),

  users: defineTable({
    clerkId: v.string(), // External auth ID
    email: v.string(),
    name: v.string(),
    avatarUrl: v.optional(v.string()),
    address: v.optional(addressValidator),
    ...timestamps,
  })
    .index('by_clerk_id', ['clerkId'])
    .index('by_email', ['email']),
})

Queries - Fetching data

Basic query

TSconvex/posts.ts
TypeScript
// convex/posts.ts
import { query } from './_generated/server'
import { v } from 'convex/values'

// Simple query without arguments
export const list = query({
  handler: async (ctx) => {
    // ctx.db is the database interface
    return await ctx.db
      .query('posts')
      .filter((q) => q.eq(q.field('published'), true))
      .order('desc')
      .collect()
  },
})

// Query with arguments
export const getById = query({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    return await ctx.db.get(args.id)
  },
})

// Query with pagination
export const listPaginated = query({
  args: {
    paginationOpts: v.object({
      numItems: v.number(),
      cursor: v.optional(v.string()),
    }),
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('posts')
      .filter((q) => q.eq(q.field('published'), true))
      .order('desc')
      .paginate(args.paginationOpts)
  },
})

Using indexes

TSconvex/posts.ts
TypeScript
// convex/posts.ts
import { query } from './_generated/server'
import { v } from 'convex/values'

// Query using an index
export const getByAuthor = query({
  args: { authorId: v.id('users') },
  handler: async (ctx, args) => {
    // withIndex is MUCH faster than filter
    return await ctx.db
      .query('posts')
      .withIndex('by_author', (q) => q.eq('authorId', args.authorId))
      .order('desc')
      .collect()
  },
})

// Compound index query
export const getPublishedByAuthor = query({
  args: { authorId: v.id('users') },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('posts')
      .withIndex('by_author', (q) => q.eq('authorId', args.authorId))
      .filter((q) => q.eq(q.field('published'), true))
      .collect()
  },
})

// Range query with index
export const getRecentPublished = query({
  args: { since: v.number() },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('posts')
      .withIndex('by_published', (q) =>
        q.eq('published', true).gte('publishedAt', args.since)
      )
      .order('desc')
      .take(20)
  },
})

Full-text search

TSconvex/posts.ts
TypeScript
// convex/posts.ts
export const search = query({
  args: {
    searchTerm: v.string(),
    published: v.optional(v.boolean()),
  },
  handler: async (ctx, args) => {
    let searchQuery = ctx.db
      .query('posts')
      .withSearchIndex('search_posts', (q) => {
        let search = q.search('title', args.searchTerm)

        if (args.published !== undefined) {
          search = search.eq('published', args.published)
        }

        return search
      })

    return await searchQuery.take(20)
  },
})

Queries with relations (joins)

TSconvex/posts.ts
TypeScript
// convex/posts.ts
export const listWithAuthors = query({
  handler: async (ctx) => {
    const posts = await ctx.db
      .query('posts')
      .filter((q) => q.eq(q.field('published'), true))
      .order('desc')
      .take(20)

    // "Join" - fetch authors for each post
    return await Promise.all(
      posts.map(async (post) => {
        const author = await ctx.db.get(post.authorId)
        return {
          ...post,
          author: author ? { name: author.name, avatarUrl: author.avatarUrl } : null,
        }
      })
    )
  },
})

// More complex join with comments
export const getPostWithComments = query({
  args: { postId: v.id('posts') },
  handler: async (ctx, args) => {
    const post = await ctx.db.get(args.postId)
    if (!post) return null

    const [author, comments] = await Promise.all([
      ctx.db.get(post.authorId),
      ctx.db
        .query('comments')
        .withIndex('by_post', (q) => q.eq('postId', args.postId))
        .order('desc')
        .collect(),
    ])

    // Fetch comment authors
    const commentsWithAuthors = await Promise.all(
      comments.map(async (comment) => {
        const commentAuthor = await ctx.db.get(comment.authorId)
        return {
          ...comment,
          author: commentAuthor,
        }
      })
    )

    return {
      ...post,
      author,
      comments: commentsWithAuthors,
    }
  },
})

Mutations - Modifying data

Basic mutations

TSconvex/posts.ts
TypeScript
// convex/posts.ts
import { mutation } from './_generated/server'
import { v } from 'convex/values'

// Creating
export const create = mutation({
  args: {
    title: v.string(),
    content: v.string(),
    authorId: v.id('users'),
    tags: v.optional(v.array(v.string())),
  },
  handler: async (ctx, args) => {
    const slug = args.title.toLowerCase().replace(/\s+/g, '-')

    const postId = await ctx.db.insert('posts', {
      title: args.title,
      content: args.content,
      slug,
      authorId: args.authorId,
      published: false,
      tags: args.tags || [],
      viewCount: 0,
    })

    return postId
  },
})

// Updating
export const update = mutation({
  args: {
    id: v.id('posts'),
    title: v.optional(v.string()),
    content: v.optional(v.string()),
    tags: v.optional(v.array(v.string())),
  },
  handler: async (ctx, args) => {
    const { id, ...updates } = args

    // Fetch the existing post
    const existing = await ctx.db.get(id)
    if (!existing) {
      throw new Error('Post not found')
    }

    // Update only the provided fields
    const updateData: Partial<typeof existing> = {}
    if (updates.title !== undefined) {
      updateData.title = updates.title
      updateData.slug = updates.title.toLowerCase().replace(/\s+/g, '-')
    }
    if (updates.content !== undefined) updateData.content = updates.content
    if (updates.tags !== undefined) updateData.tags = updates.tags

    await ctx.db.patch(id, updateData)
  },
})

// Deleting
export const remove = mutation({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    // First delete related comments
    const comments = await ctx.db
      .query('comments')
      .withIndex('by_post', (q) => q.eq('postId', args.id))
      .collect()

    for (const comment of comments) {
      await ctx.db.delete(comment._id)
    }

    // Delete the post
    await ctx.db.delete(args.id)
  },
})

Mutations with validation and authorization

TSconvex/posts.ts
TypeScript
// convex/posts.ts
import { mutation } from './_generated/server'
import { v } from 'convex/values'

export const publish = mutation({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    // Get the user's identity
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) {
      throw new Error('Not authenticated')
    }

    // Find the user in the database
    const user = await ctx.db
      .query('users')
      .withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
      .unique()

    if (!user) {
      throw new Error('User not found')
    }

    // Fetch the post
    const post = await ctx.db.get(args.id)
    if (!post) {
      throw new Error('Post not found')
    }

    // Check if the user is the author
    if (post.authorId !== user._id) {
      throw new Error('Not authorized')
    }

    // Check if the post is already published
    if (post.published) {
      throw new Error('Post is already published')
    }

    // Content validation
    if (post.content.length < 100) {
      throw new Error('Post content must be at least 100 characters')
    }

    // Publish
    await ctx.db.patch(args.id, {
      published: true,
      publishedAt: Date.now(),
    })

    return { success: true }
  },
})

Transactions (automatic in Convex)

Code
TypeScript
// All mutations in Convex are ACID transactions
export const transferCredits = mutation({
  args: {
    fromUserId: v.id('users'),
    toUserId: v.id('users'),
    amount: v.number(),
  },
  handler: async (ctx, args) => {
    const fromUser = await ctx.db.get(args.fromUserId)
    const toUser = await ctx.db.get(args.toUserId)

    if (!fromUser || !toUser) {
      throw new Error('User not found')
    }

    if (fromUser.credits < args.amount) {
      throw new Error('Insufficient credits')
    }

    // These operations are atomic - either both execute, or neither does
    await ctx.db.patch(args.fromUserId, {
      credits: fromUser.credits - args.amount,
    })

    await ctx.db.patch(args.toUserId, {
      credits: toUser.credits + args.amount,
    })

    // Save transaction history
    await ctx.db.insert('transactions', {
      fromUserId: args.fromUserId,
      toUserId: args.toUserId,
      amount: args.amount,
      createdAt: Date.now(),
    })

    return { success: true }
  },
})

Actions - External APIs and side effects

Actions are used for operations that should not be in a mutation (external APIs, non-deterministic operations).

TSconvex/actions.ts
TypeScript
// convex/actions.ts
import { action } from './_generated/server'
import { v } from 'convex/values'
import { api } from './_generated/api'

// Action calling an external API
export const sendEmail = action({
  args: {
    to: v.string(),
    subject: v.string(),
    body: v.string(),
  },
  handler: async (ctx, args) => {
    // External API - cannot be in a mutation
    const response = await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        from: 'noreply@myapp.com',
        to: args.to,
        subject: args.subject,
        html: args.body,
      }),
    })

    if (!response.ok) {
      throw new Error('Failed to send email')
    }

    return { success: true }
  },
})

// Action with a mutation call
export const createPostWithAIContent = action({
  args: {
    title: v.string(),
    topic: v.string(),
    authorId: v.id('users'),
  },
  handler: async (ctx, args) => {
    // Generate content using AI
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'gpt-4',
        messages: [
          {
            role: 'user',
            content: `Write a blog post about: ${args.topic}`,
          },
        ],
      }),
    })

    const data = await response.json()
    const content = data.choices[0].message.content

    // Call mutation to save to the database
    const postId = await ctx.runMutation(api.posts.create, {
      title: args.title,
      content,
      authorId: args.authorId,
    })

    return { postId, content }
  },
})

// Action with a query call
export const generateReport = action({
  args: { userId: v.id('users') },
  handler: async (ctx, args) => {
    // Fetch data from a query
    const posts = await ctx.runQuery(api.posts.getByAuthor, {
      authorId: args.userId,
    })

    // Generate the report
    const report = {
      totalPosts: posts.length,
      publishedPosts: posts.filter((p) => p.published).length,
      totalViews: posts.reduce((sum, p) => sum + p.viewCount, 0),
      generatedAt: new Date().toISOString(),
    }

    // Send report via email
    await ctx.runAction(api.actions.sendEmail, {
      to: 'user@example.com',
      subject: 'Your Monthly Report',
      body: `<h1>Report</h1><pre>${JSON.stringify(report, null, 2)}</pre>`,
    })

    return report
  },
})

Using Convex in React

useQuery - reactive data fetching

TScomponents/PostList.tsx
TypeScript
// components/PostList.tsx
import { useQuery } from 'convex/react'
import { api } from '../convex/_generated/api'

export function PostList() {
  // Automatically reactive - updates when data changes
  const posts = useQuery(api.posts.list)

  // Loading state
  if (posts === undefined) {
    return <div>Loading...</div>
  }

  // Empty state
  if (posts.length === 0) {
    return <div>No posts yet</div>
  }

  return (
    <ul>
      {posts.map((post) => (
        <li key={post._id}>
          <h2>{post.title}</h2>
          <p>{post.content.substring(0, 100)}...</p>
        </li>
      ))}
    </ul>
  )
}

// With arguments
export function UserPosts({ userId }: { userId: Id<'users'> }) {
  const posts = useQuery(api.posts.getByAuthor, { authorId: userId })

  // Conditional query - skip when userId is undefined
  // const posts = useQuery(
  //   userId ? api.posts.getByAuthor : 'skip',
  //   userId ? { authorId: userId } : undefined
  // )

  if (posts === undefined) return <div>Loading...</div>

  return (
    <div>
      <h2>Posts by user</h2>
      {posts.map((post) => (
        <PostCard key={post._id} post={post} />
      ))}
    </div>
  )
}

useMutation - modifying data

TScomponents/CreatePostForm.tsx
TypeScript
// components/CreatePostForm.tsx
import { useMutation } from 'convex/react'
import { api } from '../convex/_generated/api'
import { useState } from 'react'

export function CreatePostForm({ authorId }: { authorId: Id<'users'> }) {
  const [title, setTitle] = useState('')
  const [content, setContent] = useState('')
  const [isSubmitting, setIsSubmitting] = useState(false)

  const createPost = useMutation(api.posts.create)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setIsSubmitting(true)

    try {
      await createPost({
        title,
        content,
        authorId,
      })

      // Reset form
      setTitle('')
      setContent('')
    } catch (error) {
      console.error('Failed to create post:', error)
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Title"
        required
      />
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Content"
        required
      />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

useAction - calling actions

TScomponents/GenerateWithAI.tsx
TypeScript
// components/GenerateWithAI.tsx
import { useAction } from 'convex/react'
import { api } from '../convex/_generated/api'
import { useState } from 'react'

export function GenerateWithAI({ authorId }: { authorId: Id<'users'> }) {
  const [title, setTitle] = useState('')
  const [topic, setTopic] = useState('')
  const [isGenerating, setIsGenerating] = useState(false)
  const [result, setResult] = useState<string | null>(null)

  const generatePost = useAction(api.actions.createPostWithAIContent)

  const handleGenerate = async () => {
    setIsGenerating(true)
    try {
      const result = await generatePost({
        title,
        topic,
        authorId,
      })
      setResult(result.content)
    } catch (error) {
      console.error('Failed to generate:', error)
    } finally {
      setIsGenerating(false)
    }
  }

  return (
    <div>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Post title"
      />
      <input
        value={topic}
        onChange={(e) => setTopic(e.target.value)}
        placeholder="Topic for AI"
      />
      <button onClick={handleGenerate} disabled={isGenerating}>
        {isGenerating ? 'Generating...' : 'Generate with AI'}
      </button>
      {result && (
        <div>
          <h3>Generated content:</h3>
          <p>{result}</p>
        </div>
      )}
    </div>
  )
}

Pagination with usePaginatedQuery

TScomponents/InfinitePostList.tsx
TypeScript
// components/InfinitePostList.tsx
import { usePaginatedQuery } from 'convex/react'
import { api } from '../convex/_generated/api'

export function InfinitePostList() {
  const { results, status, loadMore } = usePaginatedQuery(
    api.posts.listPaginated,
    {},
    { initialNumItems: 10 }
  )

  return (
    <div>
      {results.map((post) => (
        <PostCard key={post._id} post={post} />
      ))}

      {status === 'CanLoadMore' && (
        <button onClick={() => loadMore(10)}>Load More</button>
      )}

      {status === 'LoadingMore' && <div>Loading more...</div>}

      {status === 'Exhausted' && <div>No more posts</div>}
    </div>
  )
}

Optimistic updates

TScomponents/LikeButton.tsx
TypeScript
// components/LikeButton.tsx
import { useMutation, useQuery } from 'convex/react'
import { api } from '../convex/_generated/api'
import { useState } from 'react'

export function LikeButton({ postId }: { postId: Id<'posts'> }) {
  const post = useQuery(api.posts.getById, { id: postId })
  const toggleLike = useMutation(api.posts.toggleLike)
  const [optimisticLiked, setOptimisticLiked] = useState<boolean | null>(null)

  if (!post) return null

  const isLiked = optimisticLiked ?? post.isLikedByCurrentUser

  const handleClick = async () => {
    // Optimistic update
    setOptimisticLiked(!isLiked)

    try {
      await toggleLike({ postId })
    } catch (error) {
      // Rollback on error
      setOptimisticLiked(null)
    } finally {
      // Let real data take over
      setOptimisticLiked(null)
    }
  }

  return (
    <button
      onClick={handleClick}
      className={isLiked ? 'liked' : ''}
    >
      {isLiked ? '❤️' : '🤍'} {post.likesCount}
    </button>
  )
}

File storage

Uploading files

TSconvex/files.ts
TypeScript
// convex/files.ts
import { mutation, query } from './_generated/server'
import { v } from 'convex/values'

// Generate an upload URL
export const generateUploadUrl = mutation({
  handler: async (ctx) => {
    return await ctx.storage.generateUploadUrl()
  },
})

// Save file metadata after upload
export const saveFile = mutation({
  args: {
    storageId: v.id('_storage'),
    name: v.string(),
    type: v.string(),
    size: v.number(),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Not authenticated')

    const user = await ctx.db
      .query('users')
      .withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
      .unique()

    if (!user) throw new Error('User not found')

    return await ctx.db.insert('files', {
      storageId: args.storageId,
      name: args.name,
      type: args.type,
      size: args.size,
      uploadedBy: user._id,
    })
  },
})

// Get file URL
export const getFileUrl = query({
  args: { storageId: v.id('_storage') },
  handler: async (ctx, args) => {
    return await ctx.storage.getUrl(args.storageId)
  },
})

// Delete a file
export const deleteFile = mutation({
  args: { fileId: v.id('files') },
  handler: async (ctx, args) => {
    const file = await ctx.db.get(args.fileId)
    if (!file) throw new Error('File not found')

    // Delete from storage
    await ctx.storage.delete(file.storageId)

    // Delete metadata
    await ctx.db.delete(args.fileId)
  },
})

React upload component

TScomponents/FileUpload.tsx
TypeScript
// components/FileUpload.tsx
import { useMutation } from 'convex/react'
import { api } from '../convex/_generated/api'
import { useState, useRef } from 'react'

export function FileUpload() {
  const [isUploading, setIsUploading] = useState(false)
  const [progress, setProgress] = useState(0)
  const fileInputRef = useRef<HTMLInputElement>(null)

  const generateUploadUrl = useMutation(api.files.generateUploadUrl)
  const saveFile = useMutation(api.files.saveFile)

  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    setIsUploading(true)
    setProgress(0)

    try {
      // 1. Get the upload URL
      const uploadUrl = await generateUploadUrl()

      // 2. Upload the file
      const response = await fetch(uploadUrl, {
        method: 'POST',
        headers: { 'Content-Type': file.type },
        body: file,
      })

      if (!response.ok) throw new Error('Upload failed')

      const { storageId } = await response.json()

      // 3. Save metadata
      await saveFile({
        storageId,
        name: file.name,
        type: file.type,
        size: file.size,
      })

      setProgress(100)
    } catch (error) {
      console.error('Upload failed:', error)
    } finally {
      setIsUploading(false)
      if (fileInputRef.current) {
        fileInputRef.current.value = ''
      }
    }
  }

  return (
    <div>
      <input
        ref={fileInputRef}
        type="file"
        onChange={handleUpload}
        disabled={isUploading}
      />
      {isUploading && (
        <div>
          <progress value={progress} max="100" />
          <span>{progress}%</span>
        </div>
      )}
    </div>
  )
}

// Displaying an image
export function ImageDisplay({ storageId }: { storageId: Id<'_storage'> }) {
  const url = useQuery(api.files.getFileUrl, { storageId })

  if (!url) return <div>Loading...</div>

  return <img src={url} alt="" />
}

Authentication

Integration with Clerk

TSconvex/auth.config.ts
TypeScript
// convex/auth.config.ts
export default {
  providers: [
    {
      domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
      applicationID: 'convex',
    },
  ],
}

// convex/users.ts
import { mutation, query } from './_generated/server'
import { v } from 'convex/values'

// Create or update user after login
export const upsertUser = mutation({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Not authenticated')

    // Check if the user exists
    const existingUser = await ctx.db
      .query('users')
      .withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
      .unique()

    if (existingUser) {
      // Update data
      await ctx.db.patch(existingUser._id, {
        name: identity.name ?? existingUser.name,
        email: identity.email ?? existingUser.email,
        avatarUrl: identity.pictureUrl ?? existingUser.avatarUrl,
        updatedAt: Date.now(),
      })
      return existingUser._id
    }

    // Create a new user
    return await ctx.db.insert('users', {
      clerkId: identity.subject,
      name: identity.name ?? 'Anonymous',
      email: identity.email ?? '',
      avatarUrl: identity.pictureUrl,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    })
  },
})

// Get the current user
export const currentUser = query({
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) return null

    return await ctx.db
      .query('users')
      .withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
      .unique()
  },
})

React provider with Clerk

TSapp/providers.tsx
TypeScript
// app/providers.tsx
'use client'

import { ClerkProvider, useAuth } from '@clerk/nextjs'
import { ConvexProviderWithClerk } from 'convex/react-clerk'
import { ConvexReactClient } from 'convex/react'

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!)

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <ConvexProviderWithClerk client={convex} useAuth={useAuth}>
        {children}
      </ConvexProviderWithClerk>
    </ClerkProvider>
  )
}

// Hook for user synchronization
// components/UserSync.tsx
import { useMutation } from 'convex/react'
import { useUser } from '@clerk/nextjs'
import { useEffect } from 'react'
import { api } from '../convex/_generated/api'

export function UserSync() {
  const { isSignedIn } = useUser()
  const upsertUser = useMutation(api.users.upsertUser)

  useEffect(() => {
    if (isSignedIn) {
      upsertUser()
    }
  }, [isSignedIn, upsertUser])

  return null
}

Scheduled functions (cron jobs)

TSconvex/crons.ts
TypeScript
// convex/crons.ts
import { cronJobs } from 'convex/server'
import { internal } from './_generated/api'

const crons = cronJobs()

// Daily at 9:00 UTC
crons.daily(
  'daily-report',
  { hourUTC: 9, minuteUTC: 0 },
  internal.reports.generateDaily
)

// Every hour
crons.hourly(
  'cleanup-expired',
  { minuteUTC: 0 },
  internal.cleanup.expiredSessions
)

// Every minute
crons.interval(
  'health-check',
  { minutes: 1 },
  internal.monitoring.healthCheck
)

// Cron expression
crons.cron(
  'weekly-digest',
  '0 10 * * 1', // Mondays at 10:00
  internal.emails.sendWeeklyDigest
)

export default crons

// convex/cleanup.ts
import { internalMutation } from './_generated/server'

export const expiredSessions = internalMutation({
  handler: async (ctx) => {
    const expiredTime = Date.now() - 24 * 60 * 60 * 1000 // 24h ago

    const expiredSessions = await ctx.db
      .query('sessions')
      .filter((q) => q.lt(q.field('lastActiveAt'), expiredTime))
      .collect()

    for (const session of expiredSessions) {
      await ctx.db.delete(session._id)
    }

    console.log(`Cleaned up ${expiredSessions.length} expired sessions`)
  },
})

HTTP endpoints

TSconvex/http.ts
TypeScript
// convex/http.ts
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
import { api } from './_generated/api'

const http = httpRouter()

// Webhook endpoint
http.route({
  path: '/webhooks/stripe',
  method: 'POST',
  handler: httpAction(async (ctx, request) => {
    const body = await request.text()
    const signature = request.headers.get('stripe-signature')

    if (!signature) {
      return new Response('Missing signature', { status: 400 })
    }

    // Verify webhook (simplified)
    // In production, use stripe.webhooks.constructEvent()

    const event = JSON.parse(body)

    switch (event.type) {
      case 'checkout.session.completed':
        await ctx.runMutation(api.payments.handleCheckoutComplete, {
          sessionId: event.data.object.id,
        })
        break
      case 'customer.subscription.updated':
        await ctx.runMutation(api.subscriptions.handleUpdate, {
          subscriptionId: event.data.object.id,
        })
        break
    }

    return new Response('OK', { status: 200 })
  }),
})

// Public API endpoint
http.route({
  path: '/api/posts',
  method: 'GET',
  handler: httpAction(async (ctx, request) => {
    const posts = await ctx.runQuery(api.posts.listPublic)

    return new Response(JSON.stringify(posts), {
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
      },
    })
  }),
})

// RSS Feed
http.route({
  path: '/rss.xml',
  method: 'GET',
  handler: httpAction(async (ctx) => {
    const posts = await ctx.runQuery(api.posts.listPublic)

    const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>My Blog</title>
    <link>https://myblog.com</link>
    ${posts.map(post => `
    <item>
      <title>${post.title}</title>
      <link>https://myblog.com/posts/${post.slug}</link>
      <pubDate>${new Date(post.publishedAt!).toUTCString()}</pubDate>
    </item>`).join('')}
  </channel>
</rss>`

    return new Response(rss, {
      headers: { 'Content-Type': 'application/xml' },
    })
  }),
})

export default http

Best practices

Code organization

Code
TEXT
convex/
├── _generated/        # Auto-generated (don't edit)
├── schema.ts          # Database schema
├── auth.config.ts     # Auth configuration
├── http.ts            # HTTP endpoints
├── crons.ts           # Scheduled jobs
│
├── posts/             # Feature: Posts
│   ├── queries.ts
│   ├── mutations.ts
│   └── actions.ts
│
├── users/             # Feature: Users
│   ├── queries.ts
│   └── mutations.ts
│
├── files/             # Feature: File storage
│   ├── queries.ts
│   └── mutations.ts
│
└── lib/               # Shared utilities
    ├── validators.ts
    └── helpers.ts

Helpers and reusable validators

TSconvex/lib/validators.ts
TypeScript
// convex/lib/validators.ts
import { v } from 'convex/values'

export const paginationValidator = v.object({
  numItems: v.optional(v.number()),
  cursor: v.optional(v.string()),
})

export const timestampsValidator = {
  createdAt: v.number(),
  updatedAt: v.number(),
}

// convex/lib/helpers.ts
import { QueryCtx, MutationCtx } from './_generated/server'

export async function requireAuth(ctx: QueryCtx | MutationCtx) {
  const identity = await ctx.auth.getUserIdentity()
  if (!identity) {
    throw new Error('Not authenticated')
  }
  return identity
}

export async function getCurrentUser(ctx: QueryCtx | MutationCtx) {
  const identity = await ctx.auth.getUserIdentity()
  if (!identity) return null

  return await ctx.db
    .query('users')
    .withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
    .unique()
}

export async function requireCurrentUser(ctx: QueryCtx | MutationCtx) {
  const user = await getCurrentUser(ctx)
  if (!user) {
    throw new Error('User not found')
  }
  return user
}

Error handling

TSconvex/lib/errors.ts
TypeScript
// convex/lib/errors.ts
export class ConvexError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number = 400
  ) {
    super(message)
    this.name = 'ConvexError'
  }
}

export const Errors = {
  NOT_AUTHENTICATED: new ConvexError('Not authenticated', 'NOT_AUTHENTICATED', 401),
  NOT_AUTHORIZED: new ConvexError('Not authorized', 'NOT_AUTHORIZED', 403),
  NOT_FOUND: (resource: string) =>
    new ConvexError(`${resource} not found`, 'NOT_FOUND', 404),
  VALIDATION_ERROR: (message: string) =>
    new ConvexError(message, 'VALIDATION_ERROR', 400),
}

// Usage in mutations
export const deletePost = mutation({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    const user = await requireCurrentUser(ctx)
    const post = await ctx.db.get(args.id)

    if (!post) {
      throw Errors.NOT_FOUND('Post')
    }

    if (post.authorId !== user._id) {
      throw Errors.NOT_AUTHORIZED
    }

    await ctx.db.delete(args.id)
  },
})

Pricing

PlanStorageFunction CallsBandwidthPrice
Free1GB1M/month1GB$0
Pro10GB25M/month25GB$25/month
Team50GB100M/month100GB$100/month
EnterpriseCustomCustomCustomContact us

FAQ - Frequently asked questions

How does Convex differ from Firebase?

Convex offers full type safety with TypeScript, ACID transactions, and a more intuitive API. Firebase uses NoSQL with limited queries, while Convex has flexible filtering and indexes.

Can I self-host Convex?

Currently, no. Convex is cloud-hosted only. If you need self-hosting, consider Supabase or PocketBase.

How does Convex scale?

Convex automatically scales depending on load. You don't need to manage instances or replicas. Functions execute in isolated environments.

Does Convex support relations between tables?

Yes, but it does not have built-in JOINs like SQL. You use ctx.db.get() to fetch related documents. In practice, this is very efficient thanks to caching.

How do I migrate the schema?

Convex supports migrations through convex dev --once. You can also write a mutation to migrate data. Convex automatically validates the schema during deployment.

Summary

Convex is a powerful backend platform for developers who want to build real-time applications without boilerplate. Key features:

  • Reactivity out of the box - Changes propagate to the UI without any extra code
  • TypeScript End-to-End - Full type safety from database to frontend
  • ACID Transactions - Data consistency guarantee
  • Serverless Functions - Queries, Mutations, Actions without infrastructure
  • Integrations - Clerk, Auth0, Stripe, and many more

Convex is ideal for building chats, dashboards, collaborative applications, and anywhere real-time synchronization is crucial.