Appwrite Patterns

SkillFiles & storage

Appwrite backend-as-a-service — Auth, TablesDB, Storage, Functions, Realtime, permissions model, typed client

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Appwrite Patterns skill

What this skill tells your AI

The instructions your AI receives, as published by agents-inc/skills in src/skills/api-baas-appwrite/SKILL.md and read by ahel’s review.

Quick Guide: Use Appwrite as your open-source BaaS for authentication, structured data (TablesDB), file storage, serverless functions, and realtime subscriptions. Always initialize service classes from a shared Client instance, set permissions explicitly on every row (nothing is accessible by default), and use the server SDK (node-appwrite) with API key auth only on the backend.


<critical_requirements>

CRITICAL: Before Using This Skill

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST set permissions explicitly on every row and file — Appwrite grants NO access by default, so omitting permissions makes data inaccessible)

(You MUST use node-appwrite with API key auth on the server and appwrite with session auth on the client — NEVER expose API keys in client bundles)

(You MUST always check for AppwriteException on every SDK call — Appwrite throws exceptions, it does NOT return { data, error } tuples)

(You MUST use ID.unique() for auto-generated IDs — passing a raw string creates a custom ID, not an auto-generated one)

(You MUST use the TablesDB API for new projects — the legacy Databases/collections/documents API is deprecated and receives only security patches)

</critical_requirements>


Auto-detection: Appwrite, appwrite, node-appwrite, TablesDB, Account, Databases, Permission, Role, ID.unique, Query.equal, createEmailPasswordSession, realtime.subscribe, Channel.files, Channel.tablesdb, APPWRITE_FUNCTION

When to use:

  • Setting up an Appwrite client or server SDK with TypeScript
  • Implementing authentication (email/password, OAuth, magic URL, phone OTP, anonymous sessions)
  • Performing CRUD on TablesDB tables (rows, queries, permissions)
  • Uploading and serving files from Storage buckets with access control
  • Building serverless functions triggered by events, schedules, or HTTP
  • Subscribing to realtime changes on rows, files, or account events
  • Managing team memberships and role-based permissions

Key patterns covered:

  • Client and server SDK initialization with typed service classes
  • Auth flows: sign up, sign in, OAuth, magic URL, session management
  • TablesDB operations with Query class for filtering, pagination, ordering
  • Permission model: Permission.read(Role.any()), Role.user(), Role.team()
  • Storage: upload, download, preview with image transforms, bucket permissions
  • Serverless functions: export default async ({ req, res, log, error }) => {}
  • Realtime subscriptions via Channel helpers and realtime.subscribe()

When NOT to use:

  • Direct database connections or SQL queries (Appwrite is API-only, no raw SQL)
  • Complex relational joins across many tables (Appwrite supports relationships but not arbitrary SQL joins)
  • Applications requiring server-side realtime (Appwrite Realtime is client SDK only)

Detailed Resources:

  • For decision frameworks and anti-patterns, see reference.md

Client & Queries:

Authentication:

Storage & Functions:

Realtime:


Philosophy

Appwrite is an open-source backend-as-a-service providing authentication, databases (TablesDB), file storage, serverless functions, and realtime — all through a unified SDK. It can be self-hosted or used via Appwrite Cloud.

Core principles:

  1. Secure by default — Nothing is accessible without explicit permissions. Every row and file must have permissions set, or it is invisible to all users. This is the opposite of "open by default" — treat it as a mandatory step, not an afterthought.
  2. Service class architecture — All SDK interactions go through service classes (Account, TablesDB, Storage, Functions, Teams, Realtime) instantiated from a shared Client. This pattern is consistent across client and server SDKs.
  3. Two SDK splitappwrite (client) uses session-based auth for browsers. node-appwrite (server) uses API key auth for backends. They share the same API shape but different auth mechanisms. Never mix them.
  4. Exceptions, not tuples — Appwrite throws AppwriteException on failure, not { data, error } tuples. Always wrap calls in try/catch.
  5. TablesDB is the future — Appwrite 1.8 introduced TablesDB (tables/rows/columns) as the modern API. The legacy Databases API (collections/documents/attributes) still works but is deprecated. New features only land in TablesDB.
  6. ID generation — Use ID.unique() for server-generated unique IDs. If you pass a string directly, it becomes a custom ID (which must be globally unique within the table).

