Skip to main content
This example demonstrates how to build a comprehensive dashboard for managing invitations, including listing, canceling, and tracking invite status.

Overview

An invite dashboard allows you to:
  • View all invitations created by a user
  • Track invitation status (pending, used, canceled, rejected)
  • Cancel pending invitations
  • Monitor invitation usage and expiration
  • Display invite analytics

Server Setup

1

Create API Route for Listing Invites

Create an endpoint to fetch user’s invitations:
app/api/invites/route.ts
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { NextResponse } from "next/server";

export async function GET() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Query invitations from database
  // This assumes you have access to the database adapter
  const invitations = await db.query.invitation.findMany({
    where: eq(invitation.createdByUserId, session.user.id),
    orderBy: desc(invitation.createdAt),
  });

  return NextResponse.json({ invitations });
}
2

Configure Database Access

Ensure you have access to the invitation table:
lib/db.ts
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";

const sqlite = new Database("./sqlite.db");
export const db = drizzle(sqlite);

// Export invitation schema
export const invitation = {
  id: text("id").primaryKey(),
  token: text("token").notNull(),
  createdByUserId: text("createdByUserId").notNull(),
  email: text("email"),
  role: text("role").notNull(),
  status: text("status").notNull().default("pending"),
  maxUses: integer("maxUses").notNull().default(1),
  createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
  expiresAt: integer("expiresAt", { mode: "timestamp" }).notNull(),
  shareInviterName: integer("shareInviterName", { mode: "boolean" }).notNull().default(true),
  redirectToAfterUpgrade: text("redirectToAfterUpgrade"),
  newAccount: integer("newAccount", { mode: "boolean" }),
};

Dashboard Components

1

Main Dashboard Component

Create the main dashboard that displays all invitations:
components/invite-dashboard.tsx
"use client";

import { useState, useEffect } from "react";
import { authClient } from "@/lib/auth-client";
import { InviteCard } from "./invite-card";
import { InviteStats } from "./invite-stats";
import { CreateInviteModal } from "./create-invite-modal";

interface Invitation {
  id: string;
  token: string;
  email?: string;
  role: string;
  status: "pending" | "used" | "canceled" | "rejected";
  maxUses: number;
  createdAt: Date;
  expiresAt: Date;
  newAccount?: boolean;
}

export function InviteDashboard() {
  const [invitations, setInvitations] = useState<Invitation[]>([]);
  const [loading, setLoading] = useState(true);
  const [showCreateModal, setShowCreateModal] = useState(false);
  const [filter, setFilter] = useState<"all" | "pending" | "used" | "canceled" | "rejected">("all");

  useEffect(() => {
    fetchInvitations();
  }, []);

  const fetchInvitations = async () => {
    setLoading(true);
    try {
      const response = await fetch("/api/invites");
      const data = await response.json();
      setInvitations(data.invitations || []);
    } catch (error) {
      console.error("Failed to fetch invitations:", error);
    } finally {
      setLoading(false);
    }
  };

  const handleCancel = async (token: string) => {
    try {
      const { error } = await authClient.invite.cancel({ token });
      
      if (error) {
        alert(`Error: ${error.message}`);
        return;
      }

      // Refresh the list
      await fetchInvitations();
    } catch (error) {
      alert("Failed to cancel invitation");
    }
  };

  const filteredInvitations = filter === "all" 
    ? invitations 
    : invitations.filter(inv => inv.status === filter);

  const stats = {
    total: invitations.length,
    pending: invitations.filter(i => i.status === "pending").length,
    used: invitations.filter(i => i.status === "used").length,
    canceled: invitations.filter(i => i.status === "canceled").length,
  };

  if (loading) {
    return (
      <div className="flex justify-center items-center h-64">
        <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
      </div>
    );
  }

  return (
    <div className="max-w-6xl mx-auto px-4 py-8">
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-3xl font-bold text-gray-900">Invitation Dashboard</h1>
        <button
          onClick={() => setShowCreateModal(true)}
          className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors"
        >
          Create Invite
        </button>
      </div>

      <InviteStats stats={stats} />

      <div className="bg-white rounded-lg shadow mb-6">
        <div className="border-b border-gray-200">
          <nav className="flex space-x-8 px-6" aria-label="Tabs">
            {["all", "pending", "used", "canceled", "rejected"].map((tab) => (
              <button
                key={tab}
                onClick={() => setFilter(tab as typeof filter)}
                className={`py-4 px-1 border-b-2 font-medium text-sm capitalize ${
                  filter === tab
                    ? "border-blue-500 text-blue-600"
                    : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
                }`}
              >
                {tab} ({tab === "all" ? stats.total : invitations.filter(i => i.status === tab).length})
              </button>
            ))}
          </nav>
        </div>
      </div>

      <div className="space-y-4">
        {filteredInvitations.length === 0 ? (
          <div className="bg-white rounded-lg shadow p-12 text-center">
            <p className="text-gray-500 text-lg">No {filter !== "all" && filter} invitations found</p>
            <button
              onClick={() => setShowCreateModal(true)}
              className="mt-4 text-blue-600 hover:text-blue-700 font-medium"
            >
              Create your first invitation
            </button>
          </div>
        ) : (
          filteredInvitations.map((invitation) => (
            <InviteCard
              key={invitation.id}
              invitation={invitation}
              onCancel={handleCancel}
            />
          ))
        )}
      </div>

      {showCreateModal && (
        <CreateInviteModal
          onClose={() => setShowCreateModal(false)}
          onSuccess={() => {
            setShowCreateModal(false);
            fetchInvitations();
          }}
        />
      )}
    </div>
  );
}
2

