DEV Community

Cover image for Your First Full-Stack App with Appwrite — Auth, Database, Storage, and Functions in One Backend
Jordan Sterchele
Jordan Sterchele

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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),
  ]);
}
Enter fullscreen mode Exit fullscreen mode

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.*'])
  ]);
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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'))
Enter fullscreen mode Exit fullscreen mode

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

Trigger a function on user creation (in appwrite.json):

{
  "functions": [{
    "name": "send-welcome-email",
    "runtime": "node-18.0",
    "events": ["users.*.create"],
    "execute": ["any"]
  }]
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Start the stack:

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)