When to use Appwrite:

  • Open-source, self-hostable BaaS with full control over your data
  • Projects needing auth, database, storage, and functions in one platform
  • Teams wanting a self-hostable BaaS with no vendor lock-in
  • Applications that benefit from granular document/row-level permissions

When NOT to use:

  • Complex SQL-heavy applications requiring joins, views, and stored procedures
  • Server-side realtime consumers (Appwrite Realtime is client SDK only — no server SDK support)
  • Offline-first apps requiring local-first sync (Appwrite has no built-in offline sync)

Core Patterns

Pattern 1: Client SDK Setup (Browser)

Initialize the Appwrite client with endpoint and project ID. All service classes share a single Client instance.

// lib/appwrite.ts
import { Client, Account, TablesDB, Storage, Realtime } from "appwrite";

const APPWRITE_ENDPOINT = process.env.APPWRITE_ENDPOINT!;
const APPWRITE_PROJECT_ID = process.env.APPWRITE_PROJECT_ID!;

const client = new Client()
  .setEndpoint(APPWRITE_ENDPOINT)
  .setProject(APPWRITE_PROJECT_ID);

export const account = new Account(client);
export const tablesDB = new TablesDB(client);
export const storage = new Storage(client);
export const realtime = new Realtime(client);

Why good: Single client instance shared across services, named constants for config, named exports for each service, environment variables keep config out of code

// BAD: Hardcoded config, recreating clients
import { Client, Account } from "appwrite";

const account = new Account(
  new Client()
    .setEndpoint("https://cloud.appwrite.io/v1") // Hardcoded
    .setProject("abc123"), // Hardcoded project ID
);

Why bad: Hardcoded endpoint and project ID, new Client per service wastes resources, no shared config


Pattern 2: Server SDK Setup (Node.js)

The server SDK uses API key authentication — never expose API keys in client code.

// lib/appwrite-server.ts
import { Client, TablesDB, Users, Storage } from "node-appwrite";

const APPWRITE_ENDPOINT = process.env.APPWRITE_ENDPOINT!;
const APPWRITE_PROJECT_ID = process.env.APPWRITE_PROJECT_ID!;
const APPWRITE_API_KEY = process.env.APPWRITE_API_KEY!;

const client = new Client()
  .setEndpoint(APPWRITE_ENDPOINT)
  .setProject(APPWRITE_PROJECT_ID)
  .setKey(APPWRITE_API_KEY);

export const tablesDB = new TablesDB(client);
export const users = new Users(client);
export const storage = new Storage(client);

Why good: .setKey() for API key auth (server only), Users service for admin user management (not available in client SDK), named constants

When to use: API routes, serverless functions, admin scripts, webhooks. NEVER import this module from client-side code.


Pattern 3: Error Handling (AppwriteException)

Appwrite throws AppwriteException — it does NOT return { data, error } tuples. Always use try/catch.

import { AppwriteException, type Models } from "appwrite";

async function getUser(): Promise<Models.User<Models.Preferences>> {
  try {
    return await account.get();
  } catch (error) {
    if (error instanceof AppwriteException) {
      // error.message — human-readable message
      // error.code — HTTP status code (401, 404, etc.)
      // error.type — machine-readable error type string
      throw new Error(`Appwrite error ${error.code}: ${error.message}`);
    }
    throw error;
  }
}

Why good: Type-narrowed AppwriteException provides code, message, type; re-throws unknown errors; explicit return type using Models namespace

// BAD: Assuming { data, error } tuple returns
const { data, error } = await account.get(); // WRONG — Appwrite throws, not returns tuples
if (error) {
  /* ... */
}

Why bad: Appwrite does NOT return error tuples — this code will fail silently because account.get() either returns data or throws


Pattern 4: TablesDB Operations (CRUD)

Use TablesDB for all database operations. Row IDs are required for create — use ID.unique() for auto-generation.

Create a Row
import { ID, Permission, Role } from "appwrite";

interface Todo {
  title: string;
  completed: boolean;
}

const DATABASE_ID = "main";
const TABLE_ID = "todos";

