Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik25 min czytania

Appwrite, otwarta alternatywa dla Firebase

Appwrite to otwarty backend z autoryzacją, bazą TablesDB, plikami i funkcjami. Wersja 1.9, self hosting, Sites, cennik i porównanie z Supabase.

Appwrite, otwarta alternatywa dla Firebase

Appwrite to otwarty zestaw usług backendowych, który zdejmuje z projektu konieczność pisania własnej autoryzacji, warstwy danych, obsługi plików i zadań w tle. Można go uruchomić na własnym serwerze przez Dockera albo wziąć wersję zarządzaną w chmurze. Aktualne wydanie to 1.9.6 z 22 lipca 2026 roku, a projekt rozwija się na licencji BSD.

Co Appwrite właściwie zastępuje

Najkrótsza odpowiedź brzmi: cały backend typowej aplikacji, w której nie ma skomplikowanej logiki domenowej. Rejestracja i logowanie, resetowanie hasła, logowanie przez konto Google, przechowywanie rekordów z uprawnieniami na poziomie pojedynczego wiersza, przesyłanie plików z generowaniem miniatur, powiadomienia w czasie rzeczywistym i funkcje uruchamiane zdarzeniem. To wszystko jest gotowe i konfigurowane z panelu.

Konsekwencja jest taka, że pierwsza wersja produktu powstaje w kilka dni zamiast w kilka tygodni, a cena za to jest opóźniona w czasie. Płacisz nią dopiero wtedy, gdy potrzebujesz czegoś, czego platforma nie przewidziała: nietypowego zapytania łączącego trzy zbiory danych, transakcji obejmującej kilka operacji albo migracji, która ma przejść bez przestoju.

Warto rozumieć, gdzie leży granica. Appwrite jest mocny w tym, co da się opisać jako operacje na pojedynczych rekordach z uprawnieniami. Jest słaby wszędzie tam, gdzie zaczyna się praca na zbiorach: raportowanie, agregacje, złożone złączenia. Jeśli Twój produkt to głównie odczyt i zapis rekordów użytkownika, trafiłeś dobrze. Jeśli to system analityczny, PostgreSQL z własną warstwą API posłuży lepiej.

Zmiana nazewnictwa, o której trzeba wiedzieć

To najważniejsza rzecz przy czytaniu starszych poradników, także starszych fragmentów tego tekstu. W wersji 1.8, wydanej w sierpniu 2025 roku, warstwa danych dostała nowy interfejs o nazwie TablesDB, a wraz z nim nowe słownictwo. Kolekcje stały się tabelami, dokumenty wierszami, a atrybuty kolumnami. Zmiana jest bardziej niż kosmetyczna, bo dotknęła też nazw metod w bibliotekach klienckich.

Stare metody oparte o dokumenty są oznaczone jako przestarzałe. Nadal działają i nadal dostają poprawki bezpieczeństwa oraz podstawowe utrzymanie, ale nie będą już rozwijane. Zgodność wsteczna została utrzymana, więc istniejący kod nie przestanie działać z dnia na dzień.

Praktyczny wniosek dla nowego projektu: pisz od razu na TablesDB, bo to interfejs, który dostaje nowe funkcje. Dla projektu istniejącego nie ma pilnej potrzeby migracji, ale warto ją zaplanować, zanim przestarzała warstwa zacznie hamować dostęp do nowości. Przykłady kodu w dalszej części tego artykułu korzystają jeszcze ze starszego nazewnictwa, więc traktuj je jako mapę pojęć, a nazwy metod sprawdzaj w bieżącej dokumentacji.

Appwrite Sites, czyli hosting w tym samym projekcie

Osobny wątek, który zmienił pozycjonowanie tej platformy. W maju 2025 roku pojawiły się Appwrite Sites, a od lipca tego samego roku są dostępne dla wszystkich. To warstwa hostingu stron statycznych i aplikacji renderowanych po stronie serwera, wprost pozycjonowana jako otwarta alternatywa dla Vercela.

