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

Appwrite, an open source alternative to Firebase

Appwrite is an open source backend with auth, the TablesDB database, storage, and functions. Version 1.9, self hosting, Sites, pricing, and a Supabase comparison.

Appwrite, an open source alternative to Firebase

Appwrite is an open source set of backend services that removes the need to write your own authentication, data layer, file handling, and background jobs. You can run it on your own server through Docker or take the managed cloud version. The current release is 1.9.6 from 22 July 2026, and the project ships under a BSD licence.

What Appwrite actually replaces

The short answer: the entire backend of a typical application with no complicated domain logic. Signup and login, password reset, sign in with a Google account, storing records with permissions down to a single row, file uploads with thumbnail generation, realtime notifications, and functions triggered by events. All of it exists already and is configured from a dashboard.

The consequence is that a first version ships in days rather than weeks, and the price is deferred. You pay it only when you need something the platform did not anticipate: an unusual query joining three datasets, a transaction spanning several operations, or a migration that has to run without downtime.

It is worth understanding where the boundary sits. Appwrite is strong at what can be described as operations on individual records with permissions. It is weak wherever set based work begins: reporting, aggregation, complex joins. If your product is mostly reading and writing a user's own records, you have chosen well. If it is an analytics system, PostgreSQL with your own API layer will serve better.

A naming change you need to know about

This is the most important thing when reading older tutorials, including older parts of this text. In version 1.8, released in August 2025, the data layer gained a new interface called TablesDB and, with it, new vocabulary. Collections became tables, documents became rows, and attributes became columns. The change is more than cosmetic, since it also touched method names in the client libraries.

The old document based methods are marked as deprecated. They still work and still receive security patches and essential maintenance, but they will not gain new capabilities. Backward compatibility was preserved, so existing code will not stop working overnight.

The practical takeaway for a new project: write against TablesDB from the start, because that is the interface receiving new features. For an existing project there is no urgency, but the migration is worth planning before the deprecated layer starts blocking access to new capabilities. The code examples later in this article still use the older vocabulary, so treat them as a map of concepts and check method names against current documentation.

Appwrite Sites, hosting inside the same project

A separate development that shifted how this platform positions itself. Appwrite Sites appeared in May 2025 and became available to everyone that July. It is a hosting layer for static sites and server rendered applications, explicitly positioned as an open source alternative to Vercel.

It works as you would expect: connect a repository, every push to a branch triggers a deployment, every pull request gets its own preview address, and content is distributed through a content delivery network. Static applications are supported alongside server side rendering for the popular frameworks, including Next.js, Nuxt, SvelteKit, and Astro.

The real value is not the hosting itself, which you can get in plenty of places. It is that the application and its backend live in one project, with one environment variable configuration and one place to check logs. For a small team that saves more time than any single platform feature. A comparison against Vercel then lands differently than a parameter by parameter table would suggest.

Appwrite against Firebase and Supabase

FeatureAppwriteFirebaseSupabase
Licenceopen, BSDclosedopen, Apache 2.0
Self hostingfull, via Dockernonepossible, harder
Data modeltables and rows, TablesDBdocumentsPostgreSQL, relational
Complex querieslimitedlimitedfull SQL
Function runtimesmany languagesmainly JS and TSDeno plus the database
Application hostingyes, Sitesyespartial
Vendor lock inlowhighlow

Deciding between the three rarely comes down to a feature list, since the coverage is similar. It comes down to the data model and to whether you need an exit route.

Supabase wins wherever data is relational and where sooner or later you will write a query with three joins. You get ordinary PostgreSQL, so its whole ecosystem is available. Appwrite wins where simplicity and multi platform reach matter, particularly for mobile applications, because the Flutter and native libraries are treated as equals to the web one rather than as an afterthought. Firebase wins on integration with the rest of Google's services and on the maturity of its surrounding tooling, paying for it with the complete absence of a way out.

Installation and setup

Self-hosting with Docker

The simplest way to run Appwrite is to use the official installation script:

Code
Bash
docker run -it --rm \
  --volume /var/run/docker.sock:/var/run/docker.sock \
  --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
  --entrypoint="install" \
  appwrite/appwrite:1.5.6

# After installation open http://localhost:80

Docker Compose (recommended)

For greater control use docker-compose:

docker-compose.yml
YAML
# docker-compose.yml
version: '3'

services:
  appwrite:
    image: appwrite/appwrite:1.5.6
    container_name: appwrite
    restart: unless-stopped
    ports:
      - 80:80
      - 443:443
    volumes:
      - appwrite-uploads:/storage/uploads
      - appwrite-cache:/storage/cache
      - appwrite-config:/storage/config
      - appwrite-certificates:/storage/certificates
      - appwrite-functions:/storage/functions
    environment:
      - _APP_ENV=production
      - _APP_OPENSSL_KEY_V1=your-secret-key
      - _APP_DOMAIN=localhost
      - _APP_DOMAIN_TARGET=localhost
      - _APP_REDIS_HOST=redis
      - _APP_REDIS_PORT=6379
      - _APP_DB_HOST=mariadb
      - _APP_DB_PORT=3306
      - _APP_DB_USER=appwrite
      - _APP_DB_PASS=password
      - _APP_STORAGE_DEVICE=local
      - _APP_STORAGE_S3_ACCESS_KEY=
      - _APP_STORAGE_S3_SECRET=
      - _APP_STORAGE_S3_REGION=us-east-1
      - _APP_STORAGE_S3_BUCKET=
    depends_on:
      - mariadb
      - redis

  mariadb:
    image: mariadb:10.11
    container_name: appwrite-mariadb
    restart: unless-stopped
    volumes:
      - appwrite-mariadb:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=rootpassword
      - MYSQL_DATABASE=appwrite
      - MYSQL_USER=appwrite
      - MYSQL_PASSWORD=password

  redis:
    image: redis:7.2-alpine
    container_name: appwrite-redis
    restart: unless-stopped
    volumes:
      - appwrite-redis:/data

volumes:
  appwrite-uploads:
  appwrite-cache:
  appwrite-config:
  appwrite-certificates:
  appwrite-functions:
  appwrite-mariadb:
  appwrite-redis:
Code
Bash
docker-compose up -d

docker-compose ps

docker-compose logs -f appwrite

Appwrite Cloud

For a quick start without managing infrastructure:

Code
Bash
# 1. Sign up at cloud.appwrite.io
# 2. Create a new project
# 3. Copy the Project ID and Endpoint

SDK installation

Code
Bash
# JavaScript/TypeScript (Web)
npm install appwrite

# React Native
npm install react-native-appwrite

# Flutter
flutter pub add appwrite

# Node.js (Server)
npm install node-appwrite

# Python
pip install appwrite

# PHP
composer require appwrite/appwrite

Authentication - complete authorization system

Client configuration

TSlib/appwrite.ts
TypeScript
// lib/appwrite.ts
import { Client, Account, Databases, Storage, Functions } from 'appwrite'

const client = new Client()
  .setEndpoint('https://cloud.appwrite.io/v1')
  .setProject('your-project-id')

export const account = new Account(client)
export const databases = new Databases(client)
export const storage = new Storage(client)
export const functions = new Functions(client)
export { client }

Registration and login

Code
TypeScript
async function register(email: string, password: string, name: string) {
  try {
    const user = await account.create(
      'unique()',
      email,
      password,
      name
    )

    await account.createEmailPasswordSession(email, password)

    await account.createVerification('https://example.com/verify')

    return user
  } catch (error) {
    console.error('Registration error:', error)
    throw error
  }
}

async function login(email: string, password: string) {
  try {
    const session = await account.createEmailPasswordSession(email, password)
    return session
  } catch (error) {
    console.error('Login error:', error)
    throw error
  }
}

async function logout() {
  try {
    await account.deleteSession('current')
  } catch (error) {
    console.error('Logout error:', error)
    throw error
  }
}

async function getCurrentUser() {
  try {
    return await account.get()
  } catch (error) {
    return null
  }
}

OAuth - social login

Code
TypeScript
async function loginWithGoogle() {
  account.createOAuth2Session(
    'google',
    'https://example.com/success',
    'https://example.com/failure',
    ['email', 'profile']
  )
}

async function loginWithGitHub() {
  account.createOAuth2Session(
    'github',
    'https://example.com/success',
    'https://example.com/failure'
  )
}

async function loginWithApple() {
  account.createOAuth2Session(
    'apple',
    'https://example.com/success',
    'https://example.com/failure'
  )
}

async function loginWithDiscord() {
  account.createOAuth2Session(
    'discord',
    'https://example.com/success',
    'https://example.com/failure',
    ['identify', 'email']
  )
}

Magic Link (passwordless)

