A full-stack Next.js todo application that demonstrates authentication, CRUD operations, and full-text search β all powered by a self-hosted Appwrite instance that uses MongoDB as its database engine.
Browser (Next.js)
β
β Appwrite Web SDK (REST/WebSocket)
βΌ
βββββββββββββββββββββββ
β Appwrite Server β β self-hosted on Docker
β (Auth Β· Databases) β
ββββββββββ¬βββββββββββββ
β native driver
βΌ
ββββββββββββββββ
β MongoDB β β stores every document, index & user record
ββββββββββββββββ
Appwrite's Databases service maps directly onto MongoDB collections. Every todo you create, update, or delete is a MongoDB document. The full-text index on the title field is a MongoDB text index created by Appwrite under the hood.
Before cloning this project you must have a running, self-hosted Appwrite instance backed by MongoDB.
Follow the official guide:
π Self-Hosting Appwrite with MongoDBThe guide walks you through spinning up Appwrite via Docker Compose and provision a self hosted MongoDB system.
Once Appwrite is running you will need:
| Value | Where to find it |
|---|---|
| Appwrite Endpoint | The URL where Appwrite is reachable, e.g. http://localhost/v1 |
| Project ID | Appwrite Console β your project β Settings |
| API Key | Appwrite Console β project β Settings β API Keys (scopes: databases.*, collections.*, attributes.*) |
git clone https://github.com/mongodb-developer/appwrite-nextjs-selfhosted-app.git
cd appwrite-nextjs-selfhosted-appcp .env.example .envEdit .env and fill in your values:
NEXT_PUBLIC_APPWRITE_ENDPOINT=http://localhost/v1 # your Appwrite endpoint
NEXT_PUBLIC_APPWRITE_PROJECT_ID=<your-project-id>setup-appwrite.mjs uses node-appwrite (listed under devDependencies), so install everything first:
npm installThe setup-appwrite.mjs script creates the database, collection, attributes, and a full-text index on the title field:
APPWRITE_API_KEY=<your-api-key> node setup-appwrite.mjsExpected output:
β
Database 'todos-db' created.
β
Collection 'todos' created.
β
Attribute 'title' created.
β
Attribute 'completed' created.
β
Attribute 'userId' created.
β
Full-text index 'title_fulltext' created on 'title'.
π Setup complete! Database ID: todos-db | Collection ID: todos
The script is idempotent β safe to run again if something was already created.
npm run devOpen http://localhost:3000 in your browser.
All data operations use the Appwrite Web SDK (appwrite npm package) against the todos-db database and todos collection.
// src/app/todos/page.js β addTodo()
const doc = await databases.createDocument(
DATABASE_ID, // "todos-db"
COLLECTION_ID, // "todos"
ID.unique(), // auto-generated MongoDB ObjectId-style ID
{ title, completed: false, userId: user.$id },
[
Permission.read(Role.user(user.$id)),
Permission.update(Role.user(user.$id)),
Permission.delete(Role.user(user.$id)),
]
);Each document is created with document-level permissions so only the owner can read, update, or delete it β even though the collection is readable by any authenticated user.
// src/app/todos/page.js β fetchTodos()
const res = await databases.listDocuments(DATABASE_ID, COLLECTION_ID, [
Query.equal("userId", user.$id), // only this user's todos
Query.orderDesc("$createdAt"), // newest first
]);Appwrite translates the Query helpers into MongoDB find filter documents before hitting the collection.
// src/app/todos/page.js β toggleTodo()
const updated = await databases.updateDocument(
DATABASE_ID,
COLLECTION_ID,
todo.$id,
{ completed: !todo.completed } // partial update (MongoDB $set)
);Only the completed field is changed β Appwrite performs a targeted $set rather than a full document replacement.
// src/app/todos/page.js β deleteTodo()
await databases.deleteDocument(DATABASE_ID, COLLECTION_ID, id);A "Clear completed" bulk-delete button in the footer calls deleteTodo in parallel for all checked items.
The setup script creates a MongoDB full-text index (type: "fulltext") on the title field:
// setup-appwrite.mjs
await databases.createIndex(
DATABASE_ID, COLLECTION_ID,
"title_fulltext", // index name
"fulltext", // maps to a MongoDB text index
["title"]
);The app uses Query.search() to leverage that index:
// src/app/todos/page.js β fetchTodos()
if (searchTerm.length >= 3) {
queries.push(Query.search("title", searchTerm));
}Key behaviours:
| Behaviour | Detail |
|---|---|
| Minimum 3 characters | Prevents excessive queries on short input |
| Debounced 300 ms | setTimeout clears previous timer on each keystroke |
| Combined with user filter | Query.equal("userId", ...) is always applied, so users only ever see their own results |
| Clear button | Resets both searchInput and searchTerm state, falling back to the full list |
starter-for-nextjs/
βββ src/
β βββ app/
β β βββ page.js # Auth page (sign-in / register)
β β βββ todos/
β β β βββ page.js # Todos page (CRUD + search)
β β βββ app.css # Tailwind + custom styles
β βββ lib/
β βββ appwrite.js # Appwrite client singleton
βββ setup-appwrite.mjs # One-time backend scaffold script
βββ .env.example # Environment variable template
βββ package.json