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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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:

  1. Click Create Project
  2. Enter a name like "Bookmark Manager"
  3. Select your preferred region
  4. 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-manager

Install the Appwrite SDK:

npm install appwrite node-appwrite

The 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-key

The 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.write
  • collections.read, collections.write
  • documents.read, documents.write
  • files.read, files.write
  • users.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:

AttributeTypeRequiredDescription
urlString (2048)YesThe bookmark URL
titleString (256)YesPage title
descriptionString (1024)NoPage description
tagsString[] (50)NoArray of tags
thumbnailIdString (36)NoStorage file ID for screenshot
faviconString (2048)NoFavicon URL
userIdString (36)YesOwner user ID
isPublicBooleanYesWhether bookmark is publicly visible
createdAtDateTimeYesCreation 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:

  1. Google — Add your Google Cloud OAuth 2.0 client ID and secret
  2. 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 &quot;Add Bookmark&quot; 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:

  1. Go to Storage and create a bucket called thumbnails
  2. Set maximum file size to 5 MB
  3. Allow file extensions: jpg, jpeg, png, webp, gif
  4. 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

  1. Push your code to GitHub
  2. Import the repository in Vercel
  3. Add the environment variables in the Vercel dashboard
  4. Update the Appwrite platform hostname from localhost to 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

  1. Authentication flow: Click "Continue with Google" and verify the OAuth redirect completes
  2. Create bookmarks: Add a few bookmarks with different tags and verify they appear
  3. Real-time sync: Open two tabs, add a bookmark in one, and confirm it appears in the other
  4. Tag filtering: Click on tags to filter bookmarks
  5. Delete bookmarks: Hover over a bookmark and click the trash icon
  6. 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

FeatureAppwriteSupabaseFirebase
Open SourceYesYesNo
Self-HostingDockerDockerNo
DatabaseDocument (MariaDB)PostgreSQLDocument (Firestore)
Auth Providers30+20+20+
Real-TimeWebSocketWebSocketWebSocket
File StorageBuilt-inBuilt-inBuilt-in
Cloud FunctionsMultiple runtimesEdge FunctionsCloud Functions
Free TierGenerousGenerousLimited

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.


Want to read more tutorials? Check out our latest tutorial on Building Local-First Collaborative Apps with Yjs and React.

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.

30 min read·