Code
TypeScript
async function sendMagicLink(email: string) {
  await account.createMagicURLToken(
    'unique()',
    email,
    'https://example.com/login?userId={userId}&secret={secret}'
  )
}

async function verifyMagicLink(userId: string, secret: string) {
  const session = await account.createSession(userId, secret)
  return session
}

Phone authentication

Code
TypeScript
async function sendPhoneCode(phone: string) {
  await account.createPhoneToken(
    'unique()',
    phone // Format: +48123456789
  )
}

async function verifyPhoneCode(userId: string, code: string) {
  const session = await account.createSession(userId, code)
  return session
}

Multi-Factor Authentication (MFA)

Code
TypeScript
async function enableMFA() {
  const totp = await account.createMfaAuthenticator('totp')

  console.log('Secret:', totp.secret)
  console.log('QR URI:', totp.uri)

  return totp
}

async function verifyMFA(code: string) {
  await account.updateMfaAuthenticator('totp', code)
}

async function loginWithMFA(email: string, password: string, mfaCode: string) {
  const session = await account.createEmailPasswordSession(email, password)

  if (session.mfaRequired) {
    await account.updateMfaChallenge(
      session.mfaChallengeId,
      mfaCode
    )
  }

  return session
}

React hook for auth

TShooks/useAuth.ts
TypeScript
// hooks/useAuth.ts
import { useState, useEffect, createContext, useContext } from 'react'
import { account } from '@/lib/appwrite'
import type { Models } from 'appwrite'

interface AuthContextType {
  user: Models.User<Models.Preferences> | null
  loading: boolean
  login: (email: string, password: string) => Promise<void>
  logout: () => Promise<void>
  register: (email: string, password: string, name: string) => Promise<void>
}

const AuthContext = createContext<AuthContextType | null>(null)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<Models.User<Models.Preferences> | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    checkUser()
  }, [])

  async function checkUser() {
    try {
      const currentUser = await account.get()
      setUser(currentUser)
    } catch {
      setUser(null)
    } finally {
      setLoading(false)
    }
  }

  async function login(email: string, password: string) {
    await account.createEmailPasswordSession(email, password)
    await checkUser()
  }

  async function logout() {
    await account.deleteSession('current')
    setUser(null)
  }

  async function register(email: string, password: string, name: string) {
    await account.create('unique()', email, password, name)
    await login(email, password)
  }

  return (
    <AuthContext.Provider value={{ user, loading, login, logout, register }}>
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider')
  }
  return context
}

Database - document database

Data structure

Appwrite uses a document model with the following hierarchy:

  • Database, a container for collections, now called a database in TablesDB
  • Collection, a document schema, now a table
  • Document, a single record, now a row
  • Attribute, a document field, now a column

Creating a schema

Code
TypeScript
import { Databases, Permission, Role } from 'node-appwrite'

const databases = new Databases(client)

const database = await databases.create(
  'main',
  'Main Database'
)

const collection = await databases.createCollection(
  'main',
  'posts',
  'Blog Posts',
  [
    Permission.read(Role.any()),
    Permission.create(Role.users()),
    Permission.update(Role.users()),
    Permission.delete(Role.users())
  ]
)

await databases.createStringAttribute('main', 'posts', 'title', 255, true)
await databases.createStringAttribute('main', 'posts', 'content', 65535, true)
await databases.createStringAttribute('main', 'posts', 'slug', 255, true)
await databases.createStringAttribute('main', 'posts', 'authorId', 36, true)
await databases.createBooleanAttribute('main', 'posts', 'published', true, false)
await databases.createDatetimeAttribute('main', 'posts', 'publishedAt', false)
await databases.createStringAttribute('main', 'posts', 'tags', 50, false, undefined, true)
await databases.createIntegerAttribute('main', 'posts', 'views', false, 0, 0, 999999999)

await databases.createIndex('main', 'posts', 'slug_index', 'unique', ['slug'])
await databases.createIndex('main', 'posts', 'author_index', 'key', ['authorId'])
await databases.createIndex('main', 'posts', 'published_index', 'key', ['published', 'publishedAt'])

CRUD operations

Code
TypeScript
import { databases } from '@/lib/appwrite'
import { Query, ID } from 'appwrite'

const DATABASE_ID = 'main'
const COLLECTION_ID = 'posts'

async function createPost(data: {
  title: string
  content: string
  slug: string
  authorId: string
  tags?: string[]
}) {
  const document = await databases.createDocument(
    DATABASE_ID,
    COLLECTION_ID,
    ID.unique(),
    {
      ...data,
      published: false,
      views: 0,
      createdAt: new Date().toISOString()
    }
  )
  return document
}

async function getPost(postId: string) {
  const document = await databases.getDocument(
    DATABASE_ID,
    COLLECTION_ID,
    postId
  )
  return document
}

async function getPosts(options?: {
  published?: boolean
  authorId?: string
  tags?: string[]
  limit?: number
  offset?: number
}) {
  const queries: string[] = []

  if (options?.published !== undefined) {
    queries.push(Query.equal('published', options.published))
  }

  if (options?.authorId) {
    queries.push(Query.equal('authorId', options.authorId))
  }

  if (options?.tags?.length) {
    queries.push(Query.contains('tags', options.tags))
  }

  queries.push(Query.orderDesc('$createdAt'))

  queries.push(Query.limit(options?.limit || 10))
  queries.push(Query.offset(options?.offset || 0))

  const documents = await databases.listDocuments(
    DATABASE_ID,
    COLLECTION_ID,
    queries
  )

  return documents
}

async function updatePost(postId: string, data: Partial<{
  title: string
  content: string
  published: boolean
  tags: string[]
}>) {
  const document = await databases.updateDocument(
    DATABASE_ID,
    COLLECTION_ID,
    postId,
    data
  )
  return document
}

async function deletePost(postId: string) {
  await databases.deleteDocument(
    DATABASE_ID,
    COLLECTION_ID,
    postId
  )
}

Advanced queries

Code
TypeScript
import { Query } from 'appwrite'

const results = await databases.listDocuments(
  DATABASE_ID,
  COLLECTION_ID,
  [
    Query.search('title', 'React tutorial'),
    Query.equal('published', true)
  ]
)

const recentPosts = await databases.listDocuments(
  DATABASE_ID,
  COLLECTION_ID,
  [
    Query.greaterThan('publishedAt', '2024-01-01'),
    Query.lessThan('publishedAt', '2024-12-31')
  ]
)

const postsWithTags = await databases.listDocuments(
  DATABASE_ID,
  COLLECTION_ID,
  [
    Query.contains('tags', ['javascript', 'react'])
  ]
)

const drafts = await databases.listDocuments(
  DATABASE_ID,
  COLLECTION_ID,
  [
    Query.isNull('publishedAt')
  ]
)

const titles = await databases.listDocuments(
  DATABASE_ID,
  COLLECTION_ID,
  [
    Query.select(['title', 'slug', '$id'])
  ]
)

const nextPage = await databases.listDocuments(
  DATABASE_ID,
  COLLECTION_ID,
  [
    Query.cursorAfter('lastDocumentId'),
    Query.limit(10)
  ]
)

Relationships between documents

Code
TypeScript
// Appwrite has no native relations, but they can be simulated

// 1. Reference by ID
interface Post {
  $id: string
  title: string
  authorId: string // Reference to User
}

interface Comment {
  $id: string
  content: string
  postId: string // Reference to Post
  authorId: string
}

// 2. Fetching with relations (manual join)
async function getPostWithComments(postId: string) {
  const [post, comments] = await Promise.all([
    databases.getDocument(DATABASE_ID, 'posts', postId),
    databases.listDocuments(DATABASE_ID, 'comments', [
      Query.equal('postId', postId),
      Query.orderDesc('$createdAt')
    ])
  ])

  return {
    ...post,
    comments: comments.documents
  }
}

// 3. Fetching the author
async function getPostWithAuthor(postId: string) {
  const post = await databases.getDocument(DATABASE_ID, 'posts', postId)
  const author = await databases.getDocument(DATABASE_ID, 'users', post.authorId)

  return {
    ...post,
    author
  }
}

Storage - file storage

Bucket configuration

Code
TypeScript
import { Storage, Permission, Role } from 'node-appwrite'

const storage = new Storage(client)

const bucket = await storage.createBucket(
  'avatars',
  'User Avatars',
  [
    Permission.read(Role.any()),
    Permission.create(Role.users()),
    Permission.update(Role.users()),
    Permission.delete(Role.users())
  ],
  false, // fileSecurity - whether to check permissions at the file level
  true,  // enabled
  5 * 1024 * 1024, // maxFileSize - 5MB
  ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], // allowedFileExtensions
  'gzip', // compression
  true, // encryption
  true  // antivirus
)

