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
- Reaktywność z automatu - Zmiany propagują do UI bez dodatkowego kodu
- TypeScript End-to-End - Pełna type safety od bazy po frontend
- Zero konfiguracji WebSocket - Real-time bez boilerplate
- ACID Transactions - Gwarancja spójności danych
- Serverless Functions - Queries, Mutations, Actions
- File Storage - Wbudowane przechowywanie plików
- Scheduled Functions - Cron jobs i delayed execution
- Built-in Auth - Integracja z Clerk, Auth0, własna auth
Convex vs Firebase vs Supabase vs PocketBase
| Cecha | Convex | Firebase | Supabase | PocketBase |
|---|---|---|---|---|
| Real-time | ✅ Natywne | ✅ Firestore | ✅ Postgres | ✅ SSE |
| Type Safety | ✅ Full TypeScript | ❌ Runtime | Częściowe | ❌ |
| Transactions | ✅ ACID | ❌ Limited | ✅ | ✅ |
| SQL | ❌ Custom queries | ❌ | ✅ | ✅ SQLite |
| Self-hosting | ❌ | ❌ | ✅ | ✅ |
| Free tier | 1GB, 1M calls | Generous | 500MB | Unlimited |
| Serverless Functions | ✅ Wbudowane | Cloud Functions | Edge Functions | ❌ |
| File Storage | ✅ | ✅ | ✅ | ✅ |
| Pricing | Pay-as-you-go | Pay-as-you-go | Fixed tiers | Free |
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
# Stwórz nowy projekt Convex
npm create convex@latest
# Lub dodaj do istniejącego projektu
npm install convex
# Inicjalizacja
npx convex devIntegracja z Next.js
# Instalacja
npm install convex
# Inicjalizacja (tworzy folder convex/)
npx convex dev// 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)
// 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
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_URLSchema - Definiowanie Struktury Danych
Podstawowa schema
// 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)
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
// 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
// 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
// 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
// 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)
// 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
// 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ą
// 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)
// 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).
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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)
// 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
// 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 httpBest Practices
Organizacja kodu
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.tsHelpers i reusable validators
// 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
// 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
| Plan | Storage | Function Calls | Bandwidth | Cena |
|---|---|---|---|---|
| Free | 1GB | 1M/miesiąc | 1GB | $0 |
| Pro | 10GB | 25M/miesiąc | 25GB | $25/miesiąc |
| Team | 50GB | 100M/miesiąc | 100GB | $100/miesiąc |
| Enterprise | Custom | Custom | Custom | Kontakt |
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
- 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
- Zero WebSocket configuration - Real-time without boilerplate
- ACID Transactions - Data consistency guarantee
- Serverless Functions - Queries, Mutations, Actions
- File Storage - Built-in file storage
- Scheduled Functions - Cron jobs and delayed execution
- Built-in Auth - Integration with Clerk, Auth0, custom auth
Convex vs Firebase vs Supabase vs PocketBase
| Feature | Convex | Firebase | Supabase | PocketBase |
|---|---|---|---|---|
| Real-time | Native | Firestore | Postgres | SSE |
| Type Safety | Full TypeScript | Runtime only | Partial | None |
| Transactions | ACID | Limited | Yes | Yes |
| SQL | Custom queries | No | Yes | SQLite |
| Self-hosting | No | No | Yes | Yes |
| Free tier | 1GB, 1M calls | Generous | 500MB | Unlimited |
| Serverless Functions | Built-in | Cloud Functions | Edge Functions | No |
| File Storage | Yes | Yes | Yes | Yes |
| Pricing | Pay-as-you-go | Pay-as-you-go | Fixed tiers | Free |
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
# Create a new Convex project
npm create convex@latest
# Or add to an existing project
npm install convex
# Initialization
npx convex devIntegration with Next.js
# Installation
npm install convex
# Initialization (creates convex/ folder)
npx convex dev// 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)
// 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
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_URLSchema - Defining data structure
Basic schema
// 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)
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
// 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
// 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
// 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
// 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)
// 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
// 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
// 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)
// 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).
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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)
// 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
// 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 httpBest practices
Code organization
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.tsHelpers and reusable validators
// 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
// 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
| Plan | Storage | Function Calls | Bandwidth | Price |
|---|---|---|---|---|
| Free | 1GB | 1M/month | 1GB | $0 |
| Pro | 10GB | 25M/month | 25GB | $25/month |
| Team | 50GB | 100M/month | 100GB | $100/month |
| Enterprise | Custom | Custom | Custom | Contact 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.