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
| Cecha | Appwrite | Firebase | Supabase |
|---|---|---|---|
| Licencja | otwarta, BSD | zamknięta | otwarta, Apache 2.0 |
| Self hosting | pełny, przez Dockera | brak | możliwy, trudniejszy |
| Model danych | tabele i wiersze, TablesDB | dokumenty | PostgreSQL, relacyjny |
| Zapytania złożone | ograniczone | ograniczone | pełny SQL |
| Środowiska funkcji | wiele języków | głównie JS i TS | Deno oraz baza |
| Hosting aplikacji | tak, Sites | tak | częściowo |
| Uzależnienie od dostawcy | niskie | wysokie | niskie |
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:
# 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:80Docker Compose (zalecane)
Dla większej kontroli użyj docker-compose:
# 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:# Uruchomienie
docker-compose up -d
# Sprawdzenie statusu
docker-compose ps
# Logi
docker-compose logs -f appwriteAppwrite Cloud
Dla szybkiego startu bez zarządzania infrastrukturą:
# 1. Zarejestruj się na cloud.appwrite.io
# 2. Utwórz nowy projekt
# 3. Skopiuj Project ID i EndpointInstalacja pakietu SDK
# 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/appwriteAuthentication - kompletny system autoryzacji
Konfiguracja klienta
// 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
// 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
// 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)
// 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
// 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)
// 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
// 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
// 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
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
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
// 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
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
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
// 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
// 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
// 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
{
"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
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
// 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
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
// 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
// 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
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
// 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
| Plan | Cena | Transfer | Dysk | Wywołania funkcji | Aktywni użytkownicy |
|---|---|---|---|---|---|
| Free | 0 USD | 5 GB | 2 GB | 750 tys. | 75 tys. miesięcznie |
| Pro | od 25 USD miesięcznie | 2 TB | 150 GB | 3,5 mln | 200 tys. miesięcznie |
| Enterprise | wycena indywidualna | ustalany | ustalany | ustalany | ustalany |
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ób | Stawka za nadmiar |
|---|---|
| Transfer | 15 USD za 100 GB miesięcznie |
| Dysk | 2,80 USD za 100 GB |
| Wywołania funkcji | 2 USD za milion |
| Odczyty z bazy | 0,06 USD za 100 tys. |
| Zapisy do bazy | 0,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.