File uploads

Code
TypeScript
import { storage } from '@/lib/appwrite'
import { ID } from 'appwrite'

async function uploadFile(file: File, bucketId: string = 'uploads') {
  const result = await storage.createFile(
    bucketId,
    ID.unique(),
    file
  )
  return result
}

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

    try {
      const result = await uploadFile(file, 'avatars')
      console.log('Uploaded:', result)
    } catch (error) {
      console.error('Upload error:', error)
    }
  }

  return (
    <input
      type="file"
      accept="image/*"
      onChange={handleUpload}
    />
  )
}

async function uploadWithProgress(
  file: File,
  bucketId: string,
  onProgress: (progress: number) => void
) {
  const result = await storage.createFile(
    bucketId,
    ID.unique(),
    file,
    undefined,
    (progress) => {
      onProgress(Math.round((progress.chunksUploaded / progress.chunksTotal) * 100))
    }
  )
  return result
}

Retrieving and displaying files

Code
TypeScript
function getFileUrl(bucketId: string, fileId: string) {
  return storage.getFileView(bucketId, fileId)
}

function getFilePreview(
  bucketId: string,
  fileId: string,
  options?: {
    width?: number
    height?: number
    quality?: number
    gravity?: string
    output?: string
  }
) {
  return storage.getFilePreview(
    bucketId,
    fileId,
    options?.width,
    options?.height,
    options?.gravity || 'center',
    options?.quality || 90,
    undefined, // borderWidth
    undefined, // borderColor
    undefined, // borderRadius
    undefined, // opacity
    undefined, // rotation
    undefined, // background
    options?.output || 'webp'
  )
}

function AppwriteImage({
  bucketId,
  fileId,
  width,
  height,
  alt
}: {
  bucketId: string
  fileId: string
  width: number
  height: number
  alt: string
}) {
  const url = getFilePreview(bucketId, fileId, { width, height })

  return (
    <img
      src={url.href}
      width={width}
      height={height}
      alt={alt}
      loading="lazy"
    />
  )
}

Download and deletion

Code
TypeScript
async function downloadFile(bucketId: string, fileId: string) {
  const result = storage.getFileDownload(bucketId, fileId)

  window.open(result.href, '_blank')
}

async function deleteFile(bucketId: string, fileId: string) {
  await storage.deleteFile(bucketId, fileId)
}

async function listFiles(bucketId: string) {
  const files = await storage.listFiles(bucketId)
  return files.files
}

async function getFileInfo(bucketId: string, fileId: string) {
  const file = await storage.getFile(bucketId, fileId)
  return {
    id: file.$id,
    name: file.name,
    size: file.sizeOriginal,
    mimeType: file.mimeType,
    createdAt: file.$createdAt
  }
}

Cloud Functions

Creating a function

JSfunctions/send-welcome-email/src/main.js
JavaScript
// functions/send-welcome-email/src/main.js
import { Client, Users } from 'node-appwrite'

export default async ({ req, res, log, error }) => {
  const client = new Client()
    .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT)
    .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID)
    .setKey(req.headers['x-appwrite-key'])

  try {
    const { userId, email, name } = JSON.parse(req.body)

    log(`Sending welcome email to ${email}`)

    await sendWelcomeEmail(email, name)

    return res.json({
      success: true,
      message: `Welcome email sent to ${email}`
    })
  } catch (err) {
    error(err.message)
    return res.json({
      success: false,
      error: err.message
    }, 500)
  }
}

async function sendWelcomeEmail(email, name) {
}

Function configuration

functions/send-welcome-email/appwrite.json
JSON
// functions/send-welcome-email/appwrite.json
{
  "projectId": "your-project-id",
  "projectName": "Your Project",
  "functions": [
    {
      "$id": "send-welcome-email",
      "name": "Send Welcome Email",
      "runtime": "node-18.0",
      "execute": ["users"],
      "events": ["users.*.create"],
      "schedule": "",
      "timeout": 15,
      "enabled": true,
      "logging": true,
      "entrypoint": "src/main.js",
      "commands": "npm install",
      "scopes": ["users.read"]
    }
  ]
}

Calling functions

Code
TypeScript
import { functions } from '@/lib/appwrite'