Działa to tak, jak można się spodziewać: podpinasz repozytorium, każdy zapis do gałęzi uruchamia wdrożenie, każde zgłoszenie zmian dostaje własny adres podglądu, a treść rozchodzi się przez sieć dostarczania treści. Obsługiwane są aplikacje statyczne oraz renderowanie serwerowe dla popularnych frameworków, w tym Next.js, Nuxt, SvelteKit i Astro.

Realna wartość nie leży w samym hostingu, bo ten dostaniesz w wielu miejscach. Leży w tym, że aplikacja i jej backend siedzą w jednym projekcie, z jedną konfiguracją zmiennych środowiskowych i jednym miejscem, w którym sprawdzasz logi. Przy małym zespole to oszczędza więcej czasu niż jakakolwiek pojedyncza funkcja platformy. Porównanie z Vercelem wypada wtedy inaczej, niż wynikałoby z zestawienia samych parametrów.

Appwrite a Firebase i Supabase

CechaAppwriteFirebaseSupabase
Licencjaotwarta, BSDzamkniętaotwarta, Apache 2.0
Self hostingpełny, przez Dockerabrakmożliwy, trudniejszy
Model danychtabele i wiersze, TablesDBdokumentyPostgreSQL, relacyjny
Zapytania złożoneograniczoneograniczonepełny SQL
Środowiska funkcjiwiele językówgłównie JS i TSDeno oraz baza
Hosting aplikacjitak, Sitestakczęściowo
Uzależnienie od dostawcyniskiewysokieniskie

Rozstrzygnięcie między tą trójką rzadko zależy od listy funkcji, bo zestaw jest podobny. Zależy od modelu danych i od tego, czy potrzebujesz wyjścia awaryjnego.

Supabase wygrywa wszędzie tam, gdzie dane są relacyjne i gdzie prędzej czy później napiszesz zapytanie z trzema złączeniami. Dostajesz zwykły PostgreSQL, więc masz do dyspozycji cały jego ekosystem. Appwrite wygrywa tam, gdzie liczy się prostota i wieloplatformowość, zwłaszcza przy aplikacjach mobilnych, bo biblioteki dla Fluttera i platform natywnych są tu traktowane na równi z webową, a nie jako dodatek. Firebase wygrywa integracją z resztą usług Google i dojrzałością narzędzi pobocznych, płacąc za to całkowitym brakiem drogi wyjścia.

Instalacja i konfiguracja

Self-hosting z Docker

Najprostszy sposób uruchomienia Appwrite to użycie oficjalnego skryptu instalacyjnego:

Code
Bash
# Instalacja jedną komendą
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

# Po instalacji otwórz http://localhost:80

Docker Compose (zalecane)

Dla większej kontroli użyj 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
# Uruchomienie
docker-compose up -d

# Sprawdzenie statusu
docker-compose ps

# Logi
docker-compose logs -f appwrite

Appwrite Cloud

Dla szybkiego startu bez zarządzania infrastrukturą:

Code
Bash
# 1. Zarejestruj się na cloud.appwrite.io
# 2. Utwórz nowy projekt
# 3. Skopiuj Project ID i Endpoint

Instalacja pakietu SDK

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 - kompletny system autoryzacji

Konfiguracja klienta

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') // Lub Twój self-hosted URL
  .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 }

Rejestracja i logowanie

Code
TypeScript
// Rejestracja nowego użytkownika
async function register(email: string, password: string, name: string) {
  try {
    // Utwórz konto
    const user = await account.create(
      'unique()', // Automatycznie generowany ID
      email,
      password,
      name
    )

    // Automatycznie zaloguj
    await account.createEmailPasswordSession(email, password)

    // Wyślij email weryfikacyjny
    await account.createVerification('https://example.com/verify')

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

// Logowanie
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
  }
}

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

// Pobranie aktualnego użytkownika
async function getCurrentUser() {
  try {
    return await account.get()
  } catch (error) {
    // Użytkownik nie jest zalogowany
    return null
  }
}

