Build a Full-Stack App with Appwrite Cloud and Next.js 15

Appwrite is a powerful open-source backend-as-a-service (BaaS) that provides authentication, databases, file storage, cloud functions, and real-time capabilities out of the box. Unlike proprietary alternatives, Appwrite gives you full control — you can self-host it or use Appwrite Cloud for a managed experience. Combined with Next.js 15 and the App Router, you get a modern, type-safe full-stack setup that handles both server-side rendering and client-side interactivity.
In this tutorial, you will build a bookmark manager — a full-stack app where users can save, organize, tag, and share bookmarks. You will implement authentication with OAuth, a document database for storing bookmarks, file storage for screenshots, and real-time updates when bookmarks are added or modified.
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed
- An Appwrite Cloud account — free tier available at cloud.appwrite.io
- Basic knowledge of React and TypeScript
- Familiarity with Next.js App Router (pages, layouts, server components)
- A code editor (VS Code recommended)
What You Will Build
A bookmark manager application with these features:
- OAuth authentication (Google, GitHub) with session management
- Document database for storing bookmarks with collections and attributes
- File storage for bookmark thumbnails and favicons
- Real-time subscriptions for live updates across tabs
- Server Components for SEO-friendly initial data loading
- Server Actions for secure mutations
- Tag-based organization with filtering and search
Step 1: Create an Appwrite Cloud Project
Head to cloud.appwrite.io and create a new project:
- Click Create Project
- Enter a name like "Bookmark Manager"
- Select your preferred region
- Once created, note down your Project ID from the project settings
Next, configure your platform. Go to Overview and add a Web platform:
- Name: Bookmark Manager Web
- Hostname:
localhost(for development)
This registers your web app and allows it to communicate with Appwrite APIs.
Step 2: Set Up the Next.js Project
Create a new Next.js 15 application with TypeScript and Tailwind CSS:
npx create-next-app@latest bookmark-manager --typescript --tailwind --app --src-dir --eslint
cd bookmark-managerInstall the Appwrite SDK:
npm install appwrite node-appwriteThe appwrite package is for client-side usage, while node-appwrite is the server-side SDK for use in Server Components and Server Actions.
Step 3: Configure Environment Variables
Create a .env.local file at the project root:
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
NEXT_PUBLIC_APPWRITE_PROJECT_ID=your-project-id
APPWRITE_API_KEY=your-api-keyThe NEXT_PUBLIC_ prefix makes variables accessible on the client side. The APPWRITE_API_KEY is server-only and should never be exposed to the browser.
To generate an API key, go to your Appwrite Console, navigate to Overview > API Keys, and create a key with the following scopes:
databases.read,databases.writecollections.read,collections.writedocuments.read,documents.writefiles.read,files.writeusers.read
Step 4: Create Appwrite SDK Configurations
Create two SDK configurations — one for the client and one for the server.
Client SDK
// src/lib/appwrite/client.ts
import { Client, Account, Databases, Storage } 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);
export const storage = new Storage(client);
export { client };Server SDK
// src/lib/appwrite/server.ts
import { Client, Databases, Storage, Users } from "node-appwrite";
export function createAdminClient() {
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!);
return {
databases: new Databases(client),
storage: new Storage(client),
users: new Users(client),
};
}
export function createSessionClient(session: string) {
const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!)
.setSession(session);
return {
account: new Account(client),
databases: new Databases(client),
};
}The createAdminClient uses an API key for full access, while createSessionClient uses the user session for authenticated requests that respect permissions.
Step 5: Set Up the Database Schema
In the Appwrite Console, navigate to Databases and create a new database called bookmark_db. Then create the following collections:
Bookmarks Collection
Create a collection named bookmarks with these attributes:
| Attribute | Type | Required | Description |
|---|---|---|---|
url | String (2048) | Yes | The bookmark URL |
title | String (256) | Yes | Page title |
description | String (1024) | No | Page description |
tags | String[] (50) | No | Array of tags |
thumbnailId | String (36) | No | Storage file ID for screenshot |
favicon | String (2048) | No | Favicon URL |
userId | String (36) | Yes | Owner user ID |
isPublic | Boolean | Yes | Whether bookmark is publicly visible |
createdAt | DateTime | Yes | Creation timestamp |
Now create indexes for efficient querying:
- Index on
userId— type: Key - Index on
tags— type: Key (array index) - Index on
createdAt— type: Key, order: DESC
Collection Permissions
Set permissions on the bookmarks collection:
- Any — Read (for public bookmarks)
- Users — Create, Read, Update, Delete
We will add document-level permissions to control access per bookmark.
Let us define constants for database and collection IDs:
// src/lib/appwrite/config.ts
export const DATABASE_ID = "bookmark_db";
export const BOOKMARKS_COLLECTION_ID = "bookmarks";
export const THUMBNAILS_BUCKET_ID = "thumbnails";Step 6: Implement Authentication
Appwrite supports email/password, OAuth, phone, and magic link authentication. Let us implement Google and GitHub OAuth.
Configure OAuth Providers
In the Appwrite Console, go to Auth > Settings and enable:
- Google — Add your Google Cloud OAuth 2.0 client ID and secret
- GitHub — Add your GitHub OAuth App client ID and secret
Set the redirect URL to http://localhost:3000/auth/callback for both providers.
Auth Context
// src/lib/auth/context.tsx
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { account } from "@/lib/appwrite/client";
import { Models } from "appwrite";
type AuthContextType = {
user: Models.User<Models.Preferences> | null;
loading: boolean;
login: (provider: "google" | "github") => void;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<Models.User<Models.Preferences> | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
checkUser();
}, []);
async function checkUser() {
try {
const currentUser = await account.get();
setUser(currentUser);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}
function login(provider: "google" | "github") {
const redirectUrl = `${window.location.origin}/auth/callback`;
const failureUrl = `${window.location.origin}/auth/failure`;
account.createOAuth2Session(provider, redirectUrl, failureUrl);
}
async function logout() {
await account.deleteSession("current");
setUser(null);
}
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error("useAuth must be used within AuthProvider");
return context;
}Auth Callback Page
// src/app/auth/callback/page.tsx
import { redirect } from "next/navigation";
export default function AuthCallback() {
redirect("/dashboard");
}Login Page
// src/app/login/page.tsx
"use client";
import { useAuth } from "@/lib/auth/context";
export default function LoginPage() {
const { login, loading, user } = useAuth();
if (loading) return <div className="flex justify-center p-8">Loading...</div>;
if (user) return redirect("/dashboard");
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900">Bookmark Manager</h1>
<p className="mt-2 text-gray-600">Sign in to manage your bookmarks</p>
</div>
<div className="space-y-4">
<button
onClick={() => login("google")}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
<GoogleIcon />
<span>Continue with Google</span>
</button>
<button
onClick={() => login("github")}
className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition"
>
<GitHubIcon />
<span>Continue with GitHub</span>
</button>
</div>
</div>
</div>
);
}
function GoogleIcon() {
return (
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
);
}
function GitHubIcon() {
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
);
}Step 7: Build the Bookmark CRUD Operations
Create a service layer for bookmark operations using Server Actions:
// src/lib/actions/bookmarks.ts
"use server";
import { createAdminClient } from "@/lib/appwrite/server";
import { DATABASE_ID, BOOKMARKS_COLLECTION_ID } from "@/lib/appwrite/config";
import { ID, Query } from "node-appwrite";
import { revalidatePath } from "next/cache";
export type Bookmark = {
$id: string;
url: string;
title: string;
description: string;
tags: string[];
thumbnailId: string | null;
favicon: string;
userId: string;
isPublic: boolean;
createdAt: string;
};
export async function createBookmark(formData: FormData) {
const { databases } = createAdminClient();
const url = formData.get("url") as string;
const title = formData.get("title") as string;
const description = formData.get("description") as string;
const tags = (formData.get("tags") as string)
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const userId = formData.get("userId") as string;
const isPublic = formData.get("isPublic") === "true";
const bookmark = await databases.createDocument(
DATABASE_ID,
BOOKMARKS_COLLECTION_ID,
ID.unique(),
{
url,
title,
description,
tags,
userId,
isPublic,
favicon: `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=32`,
createdAt: new Date().toISOString(),
}
);
revalidatePath("/dashboard");
return bookmark;
}
export async function getBookmarks(userId: string) {
const { databases } = createAdminClient();
const response = await databases.listDocuments(
DATABASE_ID,
BOOKMARKS_COLLECTION_ID,
[
Query.equal("userId", userId),
Query.orderDesc("createdAt"),
Query.limit(50),
]
);
return response.documents as unknown as Bookmark[];
}
export async function getPublicBookmarks(limit = 20) {
const { databases } = createAdminClient();
const response = await databases.listDocuments(
DATABASE_ID,
BOOKMARKS_COLLECTION_ID,
[
Query.equal("isPublic", true),
Query.orderDesc("createdAt"),
Query.limit(limit),
]
);
return response.documents as unknown as Bookmark[];
}
export async function deleteBookmark(bookmarkId: string) {
const { databases } = createAdminClient();
await databases.deleteDocument(
DATABASE_ID,
BOOKMARKS_COLLECTION_ID,
bookmarkId
);
revalidatePath("/dashboard");
}
export async function updateBookmark(bookmarkId: string, formData: FormData) {
const { databases } = createAdminClient();
const title = formData.get("title") as string;
const description = formData.get("description") as string;
const tags = (formData.get("tags") as string)
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const isPublic = formData.get("isPublic") === "true";
await databases.updateDocument(
DATABASE_ID,
BOOKMARKS_COLLECTION_ID,
bookmarkId,
{ title, description, tags, isPublic }
);
revalidatePath("/dashboard");
}
export async function searchBookmarks(userId: string, query: string) {
const { databases } = createAdminClient();
const response = await databases.listDocuments(
DATABASE_ID,
BOOKMARKS_COLLECTION_ID,
[
Query.equal("userId", userId),
Query.or([
Query.contains("title", query),
Query.contains("description", query),
]),
Query.orderDesc("createdAt"),
Query.limit(50),
]
);
return response.documents as unknown as Bookmark[];
}Step 8: Build the Dashboard UI
Dashboard Layout
// src/app/dashboard/layout.tsx
"use client";
import { useAuth } from "@/lib/auth/context";
import { redirect } from "next/navigation";
import Link from "next/link";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user, loading, logout } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
if (!user) redirect("/login");
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16 items-center">
<Link href="/dashboard" className="text-xl font-bold text-gray-900">
Bookmarks
</Link>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{user.email}</span>
<button
onClick={logout}
className="text-sm text-red-600 hover:text-red-700"
>
Sign Out
</button>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</div>
);
}Bookmark Card Component
// src/components/BookmarkCard.tsx
"use client";
import { Bookmark, deleteBookmark } from "@/lib/actions/bookmarks";
import { useState } from "react";
export function BookmarkCard({ bookmark }: { bookmark: Bookmark }) {
const [isDeleting, setIsDeleting] = useState(false);
async function handleDelete() {
if (!confirm("Delete this bookmark?")) return;
setIsDeleting(true);
await deleteBookmark(bookmark.$id);
}
return (
<div className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition group">
<div className="flex items-start gap-3">
<img
src={bookmark.favicon}
alt=""
className="w-6 h-6 mt-1 rounded"
loading="lazy"
/>
<div className="flex-1 min-w-0">
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 font-medium truncate block"
>
{bookmark.title}
</a>
{bookmark.description && (
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{bookmark.description}
</p>
)}
<div className="flex items-center gap-2 mt-2">
{bookmark.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-700"
>
{tag}
</span>
))}
</div>
</div>
<button
onClick={handleDelete}
disabled={isDeleting}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition"
>
<TrashIcon />
</button>
</div>
</div>
);
}
function TrashIcon() {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
);
}Add Bookmark Form
// src/components/AddBookmarkForm.tsx
"use client";
import { createBookmark } from "@/lib/actions/bookmarks";
import { useAuth } from "@/lib/auth/context";
import { useState } from "react";
export function AddBookmarkForm() {
const { user } = useAuth();
const [isOpen, setIsOpen] = useState(false);
if (!user) return null;
return (
<div>
<button
onClick={() => setIsOpen(!isOpen)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
+ Add Bookmark
</button>
{isOpen && (
<form
action={async (formData) => {
formData.set("userId", user.$id);
await createBookmark(formData);
setIsOpen(false);
}}
className="mt-4 bg-white rounded-lg border border-gray-200 p-6 space-y-4"
>
<div>
<label className="block text-sm font-medium text-gray-700">URL</label>
<input
name="url"
type="url"
required
placeholder="https://example.com"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Title</label>
<input
name="title"
required
placeholder="Page title"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Description
</label>
<textarea
name="description"
rows={2}
placeholder="Brief description (optional)"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Tags</label>
<input
name="tags"
placeholder="react, nextjs, tutorial (comma-separated)"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div className="flex items-center gap-2">
<input name="isPublic" type="checkbox" value="true" id="isPublic" />
<label htmlFor="isPublic" className="text-sm text-gray-700">
Make this bookmark public
</label>
</div>
<div className="flex gap-3">
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Save Bookmark
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
</div>
</form>
)}
</div>
);
}Dashboard Page
// src/app/dashboard/page.tsx
"use client";
import { useAuth } from "@/lib/auth/context";
import { getBookmarks, Bookmark } from "@/lib/actions/bookmarks";
import { BookmarkCard } from "@/components/BookmarkCard";
import { AddBookmarkForm } from "@/components/AddBookmarkForm";
import { useEffect, useState } from "react";
export default function DashboardPage() {
const { user } = useAuth();
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("");
useEffect(() => {
if (user) {
getBookmarks(user.$id).then((data) => {
setBookmarks(data);
setLoading(false);
});
}
}, [user]);
const filteredBookmarks = filter
? bookmarks.filter((b) =>
b.tags.some((t) => t.toLowerCase().includes(filter.toLowerCase()))
)
: bookmarks;
const allTags = [...new Set(bookmarks.flatMap((b) => b.tags))];
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">My Bookmarks</h1>
<AddBookmarkForm />
</div>
{allTags.length > 0 && (
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setFilter("")}
className={`px-3 py-1 rounded-full text-sm ${
!filter
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
All
</button>
{allTags.map((tag) => (
<button
key={tag}
onClick={() => setFilter(tag)}
className={`px-3 py-1 rounded-full text-sm ${
filter === tag
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{tag}
</button>
))}
</div>
)}
{loading ? (
<div className="grid gap-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-white rounded-lg border border-gray-200 p-4 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/3" />
<div className="h-3 bg-gray-100 rounded w-2/3 mt-2" />
</div>
))}
</div>
) : filteredBookmarks.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<p className="text-lg">No bookmarks yet</p>
<p className="mt-1">Click "Add Bookmark" to save your first link</p>
</div>
) : (
<div className="grid gap-3">
{filteredBookmarks.map((bookmark) => (
<BookmarkCard key={bookmark.$id} bookmark={bookmark} />
))}
</div>
)}
</div>
);
}Step 9: Add File Storage for Thumbnails
Create a storage bucket in the Appwrite Console:
- Go to Storage and create a bucket called
thumbnails - Set maximum file size to 5 MB
- Allow file extensions:
jpg,jpeg,png,webp,gif - Set permissions: Users can create and read
Now add a thumbnail upload function:
// src/lib/actions/storage.ts
"use server";
import { createAdminClient } from "@/lib/appwrite/server";
import { THUMBNAILS_BUCKET_ID } from "@/lib/appwrite/config";
import { ID } from "node-appwrite";
export async function uploadThumbnail(formData: FormData) {
const { storage } = createAdminClient();
const file = formData.get("file") as File;
if (!file || file.size === 0) return null;
const response = await storage.createFile(
THUMBNAILS_BUCKET_ID,
ID.unique(),
file
);
return response.$id;
}
export function getThumbnailUrl(fileId: string) {
return `${process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT}/storage/buckets/${THUMBNAILS_BUCKET_ID}/files/${fileId}/view?project=${process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID}`;
}Step 10: Add Real-Time Subscriptions
Appwrite provides real-time capabilities through WebSocket subscriptions. Let us add live updates when bookmarks change:
// src/hooks/useRealtimeBookmarks.ts
"use client";
import { useEffect } from "react";
import { client } from "@/lib/appwrite/client";
import { DATABASE_ID, BOOKMARKS_COLLECTION_ID } from "@/lib/appwrite/config";
import { Bookmark } from "@/lib/actions/bookmarks";
type RealtimeEvent = {
events: string[];
payload: Bookmark;
};
export function useRealtimeBookmarks(
userId: string,
onUpdate: (bookmarks: Bookmark[]) => void,
currentBookmarks: Bookmark[]
) {
useEffect(() => {
const channel = `databases.${DATABASE_ID}.collections.${BOOKMARKS_COLLECTION_ID}.documents`;
const unsubscribe = client.subscribe(channel, (response: RealtimeEvent) => {
const { events, payload } = response;
if (payload.userId !== userId) return;
let updated = [...currentBookmarks];
if (events.some((e) => e.includes(".create"))) {
updated = [payload, ...updated];
} else if (events.some((e) => e.includes(".update"))) {
updated = updated.map((b) => (b.$id === payload.$id ? payload : b));
} else if (events.some((e) => e.includes(".delete"))) {
updated = updated.filter((b) => b.$id !== payload.$id);
}
onUpdate(updated);
});
return () => unsubscribe();
}, [userId, currentBookmarks, onUpdate]);
}Then integrate it into the dashboard:
// Add to DashboardPage component
import { useRealtimeBookmarks } from "@/hooks/useRealtimeBookmarks";
// Inside the component, after the existing useEffect:
useRealtimeBookmarks(user?.$id ?? "", setBookmarks, bookmarks);Now if you open two browser tabs, creating a bookmark in one tab will instantly appear in the other.
Step 11: Add Middleware for Route Protection
// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const session = request.cookies.get("a_session");
const isAuthPage = request.nextUrl.pathname.startsWith("/login");
const isDashboard = request.nextUrl.pathname.startsWith("/dashboard");
if (isDashboard && !session) {
return NextResponse.redirect(new URL("/login", request.url));
}
if (isAuthPage && session) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/login"],
};Step 12: Deploy to Production
Vercel Deployment
- Push your code to GitHub
- Import the repository in Vercel
- Add the environment variables in the Vercel dashboard
- Update the Appwrite platform hostname from
localhostto your production domain
Update Appwrite Platform
In the Appwrite Console, go to Overview and add your production hostname:
- Hostname:
your-app.vercel.app
Update OAuth redirect URLs to include the production domain:
https://your-app.vercel.app/auth/callback
Testing Your Implementation
- Authentication flow: Click "Continue with Google" and verify the OAuth redirect completes
- Create bookmarks: Add a few bookmarks with different tags and verify they appear
- Real-time sync: Open two tabs, add a bookmark in one, and confirm it appears in the other
- Tag filtering: Click on tags to filter bookmarks
- Delete bookmarks: Hover over a bookmark and click the trash icon
- Public bookmarks: Toggle a bookmark as public and verify it appears on the public feed
Troubleshooting
"Missing required attribute" error
Ensure all required attributes in the Appwrite collection match the data you are sending. Double-check attribute names and types in the Console.
OAuth redirect fails
- Verify the redirect URL matches exactly in both your code and the OAuth provider settings
- Ensure your Appwrite platform hostname includes the correct domain
- Check that the OAuth provider is enabled in Appwrite Auth settings
Real-time events not firing
- Confirm the collection permissions allow read access
- Check that the WebSocket connection is established (look for
wss://connections in browser DevTools) - Ensure you are subscribing to the correct channel format
CORS errors
- Add your development and production hostnames to the Appwrite platform settings
- Make sure the hostname matches exactly (no trailing slash)
Appwrite vs Other BaaS Providers
| Feature | Appwrite | Supabase | Firebase |
|---|---|---|---|
| Open Source | Yes | Yes | No |
| Self-Hosting | Docker | Docker | No |
| Database | Document (MariaDB) | PostgreSQL | Document (Firestore) |
| Auth Providers | 30+ | 20+ | 20+ |
| Real-Time | WebSocket | WebSocket | WebSocket |
| File Storage | Built-in | Built-in | Built-in |
| Cloud Functions | Multiple runtimes | Edge Functions | Cloud Functions |
| Free Tier | Generous | Generous | Limited |
Appwrite stands out with its document-based database model, extensive OAuth provider support, and the ability to self-host the entire stack with a single Docker Compose command.
Next Steps
- Add cloud functions — Create Appwrite Functions to generate bookmark previews automatically
- Implement collections — Let users organize bookmarks into folders
- Add browser extension — Build a Chrome extension to save bookmarks with one click
- Export and import — Support exporting bookmarks as JSON and importing from browser bookmark files
- Collaborative sharing — Let users share bookmark collections with teams
Conclusion
You have built a complete full-stack bookmark manager using Appwrite Cloud and Next.js 15. The application features OAuth authentication, a document database with tag-based organization, file storage for thumbnails, and real-time subscriptions for live updates. Appwrite provides a self-hostable, open-source alternative to proprietary BaaS platforms, giving you full control over your backend while maintaining developer productivity. The combination of Appwrite document databases with Next.js Server Actions creates a clean, type-safe architecture that scales from prototype to production.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Real-Time Full-Stack App with Convex and Next.js 15
Learn how to build a real-time full-stack application using Convex and Next.js 15. This tutorial covers schema design, queries, mutations, real-time subscriptions, authentication, and file uploads — all with end-to-end type safety.

Build a Real-Time App with Supabase and Next.js 15: Complete Guide
Learn how to build a full-stack real-time application using Supabase and Next.js 15 App Router. This guide covers authentication, database setup, Row Level Security, and real-time subscriptions.

Build a Full-Stack App with Firebase and Next.js 15: Auth, Firestore & Real-Time
Learn how to build a full-stack app with Next.js 15 and Firebase. This guide covers authentication, Firestore, real-time updates, Server Actions, and deployment to Vercel.