async function callFunction() {
  const execution = await functions.createExecution(
    'send-welcome-email',
    JSON.stringify({ email: 'user@example.com', name: 'John' }),
    false, // async
    '/', // path
    'POST', // method
    { 'Content-Type': 'application/json' } // headers
  )

  return JSON.parse(execution.responseBody)
}

async function callFunctionAsync() {
  const execution = await functions.createExecution(
    'process-data',
    JSON.stringify({ data: 'large-dataset' }),
    true // async
  )

  return execution.$id
}

async function checkExecution(functionId: string, executionId: string) {
  const execution = await functions.getExecution(functionId, executionId)
  return {
    status: execution.status,
    response: execution.responseBody,
    errors: execution.errors
  }
}

Example: webhook handler

JSfunctions/stripe-webhook/src/main.js
JavaScript
// functions/stripe-webhook/src/main.js
import Stripe from 'stripe'
import { Client, Databases } from 'node-appwrite'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

export default async ({ req, res, log, error }) => {
  const client = new Client()
    .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT)
    .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID)
    .setKey(process.env.APPWRITE_API_KEY)

  const databases = new Databases(client)

  try {
    const signature = req.headers['stripe-signature']
    const event = stripe.webhooks.constructEvent(
      req.bodyRaw,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    )

    log(`Processing Stripe event: ${event.type}`)

    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object

        await databases.updateDocument(
          'main',
          'orders',
          session.metadata.orderId,
          {
            status: 'paid',
            stripePaymentId: session.payment_intent,
            paidAt: new Date().toISOString()
          }
        )
        break
      }

      case 'customer.subscription.created': {
        const subscription = event.data.object

        await databases.updateDocument(
          'main',
          'users',
          subscription.metadata.userId,
          {
            subscriptionStatus: 'active',
            subscriptionId: subscription.id,
            subscriptionEndsAt: new Date(subscription.current_period_end * 1000).toISOString()
          }
        )
        break
      }
    }

    return res.json({ received: true })
  } catch (err) {
    error(err.message)
    return res.json({ error: err.message }, 400)
  }
}

Realtime subscriptions

Real-time subscriptions

Code
TypeScript
import { client } from '@/lib/appwrite'

function subscribeToCollection(
  databaseId: string,
  collectionId: string,
  callback: (payload: any) => void
) {
  const channel = `databases.${databaseId}.collections.${collectionId}.documents`

  return client.subscribe(channel, (response) => {
    console.log('Event:', response.events)
    console.log('Payload:', response.payload)
    callback(response.payload)
  })
}

function subscribeToDocument(
  databaseId: string,
  collectionId: string,
  documentId: string,
  callback: (payload: any) => void
) {
  const channel = `databases.${databaseId}.collections.${collectionId}.documents.${documentId}`

  return client.subscribe(channel, (response) => {
    callback(response.payload)
  })
}

function subscribeToFiles(bucketId: string, callback: (payload: any) => void) {
  const channel = `buckets.${bucketId}.files`

  return client.subscribe(channel, (response) => {
    callback(response.payload)
  })
}

function subscribeToAccount(callback: (payload: any) => void) {
  return client.subscribe('account', (response) => {
    callback(response.payload)
  })
}

React hook for realtime

TShooks/useRealtime.ts
TypeScript
// hooks/useRealtime.ts
import { useEffect, useState } from 'react'
import { client } from '@/lib/appwrite'

export function useRealtimeCollection<T>(
  databaseId: string,
  collectionId: string,
  initialData: T[]
) {
  const [data, setData] = useState<T[]>(initialData)

  useEffect(() => {
    const channel = `databases.${databaseId}.collections.${collectionId}.documents`

    const unsubscribe = client.subscribe(channel, (response) => {
      const eventType = response.events[0]
      const document = response.payload as T & { $id: string }

      if (eventType.includes('.create')) {
        setData(prev => [document, ...prev])
      } else if (eventType.includes('.update')) {
        setData(prev => prev.map(item =>
          (item as any).$id === document.$id ? document : item
        ))
      } else if (eventType.includes('.delete')) {
        setData(prev => prev.filter(item =>
          (item as any).$id !== document.$id
        ))
      }
    })

    return () => {
      unsubscribe()
    }
  }, [databaseId, collectionId])

  return data
}