OAuth - logowanie przez serwis zewnętrzny

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

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

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

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

Magic Link (logowanie bez hasła)

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

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

Logowanie przez telefon

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

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

Multi-Factor Authentication (MFA)

Code
TypeScript
// Włącz MFA
async function enableMFA() {
  // 1. Utwórz TOTP secret
  const totp = await account.createMfaAuthenticator('totp')

  // 2. Pokaż użytkownikowi QR code
  console.log('Secret:', totp.secret)
  console.log('QR URI:', totp.uri)

  return totp
}

// Weryfikuj i aktywuj MFA
async function verifyMFA(code: string) {
  await account.updateMfaAuthenticator('totp', code)
}

// Logowanie z MFA
async function loginWithMFA(email: string, password: string, mfaCode: string) {
  // 1. Normalne logowanie
  const session = await account.createEmailPasswordSession(email, password)

  // 2. Weryfikacja MFA jeśli wymagana
  if (session.mfaRequired) {
    await account.updateMfaChallenge(
      session.mfaChallengeId,
      mfaCode
    )
  }

  return session
}

Hook Reacta do autoryzacji

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 - dokumentowa baza danych

Struktura danych

Appwrite używa modelu dokumentowego z hierarchią:

  • Database, kontener na kolekcje, dziś nazywany bazą w TablesDB
  • Collection, schemat dokumentów, dziś tabela
  • Document, pojedynczy rekord, dziś wiersz
  • Attribute, pole dokumentu, dziś kolumna

Tworzenie schematu

Code
TypeScript
// W konsoli Appwrite lub przez SDK
import { Databases, Permission, Role } from 'node-appwrite'

const databases = new Databases(client)

// 1. Utwórz bazę danych
const database = await databases.create(
  'main', // Database ID
  'Main Database' // Name
)

// 2. Utwórz kolekcję
const collection = await databases.createCollection(
  'main',
  'posts',
  'Blog Posts',
  [
    Permission.read(Role.any()), // Publiczny odczyt
    Permission.create(Role.users()), // Zalogowani mogą tworzyć
    Permission.update(Role.users()), // Zalogowani mogą edytować
    Permission.delete(Role.users())  // Zalogowani mogą usuwać
  ]
)

// 3. Dodaj atrybuty
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) // Array
await databases.createIntegerAttribute('main', 'posts', 'views', false, 0, 0, 999999999)

// 4. Utwórz indeksy
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'])

Operacje CRUD

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

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

// CREATE - Tworzenie dokumentu
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(), // Lub własny ID
    {
      ...data,
      published: false,
      views: 0,
      createdAt: new Date().toISOString()
    }
  )
  return document
}

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

// READ - Lista dokumentów z filtrowaniem
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))
  }

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

  // Paginacja
  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
}

// UPDATE - Aktualizacja dokumentu
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
}

// DELETE - Usuwanie dokumentu
async function deletePost(postId: string) {
  await databases.deleteDocument(
    DATABASE_ID,
    COLLECTION_ID,
    postId
  )
}

Zaawansowane zapytania

Code
TypeScript
import { Query } from 'appwrite'

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

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

// Wartości w tablicy
const postsWithTags = await databases.listDocuments(
  DATABASE_ID,
  COLLECTION_ID,
  [
    Query.contains('tags', ['javascript', 'react'])
  ]
)

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

// Wybór pól (zmniejszenie transferu danych)
const titles = await databases.listDocuments(
  DATABASE_ID,
  COLLECTION_ID,
  [
    Query.select(['title', 'slug', '$id'])
  ]
)

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

Relacje między dokumentami

Code
TypeScript
// Appwrite nie ma natywnych relacji, ale można je symulować

// 1. Referencja przez ID
interface Post {
  $id: string
  title: string
  authorId: string // Referencja do User
}

interface Comment {
  $id: string
  content: string
  postId: string // Referencja do Post
  authorId: string
}

// 2. Pobieranie z relacjami (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. Pobieranie autora
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 - przechowywanie plików

Konfiguracja bucket

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

const storage = new Storage(client)

// Utwórz bucket
const bucket = await storage.createBucket(
  'avatars',
  'User Avatars',
  [
    Permission.read(Role.any()), // Publiczny odczyt
    Permission.create(Role.users()), // Użytkownicy mogą uploadować
    Permission.update(Role.users()),
    Permission.delete(Role.users())
  ],
  false, // fileSecurity - czy sprawdzać uprawnienia na poziomie pliku
  true,  // enabled
  5 * 1024 * 1024, // maxFileSize - 5MB
  ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], // allowedFileExtensions
  'gzip', // compression
  true, // encryption
  true  // antivirus
)

Upload plików

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

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

// Upload z inputem
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}
    />
  )
}

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

Pobieranie i wyświetlanie plików

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

// URL do podglądu z transformacją
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'
  )
}

// Komponent Image
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"
    />
  )
}

Pobieranie i usuwanie

Code
TypeScript
// Pobierz plik do download
async function downloadFile(bucketId: string, fileId: string) {
  const result = storage.getFileDownload(bucketId, fileId)

  // Otwórz w nowym oknie
  window.open(result.href, '_blank')
}

// Usuń plik
async function deleteFile(bucketId: string, fileId: string) {
  await storage.deleteFile(bucketId, fileId)
}

// Lista plików
async function listFiles(bucketId: string) {
  const files = await storage.listFiles(bucketId)
  return files.files
}

// Informacje o pliku
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