const row = await tablesDB.createRow<Todo>({
  databaseId: DATABASE_ID,
  tableId: TABLE_ID,
  rowId: ID.unique(),
  data: {
    title: "Buy groceries",
    completed: false,
  },
  permissions: [
    Permission.read(Role.user(userId)),
    Permission.update(Role.user(userId)),
    Permission.delete(Role.user(userId)),
  ],
});
List Rows with Queries
import { Query } from "appwrite";

const PAGE_SIZE = 25;

const result = await tablesDB.listRows<Todo>({
  databaseId: DATABASE_ID,
  tableId: TABLE_ID,
  queries: [
    Query.equal("completed", false),
    Query.orderDesc("$createdAt"),
    Query.limit(PAGE_SIZE),
  ],
});

// result.rows — array of Todo rows
// result.total — total matching count
Update a Row
const updated = await tablesDB.updateRow<Todo>({
  databaseId: DATABASE_ID,
  tableId: TABLE_ID,
  rowId: row.$id,
  data: { completed: true },
});
Delete a Row
await tablesDB.deleteRow({
  databaseId: DATABASE_ID,
  tableId: TABLE_ID,
  rowId: row.$id,
});

Why good: Named constants for database/table IDs, ID.unique() for auto-generated IDs, permissions set explicitly on create, Query class for type-safe filtering, generic type parameter for row shape


Pattern 5: Permissions Model

Appwrite grants NO access by default. You must set permissions on every row and file.

Permission + Role Syntax
import { Permission, Role } from "appwrite";

// Public read, owner full access
const publicReadPermissions = [
  Permission.read(Role.any()),
  Permission.update(Role.user(userId)),
  Permission.delete(Role.user(userId)),
];

// Team access
const teamPermissions = [
  Permission.read(Role.team(teamId)),
  Permission.update(Role.team(teamId, "admin")),
  Permission.delete(Role.team(teamId, "owner")),
];

// Verified users only
const verifiedOnlyPermissions = [Permission.read(Role.users("verified"))];
Common Role Helpers
Role.any(); // Anyone (including guests)
Role.guests(); // Unauthenticated users only
Role.users(); // All authenticated users
Role.users("verified"); // Only verified users
Role.user(userId); // Specific user by ID
Role.team(teamId); // All members of a team
Role.team(teamId, "admin"); // Team members with "admin" role
Role.member(membershipId); // Specific team membership
Role.label("premium"); // Users with "premium" label

Why good: Explicit permissions on every resource, role helpers are type-safe, team roles enable granular access

// BAD: Creating a row without permissions
const row = await tablesDB.createRow({
  databaseId: DATABASE_ID,
  tableId: TABLE_ID,
  rowId: ID.unique(),
  data: { title: "Secret" },
  // NO permissions set — this row is INVISIBLE to everyone!
});

Why bad: Without permissions, the row exists in the database but NO user can read, update, or delete it (not even the creator) — this is the most common Appwrite mistake


Pattern 6: Authentication Flows

Use the Account service for all auth operations.

Email/Password Sign Up and Sign In
import { ID, type Models } from "appwrite";

// Sign up: create account + create session (two steps)
const user = await account.create({
  userId: ID.unique(),
  email,
  password,
  name,
});
const session = await account.createEmailPasswordSession({ email, password });

// Sign in
const session = await account.createEmailPasswordSession({ email, password });

// Sign out (current device)
await account.deleteSession({ sessionId: "current" });

Why good: Two-step sign up (create account + create session), object parameters for all SDK calls, deleteSession({ sessionId: "current" }) for current device only

See examples/auth.md for full auth flows including OAuth, magic URL, anonymous sessions, teams, and password reset.


Pattern 7: Storage Operations

Upload, download, and preview files with the Storage service.

import { ID, Permission, Role } from "appwrite";

const BUCKET_ID = "user-uploads";
const PREVIEW_WIDTH = 400;
const PREVIEW_HEIGHT = 300;

// Upload with permissions
await storage.createFile({
  bucketId: BUCKET_ID,
  fileId: ID.unique(),
  file,
  permissions: [
    Permission.read(Role.user(userId)),
    Permission.update(Role.user(userId)),
  ],
});

// Image preview with transforms
const url = storage.getFilePreview({
  bucketId: BUCKET_ID,
  fileId,
  width: PREVIEW_WIDTH,
  height: PREVIEW_HEIGHT,
});