function ChatMessages({ chatId }: { chatId: string }) {
  const [initialMessages, setInitialMessages] = useState([])

  useEffect(() => {
    databases.listDocuments('main', 'messages', [
      Query.equal('chatId', chatId),
      Query.orderDesc('$createdAt')
    ]).then(res => setInitialMessages(res.documents))
  }, [chatId])

  const messages = useRealtimeCollection('main', 'messages', initialMessages)

  return (
    <div>
      {messages.map(msg => (
        <div key={msg.$id}>{msg.content}</div>
      ))}
    </div>
  )
}

Integrations and SDKs

Next.js App Router

TSapp/api/posts/route.ts
TypeScript
// app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { Client, Databases, Query } from 'node-appwrite'

const client = new Client()
  .setEndpoint(process.env.APPWRITE_ENDPOINT!)
  .setProject(process.env.APPWRITE_PROJECT_ID!)
  .setKey(process.env.APPWRITE_API_KEY!)

const databases = new Databases(client)

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const page = parseInt(searchParams.get('page') || '1')
  const limit = 10

  const posts = await databases.listDocuments(
    'main',
    'posts',
    [
      Query.equal('published', true),
      Query.orderDesc('$createdAt'),
      Query.limit(limit),
      Query.offset((page - 1) * limit)
    ]
  )

  return NextResponse.json(posts)
}

export async function POST(request: Request) {
  const body = await request.json()

  const post = await databases.createDocument(
    'main',
    'posts',
    'unique()',
    body
  )

  return NextResponse.json(post)
}

Flutter integration

lib/appwrite.dart
DART
// lib/appwrite.dart
import 'package:appwrite/appwrite.dart';

class AppwriteService {
  static final Client client = Client()
    .setEndpoint('https://cloud.appwrite.io/v1')
    .setProject('your-project-id');

  static final Account account = Account(client);
  static final Databases databases = Databases(client);
  static final Storage storage = Storage(client);
}

// lib/services/auth_service.dart
class AuthService {
  final Account _account = AppwriteService.account;

  Future<User> register(String email, String password, String name) async {
    final user = await _account.create(
      userId: ID.unique(),
      email: email,
      password: password,
      name: name,
    );
    return user;
  }

  Future<Session> login(String email, String password) async {
    final session = await _account.createEmailPasswordSession(
      email: email,
      password: password,
    );
    return session;
  }

  Future<User?> getCurrentUser() async {
    try {
      return await _account.get();
    } catch (e) {
      return null;
    }
  }

  Future<void> logout() async {
    await _account.deleteSession(sessionId: 'current');
  }
}

React Native integration

TSlib/appwrite.ts
TypeScript
// lib/appwrite.ts (React Native)
import { Client, Account, Databases, Storage } from 'react-native-appwrite'

const client = new Client()
  .setEndpoint('https://cloud.appwrite.io/v1')
  .setProject('your-project-id')
  .setPlatform('com.example.myapp') // Bundle ID

export const account = new Account(client)
export const databases = new Databases(client)
export const storage = new Storage(client)

import * as ImagePicker from 'expo-image-picker'

async function pickAndUploadImage() {
  const result = await ImagePicker.launchImageLibraryAsync({
    mediaTypes: ImagePicker.MediaTypeOptions.Images,
    quality: 0.8,
  })

  if (!result.canceled) {
    const file = {
      uri: result.assets[0].uri,
      name: 'photo.jpg',
      type: 'image/jpeg',
    }

    const uploaded = await storage.createFile(
      'avatars',
      ID.unique(),
      file
    )

    return uploaded
  }
}

Functions and events, where the logic belongs

Splitting work between the client and functions is a decision you make once and live with afterwards. The rule that holds up in practice: leave in the client whatever does no harm if forged, and move everything else into a function.

Writing a record that belongs to the user can go straight from the browser, since the platform checks permissions anyway. Awarding points, changing an order's status, or anything somebody might want to inflate has to go through a function holding a server key. The distinction looks obvious on paper and reliably blurs when, under time pressure, somebody adds "just one field" on the client side.

Event triggered functions are the most interesting part here, because they let you build reactions without writing a queue. Account creation can send its own welcome message, a file upload can start its own processing, a row change can invalidate its own cache. You only have to remember that such a function may run more than once for the same event, so its operations must survive repetition. A counter incremented without an idempotency key will double count, and nobody notices until the first complaint arrives.

Pricing and real cost

PlanPriceBandwidthStorageFunction executionsActive users
Free0 USD5 GB2 GB750k75k monthly
Profrom 25 USD per month2 TB150 GB3.5M200k monthly
Enterprisecustom quotenegotiatednegotiatednegotiatednegotiated