Invite Card Component

Create individual cards for each invitation:
components/invite-card.tsx
"use client";

import { useState } from "react";
import { Mail, Link as LinkIcon, Calendar, Users, Copy, X, Check } from "lucide-react";

interface InviteCardProps {
  invitation: {
    id: string;
    token: string;
    email?: string;
    role: string;
    status: "pending" | "used" | "canceled" | "rejected";
    maxUses: number;
    createdAt: Date;
    expiresAt: Date;
    newAccount?: boolean;
  };
  onCancel: (token: string) => Promise<void>;
}

export function InviteCard({ invitation, onCancel }: InviteCardProps) {
  const [copied, setCopied] = useState(false);
  const [canceling, setCanceling] = useState(false);

  const isPrivate = !!invitation.email;
  const isExpired = new Date(invitation.expiresAt) < new Date();
  const isPending = invitation.status === "pending";
  const canCancel = isPending && !isExpired;

  const inviteUrl = `${window.location.origin}/invite/${invitation.token}`;

  const handleCopy = async () => {
    await navigator.clipboard.writeText(inviteUrl);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  const handleCancel = async () => {
    if (!confirm("Are you sure you want to cancel this invitation?")) return;
    
    setCanceling(true);
    await onCancel(invitation.token);
    setCanceling(false);
  };

  const statusColors = {
    pending: "bg-yellow-100 text-yellow-800",
    used: "bg-green-100 text-green-800",
    canceled: "bg-gray-100 text-gray-800",
    rejected: "bg-red-100 text-red-800",
  };

  return (
    <div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
      <div className="flex items-start justify-between mb-4">
        <div className="flex items-center space-x-3">
          {isPrivate ? (
            <div className="p-2 bg-blue-100 rounded-lg">
              <Mail className="w-5 h-5 text-blue-600" />
            </div>
          ) : (
            <div className="p-2 bg-purple-100 rounded-lg">
              <LinkIcon className="w-5 h-5 text-purple-600" />
            </div>
          )}
          <div>
            <h3 className="font-semibold text-gray-900">
              {isPrivate ? invitation.email : "Public Invite"}
            </h3>
            <p className="text-sm text-gray-500">
              Role: <span className="font-medium text-gray-700">{invitation.role}</span>
              {invitation.newAccount !== undefined && (
                <span className="ml-2">
                  ({invitation.newAccount ? "New Account" : "Role Upgrade"})
                </span>
              )}
            </p>
          </div>
        </div>
        <span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[invitation.status]}`}>
          {invitation.status}
        </span>
      </div>

      <div className="grid grid-cols-2 gap-4 mb-4 text-sm">
        <div className="flex items-center text-gray-600">
          <Calendar className="w-4 h-4 mr-2" />
          <span>
            Created {new Date(invitation.createdAt).toLocaleDateString()}
          </span>
        </div>
        <div className="flex items-center text-gray-600">
          <Users className="w-4 h-4 mr-2" />
          <span>Max uses: {invitation.maxUses}</span>
        </div>
      </div>

      {isExpired && (
        <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
          <p className="text-sm text-red-800">
            Expired on {new Date(invitation.expiresAt).toLocaleDateString()}
          </p>
        </div>
      )}

      <div className="flex items-center space-x-2">
        {!isPrivate && isPending && (
          <>
            <input
              type="text"
              value={inviteUrl}
              readOnly
              className="flex-1 px-3 py-2 bg-gray-50 border border-gray-300 rounded-md text-sm"
            />
            <button
              onClick={handleCopy}
              className="p-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
              title="Copy link"
            >
              {copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
            </button>
          </>
        )}
        {canCancel && (
          <button
            onClick={handleCancel}
            disabled={canceling}
            className="p-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors disabled:opacity-50"
            title="Cancel invitation"
          >
            <X className="w-5 h-5" />
          </button>
        )}
      </div>
    </div>
  );
}
3

Stats Component

Display invitation statistics:
components/invite-stats.tsx
import { TrendingUp, Clock, CheckCircle, XCircle } from "lucide-react";

interface InviteStatsProps {
  stats: {
    total: number;
    pending: number;
    used: number;
    canceled: number;
  };
}

export function InviteStats({ stats }: InviteStatsProps) {
  const statCards = [
    {
      label: "Total Invites",
      value: stats.total,
      icon: TrendingUp,
      color: "blue",
    },
    {
      label: "Pending",
      value: stats.pending,
      icon: Clock,
      color: "yellow",
    },
    {
      label: "Used",
      value: stats.used,
      icon: CheckCircle,
      color: "green",
    },
    {
      label: "Canceled",
      value: stats.canceled,
      icon: XCircle,
      color: "gray",
    },
  ];

  return (
    <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
      {statCards.map((stat) => {
        const Icon = stat.icon;
        return (
          <div
            key={stat.label}
            className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow"
          >
            <div className="flex items-center justify-between">
              <div>
                <p className="text-sm font-medium text-gray-600">{stat.label}</p>
                <p className="text-3xl font-bold text-gray-900 mt-2">{stat.value}</p>
              </div>
              <div className={`p-3 bg-${stat.color}-100 rounded-lg`}>
                <Icon className={`w-6 h-6 text-${stat.color}-600`} />
              </div>
            </div>
          </div>
        );
      })}
    </div>
  );
}
4

Create Invite Modal

Add a modal for creating new invitations:
components/create-invite-modal.tsx
"use client";

import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { X } from "lucide-react";

interface CreateInviteModalProps {
  onClose: () => void;
  onSuccess: () => void;
}

export function CreateInviteModal({ onClose, onSuccess }: CreateInviteModalProps) {
  const [email, setEmail] = useState("");
  const [role, setRole] = useState("user");
  const [maxUses, setMaxUses] = useState(1);
  const [inviteType, setInviteType] = useState<"private" | "public">("private");
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);

    try {
      const { error } = await authClient.invite.create({
        email: inviteType === "private" ? email : undefined,
        role,
        maxUses,
        senderResponse: inviteType === "public" ? "url" : undefined,
      });

      if (error) {
        alert(`Error: ${error.message}`);
      } else {
        onSuccess();
      }
    } catch (err) {
      alert("Failed to create invitation");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
      <div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
        <div className="flex items-center justify-between p-6 border-b">
          <h2 className="text-xl font-bold">Create Invitation</h2>
          <button
            onClick={onClose}
            className="p-1 hover:bg-gray-100 rounded-full transition-colors"
          >
            <X className="w-5 h-5" />
          </button>
        </div>

        <form onSubmit={handleSubmit} className="p-6 space-y-4">
          <div>
            <label className="block text-sm font-medium mb-2">Invite Type</label>
            <div className="flex gap-4">
              <label className="flex items-center">
                <input
                  type="radio"
                  value="private"
                  checked={inviteType === "private"}
                  onChange={(e) => setInviteType("private")}
                  className="mr-2"
                />
                Private (Email)
              </label>
              <label className="flex items-center">
                <input
                  type="radio"
                  value="public"
                  checked={inviteType === "public"}
                  onChange={(e) => setInviteType("public")}
                  className="mr-2"
                />
                Public (Link)
              </label>
            </div>
          </div>

          {inviteType === "private" && (
            <div>
              <label htmlFor="email" className="block text-sm font-medium mb-1">
                Email Address
              </label>
              <input
                id="email"
                type="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                required
                className="w-full px-3 py-2 border border-gray-300 rounded-md"
              />
            </div>
          )}

          <div>
            <label htmlFor="role" className="block text-sm font-medium mb-1">
              Role
            </label>
            <select
              id="role"
              value={role}
              onChange={(e) => setRole(e.target.value)}
              className="w-full px-3 py-2 border border-gray-300 rounded-md"
            >
              <option value="user">User</option>
              <option value="moderator">Moderator</option>
              <option value="admin">Admin</option>
            </select>
          </div>

          {inviteType === "public" && (
            <div>
              <label htmlFor="maxUses" className="block text-sm font-medium mb-1">
                Max Uses (0 = unlimited)
              </label>
              <input
                id="maxUses"
                type="number"
                value={maxUses}
                onChange={(e) => setMaxUses(Number(e.target.value))}
                min="0"
                className="w-full px-3 py-2 border border-gray-300 rounded-md"
              />
            </div>
          )}

          <div className="flex gap-3 pt-4">
            <button
              type="button"
              onClick={onClose}
              className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
            >
              Cancel
            </button>
            <button
              type="submit"
              disabled={loading}
              className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
            >
              {loading ? "Creating..." : "Create Invite"}
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

Usage Example

app/dashboard/invites/page.tsx
import { InviteDashboard } from "@/components/invite-dashboard";

export default function InvitesPage() {
  return (
    <div className="min-h-screen bg-gray-50">
      <InviteDashboard />
    </div>
  );
}

Advanced Features

Tracking Usage: To track how many times an invite has been used, you’ll need to query the inviteUse table which stores each usage record with inviteId, usedByUserId, and usedAt.
Real-time Updates: Consider using webhooks or polling to update the dashboard when invitations are used or expired.

Next Steps

  • Add pagination for large invitation lists
  • Implement search and filtering by role or date
  • Add analytics charts for invitation trends
  • Create email notifications for invitation events

Build docs developers (and LLMs) love