// Download URL
const downloadUrl = storage.getFileDownload({ bucketId: BUCKET_ID, fileId });

Why good: Object parameters for all SDK calls, named constants, ID.unique() for file IDs, permissions set on upload

See examples/storage.md for full upload, preview, download, and file management patterns.


Pattern 8: Realtime Subscriptions

Subscribe to changes using the Realtime service with Channel helpers.

import { Realtime, Channel } from "appwrite";

const DATABASE_ID = "main";
const TABLE_ID = "messages";

// Subscribe to all changes on a table
const subscription = await realtime.subscribe(
  Channel.tablesdb(DATABASE_ID).table(TABLE_ID),
  (response) => {
    if (response.events.includes("tablesdb.*.tables.*.rows.*.create")) {
      console.log("New row:", response.payload);
    }
    if (response.events.includes("tablesdb.*.tables.*.rows.*.update")) {
      console.log("Updated row:", response.payload);
    }
    if (response.events.includes("tablesdb.*.tables.*.rows.*.delete")) {
      console.log("Deleted row:", response.payload);
    }
  },
);

// IMPORTANT: Always unsubscribe when done
await subscription.close();
Subscribe to Files
const fileSubscription = await realtime.subscribe(
  Channel.files(),
  (response) => {
    if (response.events.includes("buckets.*.files.*.create")) {
      console.log("New file uploaded:", response.payload);
    }
  },
);
Subscribe to Account Changes
const accountSub = await realtime.subscribe(Channel.account(), (response) => {
  console.log("Account event:", response.events);
});

Why good: Channel helpers provide type-safe channel construction, event string matching for filtering, explicit cleanup with subscription.close(), separate subscriptions per concern

When to use: Chat apps, live dashboards, collaborative editing, notification feeds. Avoid for high-frequency data streams.


<decision_framework>

Decision Framework

Client SDK vs Server SDK

Where is the code running?
+-  Browser / Client-side -> `appwrite` package, session auth
+-  Server / API route -> `node-appwrite` package, API key auth
+-  Serverless function -> `node-appwrite` package, dynamic key from `req.headers['x-appwrite-key']`
    +-- NEVER expose API keys in client bundles

TablesDB vs Legacy Databases

Which API should I use?
+-  New project -> TablesDB (tables, rows, columns)
+-  Existing project with collections -> Legacy Databases still works (deprecated, security patches only)
+-  Migrating -> Adopt TablesDB for new tables, migrate existing gradually

Auth Method Selection

What auth flow does the user need?
+-  Email + Password -> account.create({ ... }) + account.createEmailPasswordSession({ ... })
+-  Social login (GitHub, Google, etc.) -> account.createOAuth2Session({ provider: OAuthProvider.Github, ... })
+-  Passwordless email -> account.createMagicURLToken({ ... })
+-  Phone + SMS -> account.createPhoneToken({ ... })
+-  Guest / anonymous -> account.createAnonymousSession()
+-  Existing JWT -> client.setJWT()

Permissions Strategy

Who should access this resource?
+-  Public (anyone) -> Permission.read(Role.any())
+-  Owner only -> Permission.read(Role.user(userId))
+-  Team members -> Permission.read(Role.team(teamId))
+-  Team role (e.g. admins) -> Permission.read(Role.team(teamId, "admin"))
+-  Verified users -> Permission.read(Role.users("verified"))
+-  Labeled users -> Permission.read(Role.label("premium"))

Storage: Public vs Private

Who should see the files?
+-  Anyone (public assets) -> Set Permission.read(Role.any()) on the file
+-  Owner only (private docs) -> Set Permission.read(Role.user(userId))
+-  Team access -> Set Permission.read(Role.team(teamId))
+-  Server-only -> Use server SDK with API key (bypasses all permissions)

</decision_framework>


<red_flags>

RED FLAGS