Tworzenie funkcji

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 }) => {
  // Inicjalizacja klienta z API key
  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}`)

    // Wyślij email (np. przez Resend, SendGrid)
    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) {
  // Implementacja wysyłki
}

Konfiguracja funkcji

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"]
    }
  ]
}

Wywołanie funkcji

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

// Synchroniczne wywołanie
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)
}

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

  // Execution ID do sprawdzenia statusu później
  return execution.$id
}

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

Przykład: 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 {
    // Weryfikacja webhook signature
    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

        // Zaktualizuj status zamówienia
        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

        // Aktywuj subskrypcję użytkownika
        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)
  }
}

Subskrypcje w czasie rzeczywistym

Subskrypcje w czasie rzeczywistym

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

// Subskrybuj zmiany w kolekcji
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)
  })
}

// Subskrybuj konkretny dokument
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)
  })
}

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

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

// Subskrybuj status użytkownika
function subscribeToAccount(callback: (payload: any) => void) {
  return client.subscribe('account', (response) => {
    callback(response.payload)
  })
}

Hook Reacta do subskrypcji

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
}

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

  useEffect(() => {
    // Pobierz początkowe wiadomości
    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>
  )
}

Integracje i SDK

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)
}

Integracja z Flutterem

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');
  }
}

Integracja z React Native

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)

// Upload z React Native
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
  }
}

Funkcje i zdarzenia, czyli gdzie umieścić logikę

Podział pracy między klientem a funkcjami jest decyzją, którą podejmuje się raz i z którą się potem żyje. Reguła, która sprawdza się w praktyce, brzmi: w kliencie zostaw to, czego podrobienie nie zaszkodzi, a wszystko pozostałe przenieś do funkcji.

Zapis rekordu należącego do użytkownika może iść bezpośrednio z przeglądarki, bo uprawnienia i tak sprawdzi platforma. Naliczenie punktów, zmiana statusu zamówienia czy cokolwiek, co ktoś mógłby chcieć zawyżyć, musi iść przez funkcję z kluczem serwerowym. Rozróżnienie wydaje się oczywiste na papierze i regularnie się rozmywa, gdy pod presją czasu dopisuje się „tylko jedno pole" po stronie klienta.

Funkcje uruchamiane zdarzeniem są tu najciekawszą częścią, bo pozwalają zbudować reakcje bez pisania kolejki. Utworzenie konta może samo wysłać wiadomość powitalną, przesłanie pliku samo uruchomić przetwarzanie, a zmiana wiersza samo unieważnić pamięć podręczną. Trzeba tylko pamiętać, że taka funkcja może wykonać się więcej niż raz dla tego samego zdarzenia, więc operacje muszą być odporne na powtórzenie. Licznik zwiększany bez klucza idempotencji policzy podwójnie i nikt tego nie zauważy do czasu pierwszej reklamacji.

Cennik i realny koszt

PlanCenaTransferDyskWywołania funkcjiAktywni użytkownicy
Free0 USD5 GB2 GB750 tys.75 tys. miesięcznie
Prood 25 USD miesięcznie2 TB150 GB3,5 mln200 tys. miesięcznie
Enterprisewycena indywidualnaustalanyustalanyustalanyustalany

Plan darmowy ma jedno ograniczenie, które w praktyce boli bardziej niż limity ruchu: jedna baza, jeden kosz na pliki i dwie funkcje na projekt. Przy prototypie to wystarcza, przy produkcie z kilkoma modułami kończy się szybciej, niż wynikałoby z liczby użytkowników. Plan Pro zdejmuje te limity ilościowe i dokłada codzienne kopie zapasowe z tygodniową retencją.

Wariant samodzielnie hostowany nie ma żadnych limitów licencyjnych i płacisz wyłącznie za serwer. Kuszące jest policzenie tego jako oszczędności względem 25 dolarów miesięcznie, ale ta arytmetyka jest myląca. Do rachunku trzeba dopisać kopie zapasowe, aktualizacje, monitorowanie i czas na reakcję, gdy coś padnie w nocy. Przy jednym niewielkim produkcie wersja zarządzana wychodzi taniej, licząc uczciwie. Samodzielne hostowanie zaczyna się opłacać przy kilku projektach naraz albo wtedy, gdy dane z powodów prawnych nie mogą opuścić określonej infrastruktury.

Jeśli powodem samodzielnego hostowania jest sam koszt, a projekt jest mały, sprawdź wcześniej PocketBase. Dostajesz podobny zakres, czyli bazę, uwierzytelnianie z logowaniem przez zewnętrznych dostawców, pliki, powiadomienia w czasie rzeczywistym i panel, w jednym pliku wykonywalnym zamiast zestawu kontenerów. Odpadają za to funkcje w wielu językach i skalowanie na kilka maszyn, bo to jeden proces z bazą SQLite, a projekt nie osiągnął jeszcze wersji 1.0 i sam uprzedza, że zgodność wsteczna nie jest gwarantowana.

Jeden koszt bywa niedoszacowany: transfer wychodzący przy serwowaniu plików. Jeśli aplikacja rozdaje zdjęcia albo materiały wideo bezpośrednio z magazynu, limit transferu wyczerpie się wcześniej niż limity użytkowników. Rozwiązaniem jest sieć dostarczania treści przed magazynem, a nie wyższy plan.

Najważniejsza różnica między planami nie leży jednak w wysokości limitów, tylko w tym, co się dzieje po ich przekroczeniu. Na planie darmowym projekt zamarza, a konsola przechodzi w tryb tylko do odczytu do końca okresu rozliczeniowego. Na planie Pro projekt działa dalej, a nadmiar dopisuje się do rachunku według stawek poniżej.

ZasóbStawka za nadmiar
Transfer15 USD za 100 GB miesięcznie
Dysk2,80 USD za 100 GB
Wywołania funkcji2 USD za milion
Odczyty z bazy0,06 USD za 100 tys.
Zapisy do bazy0,10 USD za 100 tys.

Odczyty i zapisy warto przy okazji odnotować jako osobno mierzone, bo w tabeli planów ich nie widać: plan darmowy daje pięćset tysięcy odczytów i dwieście pięćdziesiąt tysięcy zapisów miesięcznie, Pro odpowiednio milion siedemset pięćdziesiąt tysięcy i siedemset pięćdziesiąt tysięcy. Przy aplikacji odpytującej bazę przy każdym odświeżeniu widoku to właśnie ta pozycja rośnie najszybciej. Zanim cokolwiek trafi na produkcję, ustaw limit wydatków na organizacji, bo bez niego rozliczenie za nadmiar nie ma górnej granicy.

Typowe błędy

Pierwszy dotyczy uprawnień. Model jest tu bardzo drobnoziarnisty i pozwala nadawać prawa na poziomie pojedynczego wiersza, przez co łatwo zbudować kolekcję działającą świetnie w testach na jednym koncie i otwartą dla wszystkich na produkcji. Sprawdź uprawnienia z konta innego użytkownika, zanim uznasz temat za zamknięty, bo panel pokazuje Ci widok właściciela.

Drugi to trzymanie klucza serwerowego po stronie przeglądarki. Biblioteka webowa i serwerowa mają podobny interfejs, więc przeklejenie fragmentu kodu z przykładu serwerowego do komponentu klienckiego zdarza się często i kończy ujawnieniem klucza z pełnymi uprawnieniami. Do przeglądarki należy wyłącznie identyfikator projektu i adres punktu końcowego.

Trzeci to nadmierne poleganie na zapytaniach po stronie klienta. Warstwa zapytań jest celowo ograniczona i nie ma złączeń, więc pobieranie powiązanych danych łatwo zamienia się w pętlę żądań. Przy listach dłuższych niż kilkadziesiąt pozycji przenieś to do funkcji, która wykona pracę po stronie serwera i zwróci gotowy wynik.

Czwarty to aktualizacje wersji samodzielnie hostowanej bez czytania notatek. Wydania z gałęzi 1.9 zawierają migracje danych, a niektóre wymagają ręcznych kroków, jeśli przeskakujesz kilka wersji naraz. Kopia zapasowa przed aktualizacją nie jest tu formalnością.

Piąty to traktowanie funkcji jak zwykłego serwera. Uruchamiają się na żądanie, więc pierwsze wywołanie po okresie bezczynności bywa zauważalnie wolniejsze. Do operacji, w których liczy się czas odpowiedzi, potrzebujesz albo utrzymywania funkcji w gotowości, albo przeniesienia logiki bliżej klienta.

FAQ

Czym Appwrite różni się od Supabase?

Modelem danych. Supabase to PostgreSQL z warstwą API, więc masz pełny SQL, złączenia i cały ekosystem tej bazy. Appwrite daje własną warstwę danych z prostszym językiem zapytań, za to z mocniejszą obsługą platform mobilnych i wbudowanym hostingiem aplikacji.

Czy trzeba przepisać kod po zmianie na TablesDB?

Nie od razu. Stare metody oparte o dokumenty są oznaczone jako przestarzałe, ale nadal działają i otrzymują poprawki bezpieczeństwa. Nowe projekty pisz od razu na TablesDB, bo to tam trafiają nowe funkcje.

Ile kosztuje samodzielne hostowanie?

Sama platforma nic, płacisz tylko za serwer. Realny koszt to jednak także kopie zapasowe, aktualizacje i czas na utrzymanie, więc przy jednym niewielkim produkcie plan Pro wychodzi zwykle taniej niż własna instancja liczona uczciwie.

Czy Appwrite nadaje się do aplikacji mobilnej?

Tak, i to jest jego mocna strona. Biblioteki dla Fluttera, Androida i systemów Apple są rozwijane równolegle z webową, a nie jako dodatek. Logowanie przez konta zewnętrzne i powiadomienia w czasie rzeczywistym działają na wszystkich tych platformach tak samo.

Czy Appwrite Sites zastąpi Vercela?

Zależy od tego, czego oczekujesz. Podstawy się pokrywają: wdrożenia z repozytorium, adresy podglądu, sieć dostarczania treści. Przewagą jest trzymanie aplikacji i backendu w jednym projekcie, słabszą stroną mniej dojrzałe narzędzia poboczne i krótszy staż produktu.

Bieżące wersje i notatki wydawnicze znajdziesz w repozytorium Appwrite, a opis interfejsu TablesDB w dokumentacji projektu.