The free plan carries one limit that stings more in practice than the traffic caps: one database, one storage bucket, and two functions per project. That is fine for a prototype and runs out sooner than the user count would suggest once a product has several modules. The Pro plan lifts those quantity limits and adds daily backups with seven day retention.

The self hosted variant has no licensing limits at all, and you pay only for the server. Counting that as a saving against 25 dollars a month is tempting, but the arithmetic misleads. Backups, upgrades, monitoring, and the time to respond when something breaks at night all belong on the bill. For a single small product the managed version comes out cheaper once counted honestly. Self hosting starts paying off across several projects at once, or when data cannot legally leave a particular infrastructure.

If cost alone is the reason for self hosting and the project is small, look at PocketBase first. You get a similar scope, a database, authentication with external providers, files, realtime notifications, and a dashboard, in one executable instead of a set of containers. What falls away is functions in many languages and spreading across several machines, since this is one process with an SQLite database, and the project has not reached version 1.0 and warns itself that backward compatibility is not guaranteed.

One cost is routinely underestimated: egress bandwidth when serving files. If your application hands out images or video straight from storage, the bandwidth allowance runs dry well before the user limits do. The answer is a content delivery network in front of storage, not a higher plan.

The biggest difference between plans is not the size of the limits, though, but what happens once you pass them. On the free plan the project freezes and the console drops to read only until the billing period ends. On Pro the project keeps running and the excess joins your bill at the rates below.

ResourceOverage rate
Bandwidth15 USD per 100 GB monthly
Storage2.80 USD per 100 GB
Function executions2 USD per million
Database reads0.06 USD per 100k
Database writes0.10 USD per 100k

Reads and writes deserve a note as separately metered, since the plan table does not show them: the free plan grants five hundred thousand reads and two hundred fifty thousand writes a month, Pro one million seven hundred fifty thousand and seven hundred fifty thousand respectively. In an application querying the database on every view refresh, that is the line which grows fastest. Before anything reaches production, set a budget cap on the organisation, because without one the overage billing has no ceiling.

Common mistakes

The first concerns permissions. The model here is very fine grained and allows rights down to a single row, which makes it easy to build a collection that behaves perfectly in single account testing and is wide open in production. Verify permissions from a second user's account before considering the matter closed, because the dashboard shows you an owner's view.

The second is keeping a server key on the browser side. The web and server libraries share a similar interface, so copying a snippet from a server example into a client component happens often and ends with a fully privileged key exposed. Only the project identifier and endpoint address belong in the browser.

The third is leaning too heavily on client side queries. The query layer is deliberately limited and has no joins, so fetching related data easily degenerates into a loop of requests. For lists longer than a few dozen items, move that into a function that does the work server side and returns a finished result.

The fourth is upgrading a self hosted instance without reading the notes. Releases on the 1.9 line carry data migrations, and some require manual steps when you skip several versions at once. A backup before upgrading is not a formality here.

The fifth is treating functions like an ordinary server. They start on demand, so the first call after an idle period is noticeably slower. For operations where response time matters you need either to keep functions warm or to move the logic closer to the client.

FAQ

How does Appwrite differ from Supabase?

In the data model. Supabase is PostgreSQL with an API layer, so you get full SQL, joins, and that database's entire ecosystem. Appwrite provides its own data layer with a simpler query language, in exchange for stronger mobile platform support and built in application hosting.

Do I have to rewrite code after the TablesDB change?

Not immediately. The old document based methods are marked deprecated but still work and still receive security patches. Write new projects against TablesDB from the start, since that is where new features land.

What does self hosting cost?

The platform itself nothing, you pay only for the server. The real cost also includes backups, upgrades, and maintenance time, so for a single small product the Pro plan usually works out cheaper than your own instance counted honestly.

Is Appwrite suitable for a mobile application?

Yes, and this is one of its strengths. The Flutter, Android, and Apple platform libraries are developed alongside the web one rather than as an afterthought. External account sign in and realtime notifications behave identically across all of them.

Will Appwrite Sites replace Vercel?

That depends on what you expect. The basics overlap: deployments from a repository, preview addresses, a content delivery network. The advantage is keeping the application and backend in one project; the weaker side is less mature surrounding tooling and a shorter track record.

Current versions and release notes live in the Appwrite repository, and the TablesDB interface is described in the project documentation.