High Priority Issues:

  • No permissions on rows/files — Appwrite grants ZERO access by default. A row without permissions is completely invisible. This is the number one mistake new Appwrite developers make.
  • API key in client bundle — The node-appwrite server SDK with API key auth must NEVER be imported in browser code. API keys bypass all permissions.
  • Assuming { data, error } return pattern — Appwrite throws AppwriteException, not error tuples. Using destructuring like const { data, error } = await account.get() will fail silently.
  • Using legacy Databases API for new projectsDatabases (collections/documents) is deprecated as of Appwrite 1.8. Use TablesDB (tables/rows) for all new work.
  • Using positional parameters — As of appwrite@19.0.0 / node-appwrite@18.0.0 (September 2025), all SDK methods use object parameters. Positional arguments are deprecated. Use account.create({ userId, email, password }) not account.create(userId, email, password).
  • Using string literals for OAuth providers — Use the OAuthProvider enum: OAuthProvider.Github not "github".

Medium Priority Issues:

  • Forgetting ID.unique() — Passing a raw string as a row/file ID creates a custom ID. If you want auto-generated IDs, you must explicitly call ID.unique().
  • Not unsubscribing from Realtime — Each realtime.subscribe() adds to a shared WebSocket. Failing to subscription.close() leaks connections and causes duplicate events.
  • Using Permission.write() when you mean specific actionsPermission.write() is an alias for create + update + delete combined. Use Permission.update() and Permission.delete() separately for granular control.
  • Calling account.create() without createEmailPasswordSession()create() registers the user but does NOT create a session. The user is not logged in until you call a session method.
  • Server SDK Realtime — Appwrite Realtime is NOT available through server SDKs. Subscriptions only work with client SDKs.

Common Mistakes:

  • OAuth: no code runs after createOAuth2Session() — This method triggers a browser redirect. Any code after the call will not execute.
  • Not setting both success AND failure URLs for OAuth — If you omit the failure URL, failed auth has no redirect target.
  • Using Role.users() when you mean Role.user(userId)Role.users() grants access to ALL authenticated users. Role.user(userId) grants access to ONE specific user.
  • Expecting $createdAt / $updatedAt in queries without $ prefix — System fields in Appwrite use $ prefix: $id, $createdAt, $updatedAt, $permissions.

Gotchas & Edge Cases:

  • Row security vs table-level permissions — By default, only table-level permissions apply. To use row-level (document-level) permissions, you must enable "Row Security" in the table settings. When enabled, BOTH table-level AND row-level permissions must pass.
  • Permission.read(Role.any()) includes guestsRole.any() means everyone, including unauthenticated users. Use Role.users() for authenticated-only access.
  • Realtime reconnection creates new WebSocket — Adding or removing a subscription tears down and recreates the entire WebSocket connection. Batch subscription changes where possible.
  • File preview only works for imagesstorage.getFilePreview() with width/height transforms only works on image files. For non-images, use getFileDownload() or getFileView().
  • account.get() throws if no session — There is no "null user" return. An unauthenticated call throws 401. Wrap in try/catch and return null for "no user" state.
  • Max 100 rows per listRows() call — The default and maximum limit is 100. For larger datasets, use cursor-based pagination with Query.cursorAfter().
  • Appwrite function cold starts — Functions have cold start latency on first invocation after idle. Design "fat functions" (one function handling multiple routes) to reduce cold starts.
  • Query.search() requires a full-text index — The search query method only works on columns that have a full-text index configured in the Appwrite console.
  • Labels are server-only — User labels (Role.label("premium")) can only be set via the server SDK users.updateLabels(). Clients cannot modify their own labels.

</red_flags>


<critical_reminders>

CRITICAL REMINDERS

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST set permissions explicitly on every row and file — Appwrite grants NO access by default, so omitting permissions makes data inaccessible)

(You MUST use node-appwrite with API key auth on the server and appwrite with session auth on the client — NEVER expose API keys in client bundles)

(You MUST always check for AppwriteException on every SDK call — Appwrite throws exceptions, it does NOT return { data, error } tuples)

(You MUST use ID.unique() for auto-generated IDs — passing a raw string creates a custom ID, not an auto-generated one)

(You MUST use the TablesDB API for new projects — the legacy Databases/collections/documents API is deprecated and receives only security patches)

Failure to follow these rules will create inaccessible data, security vulnerabilities, and silent runtime failures.

</critical_reminders>

Signals

GitHub stars
24
Forks
8
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
api-baas-appwrite
Source
github.com/agents-inc/skills