Claude Code + Appwrite: A Practitioner's Guide
Why Appwrite needs explicit CLAUDE.md constraints
Appwrite is an open-source backend-as-a-service. It gives you authentication, a document database, file storage, serverless functions, and realtime subscriptions behind a single API, either self-hosted or on Appwrite Cloud. The developer experience is good once you know the rules, and the rules are exactly what Claude Code does not know without being told.
The central trap is that Appwrite ships two SDKs that look almost identical and are not interchangeable. The server SDK (node-appwrite) runs in trusted environments (your backend, serverless functions, scripts) and authenticates with an API key that grants broad access. The web SDK (appwrite) runs in the browser and authenticates as the logged-in user through a session, never with an API key. They share class names like Client, Account, and Databases, so code written for one imports cleanly into the other and then fails at runtime or, worse, leaks your API key into client-side JavaScript.
Without explicit instructions, Claude Code mixes them. It imports node-appwrite into a React component, or sets an API key on a browser client, or uses setKey() where it should use a session. It also reaches for older API shapes (raw filter objects instead of the Query builder, deprecated class names) because its training data spans several Appwrite versions. The result compiles and then misbehaves.
This is not an edge case. It is the default output of a competent session asked to "add an Appwrite backend." The CLAUDE.md file is what keeps the server and web sides separate and the API key out of the browser.
This guide covers the CLAUDE.md configuration, choosing the right SDK per runtime, client setup, authentication, database queries with the Query builder, permissions, and the six most common mistakes Claude makes. For the alternative open-source backend, Claude Code with Supabase covers the same ground for Postgres-based projects.
The Appwrite CLAUDE.md template
Place this at your project root. Every Claude Code session reads it before generating any code.
# Appwrite rules
## Two SDKs (CRITICAL: do not mix)
- SERVER SDK: node-appwrite -> backend, serverless functions, scripts (trusted)
- authenticates with an API key via client.setKey(process.env.APPWRITE_API_KEY)
- NEVER import node-appwrite into browser/client code
- WEB SDK: appwrite -> browser, React/Vue/Svelte components (untrusted)
- authenticates as the logged-in user via a session, NEVER with an API key
- NEVER call setKey() on a web client
- Choose the SDK by where the code RUNS, not by what is convenient
## Endpoint and project (both SDKs)
- client.setEndpoint(process.env.APPWRITE_ENDPOINT) // e.g. https://cloud.appwrite.io/v1
- client.setProject(process.env.APPWRITE_PROJECT_ID)
- NEVER hardcode endpoint or project ID; read from environment
## Server client (node-appwrite)
- import { Client, Databases, Users, Account } from 'node-appwrite'
- const client = new Client().setEndpoint(...).setProject(...).setKey(API_KEY)
- API key stays server-side only; NEVER ship it to the browser
## Web client (appwrite)
- import { Client, Account, Databases } from 'appwrite'
- const client = new Client().setEndpoint(...).setProject(...)
- NO setKey(). The user's session is the auth.
- Endpoint + project ID are public and safe to expose; the API key is NOT
## Database queries (MANDATORY: use the Query builder)
- import { Query } from 'appwrite' (or 'node-appwrite' on the server)
- databases.listDocuments(dbId, collectionId, [Query.equal('status', 'active'), Query.limit(25)])
- NEVER pass raw filter objects; use Query.equal/notEqual/lessThan/greaterThan/search/orderDesc/limit/offset
- Query is an array of builder calls, always
## IDs and permissions
- import { ID, Permission, Role } from the SDK in use
- New document ID: ID.unique() (NEVER invent your own random string)
- Permissions: [Permission.read(Role.any()), Permission.write(Role.user(userId))]
- Set permissions explicitly on create; do not rely on collection defaults silently
## Hard rules
- NEVER put an API key in client-side code or a public env var
- NEVER import node-appwrite in a component that runs in the browser
- NEVER call setKey() on the web SDK
- ALWAYS use the Query builder for filters, never raw objects
- ALWAYS use ID.unique() for new document IDs
- ALWAYS set document permissions explicitly on create
Three rules prevent the majority of breakage and the one genuine security hole.
The two-SDK rule is the most important. The server and web SDKs share class names, so without an explicit instruction Claude treats them as one library and picks whichever it saw last. The fix is to make the choice depend on where the code runs (server vs browser) rather than on convenience, and to forbid node-appwrite in browser code outright.
The API-key rule is the security one. The server SDK authenticates with an API key that can read and write across your whole project. If Claude puts that key on a web client or in a NEXT_PUBLIC_ environment variable, it ships to every visitor's browser and your backend is wide open. Stating plainly that the key is server-side only, and that the web SDK uses sessions instead, closes the hole.
The Query builder rule matters because Appwrite's filtering API is a builder (Query.equal('status', 'active')), not a raw filter object. Claude's instinct is to pass a plain object like { status: 'active' }, which Appwrite does not accept. Showing the builder calls once redirects every generated query to the correct form.
Choosing the SDK for your runtime
The single decision that determines everything else is which SDK a given file uses, and the rule is mechanical: pick by where the code executes.
Server SDK (node-appwrite) for any code that runs in a trusted environment you control: a Node.js backend, a Next.js route handler or server action, an Appwrite serverless function, a migration or seed script, a cron job. These can hold the API key safely and need its broad access.
Web SDK (appwrite) for any code that runs in the user's browser: React, Vue, or Svelte components, client-side hooks, anything in a "use client" boundary. These authenticate as the logged-in user through a session and must never see the API key.
The trap in frameworks like Next.js is that server and client code live in the same project and sometimes the same directory. A component marked "use client" runs in the browser even though it sits next to server code. Claude needs the CLAUDE.md rule to keep the import correct on each side. For the full Next.js request lifecycle these calls sit inside, Claude Code with Next.js covers the server/client boundary in depth.
Install and client setup
Install the SDK that matches the runtime. Most full-stack projects need both, in different files:
npm i node-appwrite # server-side
npm i appwrite # client-side (browser)
Set the configuration in your environment. Note which values are safe to expose:
# .env (server-only secrets)
APPWRITE_API_KEY=standard_... # SERVER ONLY, never NEXT_PUBLIC_
# .env (public values, safe in the browser)
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
NEXT_PUBLIC_APPWRITE_PROJECT_ID=your-project-id
The server client, with the API key:
// src/lib/appwrite-server.ts
import { Client, Databases, Users } from 'node-appwrite';
const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!)
.setKey(process.env.APPWRITE_API_KEY!);
export const databases = new Databases(client);
export const users = new Users(client);
The web client, with no API key:
// src/lib/appwrite-client.ts
import { Client, Account, Databases } from 'appwrite';
const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!);
export const account = new Account(client);
export const databases = new Databases(client);
The two files look similar, which is the whole problem. The only structural difference is .setKey(...) on the server and its absence on the web, plus the import source. Keep them as two clearly-named files (appwrite-server.ts and appwrite-client.ts) so Claude imports the right one, and put that convention in CLAUDE.md.
Authentication
Auth is a web-SDK job, because it runs as the user. The web SDK creates and reads sessions in the browser.
// src/lib/auth.ts ("use client" context)
import { account } from '@/lib/appwrite-client';
import { ID } from 'appwrite';
export async function signUp(email: string, password: string, name: string) {
await account.create(ID.unique(), email, password, name);
// Immediately create a session so the user is logged in after sign-up
return account.createEmailPasswordSession(email, password);
}
export async function signIn(email: string, password: string) {
return account.createEmailPasswordSession(email, password);
}
export async function signOut() {
return account.deleteSession('current');
}
export async function getCurrentUser() {
try {
return await account.get();
} catch {
return null; // No active session
}
}
Two details to encode in CLAUDE.md. First, account.create(...) only registers the user; it does not log them in, so follow it with createEmailPasswordSession if you want them signed in immediately. Second, account.get() throws when there is no session rather than returning null, so wrap it in try/catch and return null yourself. Claude frequently forgets both and produces a sign-up flow that leaves the user logged out, or a getCurrentUser that crashes for guests.
Database operations with the Query builder
The database is documents inside collections inside databases. Reads and writes work from both SDKs, but the filtering API is the part Claude gets wrong: it is a builder, not a plain object.
// src/lib/posts.ts (server-side example)
import { databases } from '@/lib/appwrite-server';
import { ID, Query, Permission, Role } from 'node-appwrite';
const DB_ID = 'main';
const POSTS = 'posts';
export async function createPost(authorId: string, title: string, body: string) {
return databases.createDocument(
DB_ID,
POSTS,
ID.unique(),
{ title, body, authorId, status: 'draft' },
[
Permission.read(Role.any()),
Permission.update(Role.user(authorId)),
Permission.delete(Role.user(authorId)),
]
);
}
export async function listPublishedPosts(limit = 25, cursor?: string) {
const queries = [
Query.equal('status', 'published'),
Query.orderDesc('$createdAt'),
Query.limit(limit),
];
if (cursor) queries.push(Query.cursorAfter(cursor));
return databases.listDocuments(DB_ID, POSTS, queries);
}
export async function searchPosts(term: string) {
return databases.listDocuments(DB_ID, POSTS, [
Query.search('title', term),
Query.limit(20),
]);
}
Three patterns to standardise in CLAUDE.md. First, every new document uses ID.unique() so Appwrite assigns a valid ID; never invent your own string. Second, every filter is a Query.* builder call inside the queries array; never a raw object. Third, pagination uses Query.cursorAfter(cursor) with a document ID, which is more reliable at scale than offset paging. The system fields ($createdAt, $id, $updatedAt) are prefixed with $, which Claude often forgets when ordering or filtering by them.
Permissions and the document-level model
Appwrite's permission model is document-level: each document carries its own read/write/update/delete permissions, expressed as roles. This is powerful and easy to get subtly wrong, because if you do not set permissions on create, the document inherits collection defaults that may be more or less open than you intend.
import { Permission, Role } from 'node-appwrite';
// Public read, owner-only writes
const permissions = [
Permission.read(Role.any()),
Permission.update(Role.user(authorId)),
Permission.delete(Role.user(authorId)),
];
// Private to a single user (read and write)
const privatePermissions = [
Permission.read(Role.user(userId)),
Permission.write(Role.user(userId)),
];
// Readable by any logged-in user, writable by a team
const teamPermissions = [
Permission.read(Role.users()),
Permission.write(Role.team('editors')),
];
The rule for CLAUDE.md: set permissions explicitly on every createDocument call rather than relying on collection defaults silently. Role.any() is everyone including anonymous visitors; Role.users() is any authenticated user; Role.user(id) is one specific user; Role.team(id) is a team. Mixing these up is how data ends up either inaccessible or publicly writable, so make the choice deliberate in the code, not implicit in the collection config.
Permission hooks for Appwrite scripts
An Appwrite project accumulates server scripts: seed data, migrations that create collections and attributes, cleanup scripts that delete documents in bulk. Some are safe; some destroy data. Permission rules in .claude/settings.local.json keep the dangerous ones from running unattended.
{
"permissions": {
"allow": [
"Bash(npx tsx scripts/seed.ts*)",
"Bash(npx tsx scripts/setup-collections.ts*)",
"Bash(npx tsx scripts/count-documents.ts*)"
],
"deny": [
"Bash(npx tsx scripts/wipe-collection.ts*)",
"Bash(npx tsx scripts/delete-all-users.ts*)"
]
}
}
Seed and setup scripts are safe to auto-run. Scripts that wipe collections or delete users should surface as a prompt during a "reset the database" task rather than execute silently. The Claude Code setup guide covers the permission model in full.
Common Appwrite mistakes Claude makes by default
Six patterns Claude generates without CLAUDE.md constraints, each with the correct replacement.
1. Importing the server SDK into browser code
Claude generates: import { Client } from 'node-appwrite' inside a React component.
Correct: use import { Client } from 'appwrite' in browser code. The node-appwrite SDK is for trusted server environments only and pulls in Node APIs that do not exist in the browser.
2. Putting the API key on a web client (the security hole)
Claude generates: new Client().setProject(id).setKey(process.env.NEXT_PUBLIC_APPWRITE_API_KEY) in client code.
Correct: the web SDK never calls setKey(). It authenticates with the user's session. The API key is server-side only and must never be in a NEXT_PUBLIC_ variable.
3. Passing raw filter objects instead of the Query builder
Claude generates: databases.listDocuments(db, col, { status: 'active' }).
Correct: databases.listDocuments(db, col, [Query.equal('status', 'active')]). Filters are an array of Query.* builder calls.
4. Inventing document IDs
Claude generates: createDocument(db, col, crypto.randomUUID(), data) or a hand-rolled string.
Correct: createDocument(db, col, ID.unique(), data). ID.unique() produces an ID in the format Appwrite expects.
5. Treating account.create as login
Claude generates: a sign-up that calls account.create(...) and assumes the user is now logged in.
Correct: account.create(...) only registers the account. Follow it with account.createEmailPasswordSession(email, password) to start a session.
6. Forgetting that account.get() throws for guests
Claude generates: const user = await account.get() with no error handling, which crashes when no one is logged in.
Correct: wrap it in try/catch and return null on failure, because Appwrite throws rather than returning null for an absent session.
Add all six pairs to CLAUDE.md as a common mistakes section. The before/after contrast lets Claude match the pattern it would otherwise generate to the correct form.
Building a safe Appwrite backend
The Appwrite CLAUDE.md in this guide produces code that picks the server or web SDK by where it runs, keeps the API key strictly server-side, uses the Query builder for every filter, generates document IDs with ID.unique(), sets explicit document-level permissions, and handles the auth quirks (create is not login, get throws for guests) correctly.
The underlying principle is the same for any backend-as-a-service with a trusted and an untrusted half: the two SDKs share names but not safety properties, and Claude will conflate them without an explicit boundary. The CLAUDE.md template draws that boundary, turning Appwrite from a service where Claude leaks API keys and passes the wrong filter shape into one it integrates safely by default.
For a Postgres-based alternative with a similar server/client split, Claude Code with Supabase covers the equivalent setup. To wire Appwrite into the wider toolchain so the agent can act across systems, Claude Code MCP servers shows how to connect external services as agent tools.
Get Claudify. The Appwrite CLAUDE.md template, server and web client setup, and permission rules are included, ready to drop into any project.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify