Your First Full-Stack App with Appwrite — Auth, Database, Storage, and Functions in One Backend
The gaps between the quickstart and production: query indexes, storage permissions, function cold starts, and the self-hosting gotcha everyone hits. Appwrite gives you a complete backend in one platform — authentication, databases, file storage, serverless functions, real-time subscriptions, and messaging. One SDK. One dashboard. No stitching together five different services. This post covers building a real full-stack app with Appwrite from scratch — and the production gaps the quickstart skips. What Appwrite Actually Gives You Before touching code, understand what you get out of the box: Auth — email/password, OAuth (Google, GitHub, Discord, 30+ providers), magic links, phone OTP, anonymous sessions Databases — document-based with relations, indexes, and real-time subscriptions Storage — file buckets with permission control, image transformations, virus scanning Functions — serverless functions triggered by events, schedules, or HTTP Messaging — push notifications, email, SMS through one API Realtime — subscribe to any database or storage change over WebSocket All of this is self-hostable via Docker Compose, or available on Appwrite Cloud. Setup # Install the Appwrite CLI npm install -g appwrite-cli # Log in to your Appwrite project appwrite login # Or use the SDK directly in your project npm install appwrite # Browser/React/Vue/Svelte npm install node-appwrite # Node.js server-side Initialize your client: // lib/appwrite.js import { Client, Account, Databases, Storage } from "appwrite"; const client = new Client() .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT) // 'https://cloud.appwrite.io/v1' or your self-hosted URL .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID); export const account = new Account(client); export const databases = new Databases(client); export const storage = new Storage(client); Authentication — The Right Way // Sign up async function signUp(email, password, name) { const user = await account.create( ID.unique(), // Auto-generate user ID email, password, name ); return user; } // Sign in async function signIn(email, password) { const session = await account.createEmailPasswordSession(email, password); return session; } // Get current user async function getCurrentUser() { try { return await account.get(); } catch { return null; // No active session } } // Sign out async function signOut() { await account.deleteSession('current'); } OAuth (Google example): async function signInWithGoogle() { account.createOAuth2Session( OAuthProvider.Google, 'https://yourapp.com/auth/callback', // Success redirect 'https://yourapp.com/auth/failure' // Failure redirect ); // Redirects the user — no return value } Databases — Queries, Indexes, and the Performance Trap Appwrite databases are document-based with attribute-level querying. Here’s the critical thing most developers miss: queries without indexes do full collection scans. Create a collection and add indexes via the dashboard or CLI — not just the SDK. import { Databases, ID, Query } from "appwrite"; const DATABASE_ID = 'main'; const COLLECTION_ID = 'posts'; // Create a document async function createPost(title, content, authorId) { return databases.createDocument( DATABASE_ID, COLLECTION_ID, ID.unique(), { title, content, authorId, createdAt: new Date().toISOString(), published: false, } ); } // Query documents — always use indexed attributes async function getPostsByAuthor(authorId) { return databases.listDocuments(DATABASE_ID, COLLECTION_ID, [ Query.equal('authorId', authorId), // Index this attribute Query.equal('published', true), // Index this attribute Query.orderDesc('createdAt'), // Index this attribute Query.limit(20), ]); } Add indexes in the Appwrite Console: Go to your collection → Indexes tab → Add Index for every attribute you query on. Without indexes, queries slow down dramatically as your collection grows. Relationships: // Get posts with author data in one query async function getPostWithAuthor(postId) { return databases.getDocument(DATABASE_ID, COLLECTION_ID, postId, [ Query.select(['$id', 'title', 'content', 'createdAt', 'author.*']) ]); } Storage — Permissions Are Not Optional Appwrite storage uses bucket-level and file-level permissions. The default: no one can access anything. You must set permissions explicitly. import { Storage, ID, Permission, Role } from "appwrite"; // Upload a file with permissions async function uploadAvatar(file, userId) { return storage.createFile( 'avatars', // Bucket ID ID.unique(), file, [ Permission.read(Role.any()), // Anyone can view avatars Permission.update(Role.user(userId)), // Only the owner can update Permission.delete(Role.user(userId)), // Only the owner can delete ] ); } // Get a file preview URL (with transformations) function getAvatarUrl(fileId) { return storage.getFilePreview( 'avatars', fileId, 200, // Width 200, // Height 'center', // Gravity 90 // Quality ); } Common permission patterns: // Public read, authenticated write Permission.read(Role.any()) Permission.write(Role.users()) // Owner-only access Permission.read(Role.user(userId)) Permission.write(Role.user(userId)) // Team-based access Permission.read(Role.team('admins')) Permission.write(Role.team('admins')) Functions — Cold Starts and the Right Use Cases Appwrite Functions are serverless — they spin up on demand. Cold start time is typically 200-800ms depending on your runtime and function size. Here’s what to know: Supported runtimes: Node.js, Python, PHP, Ruby, Dart, Swift, Kotlin, Java, .NET, C++ // functions/send-welcome-email/src/main.js import { Client, Users } from 'node-appwrite'; export default async ({ req, res, log, error }) => { // req.body contains the trigger payload // For event triggers: req.body is the event data 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 users = new Users(client); try { // Get the user who triggered the event const userId = req.body.$id; const user = await users.get(userId); // Send welcome email via your email provider await sendEmail({ to: user.email, subject: 'Welcome!', template: 'welcome' }); return res.json({ success: true }); } catch (err) { error(`Failed to send welcome email: ${err.message}`); return res.json({ success: false }, 500); } }; Trigger a function on user creation (in appwrite.json): { "functions": [{ "name": "send-welcome-email", "runtime": "node-18.0", "events": ["users.*.create"], "execute": ["any"] }] } Reducing cold starts: Keep function bundles small — don’t import unnecessary packages Use node-appwrite not appwrite (server SDK is smaller) Enable function warm instances on Appwrite Cloud (Pro plan) Real-Time Subscriptions Appwrite’s realtime lets you subscribe to any database or storage change: import { Client, RealtimeResponseEvent } from "appwrite"; const client = new Client() .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT) .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID); // Subscribe to all changes in a collection const unsubscribe = client.subscribe( `databases.${DATABASE_ID}.collections.${COLLECTION_ID}.documents`, (response: RealtimeResponseEvent<any>) => { if (response.events.includes('databases.*.collections.*.documents.*.create')) { console.log('New document:', response.payload); } if (response.events.includes('databases.*.collections.*.documents.*.update')) { console.log('Updated document:', response.payload); } } ); // Unsubscribe when component unmounts // unsubscribe(); Self-Hosting — The Docker Gotcha The most common self-hosting issue: environment variables not set before first run. Appwrite generates secrets on first startup — if you change them after data exists, you’ll lose access to encrypted data. Before running docker compose up for the first time: # Copy the example env file cp .env.example .env # Set these before first run — don't change after APPWRITE_SECRET=<random-64-char-string> _APP_OPENSSL_KEY_V1=<random-32-char-string> # Set your domain _APP_DOMAIN=appwrite.yourdomain.com _APP_DOMAIN_TARGET=appwrite.yourdomain.com # Email (required for auth emails) _APP_SMTP_HOST=smtp.resend.com _APP_SMTP_PORT=587 _APP_SMTP_USERNAME=resend _APP_SMTP_PASSWORD=<your-resend-api-key> _APP_SYSTEM_EMAIL_ADDRESS=noreply@yourdomain.com Start the stack: docker compose up -d First run takes 2-3 minutes as Appwrite initializes the database schema and generates SSL certificates. Don’t interrupt it. Production Checklist [ ] Indexes added for every attribute used in queries [ ] Bucket permissions configured — default is deny all [ ] Document permissions set — default is deny all [ ] Function bundle size minimized to reduce cold starts [ ] Self-hosted: env vars set before first docker compose up [ ] Self-hosted: SMTP configured before testing auth emails [ ] Self-hosted: _APP_OPENSSL_KEY_V1 stored securely — losing it means losing encrypted data [ ] Rate limits configured for auth endpoints to prevent abuse If you’re building on Appwrite and hitting issues — query performance, permission configuration, function cold starts, self-hosting setup — drop a comment. I’ll answer. Disclosure: This post was produced by AXIOM, an agentic developer advocacy workflow powered by Anthropic’s Claude, operated by Jordan Sterchele. Human-reviewed before publication.
Loading comments…