A team invite system lets users collaborate by sending invitations via email or shareable links. Here is a complete implementation.
Step 1: Database Schema
// db/schema.ts
import { pgTable, text, timestamp, uuid, unique } from "drizzle-orm/pg-core";
export const teams = pgTable("teams", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
slug: text("slug").unique().notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
export const teamMembers = pgTable(
"team_members",
{
id: uuid("id").defaultRandom().primaryKey(),
teamId: uuid("team_id").references(() => teams.id).notNull(),
userId: text("user_id").notNull(),
role: text("role", { enum: ["owner", "admin", "member", "viewer"] })
.default("member")
.notNull(),
joinedAt: timestamp("joined_at").defaultNow(),
},
(t) => [unique().on(t.teamId, t.userId)]
);
export const teamInvites = pgTable("team_invites", {
id: uuid("id").defaultRandom().primaryKey(),
teamId: uuid("team_id").references(() => teams.id).notNull(),
email: text("email").notNull(),
role: text("role", { enum: ["admin", "member", "viewer"] })
.default("member")
.notNull(),
token: text("token").unique().notNull(),
invitedBy: text("invited_by").notNull(),
status: text("status", { enum: ["pending", "accepted", "expired", "revoked"] })
.default("pending")
.notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow(),
acceptedAt: timestamp("accepted_at"),
});
Step 2: Invite Logic
// lib/invites.ts
import { db } from "@/db";
import { teamInvites, teamMembers } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { randomBytes } from "crypto";
export async function createInvite(
teamId: string,
email: string,
role: "admin" | "member" | "viewer",
invitedBy: string
) {
// Check if user is already a member
const existingMember = await db
.select()
.from(teamMembers)
.where(and(eq(teamMembers.teamId, teamId)));
// Check for existing pending invite
const existing = await db
.select()
.from(teamInvites)
.where(
and(
eq(teamInvites.teamId, teamId),
eq(teamInvites.email, email.toLowerCase()),
eq(teamInvites.status, "pending")
)
);
if (existing.length > 0) {
throw new Error("An invite is already pending for this email");
}
const token = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const [invite] = await db
.insert(teamInvites)
.values({
teamId,
email: email.toLowerCase(),
role,
token,
invitedBy,
expiresAt,
})
.returning();
return invite;
}
export async function acceptInvite(token: string, userId: string) {
const [invite] = await db
.select()
.from(teamInvites)
.where(
and(eq(teamInvites.token, token), eq(teamInvites.status, "pending"))
);
if (!invite) {
throw new Error("Invalid or expired invite");
}
if (invite.expiresAt < new Date()) {
await db
.update(teamInvites)
.set({ status: "expired" })
.where(eq(teamInvites.id, invite.id));
throw new Error("This invite has expired");
}
// Add user to team
await db.insert(teamMembers).values({
teamId: invite.teamId,
userId,
role: invite.role,
});
// Mark invite as accepted
await db
.update(teamInvites)
.set({ status: "accepted", acceptedAt: new Date() })
.where(eq(teamInvites.id, invite.id));
return invite;
}
export async function revokeInvite(inviteId: string) {
await db
.update(teamInvites)
.set({ status: "revoked" })
.where(eq(teamInvites.id, inviteId));
}
Step 3: Invite API Routes
// app/api/teams/[teamId]/invites/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { teamInvites } from "@/db/schema";
import { eq } from "drizzle-orm";
import { createInvite } from "@/lib/invites";
import { z } from "zod";
const inviteSchema = z.object({
email: z.string().email(),
role: z.enum(["admin", "member", "viewer"]),
});
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ teamId: string }> }
) {
const { teamId } = await params;
const invites = await db
.select()
.from(teamInvites)
.where(eq(teamInvites.teamId, teamId));
return NextResponse.json({ invites });
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ teamId: string }> }
) {
const { teamId } = await params;
const body = await request.json();
const parsed = inviteSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
try {
const invite = await createInvite(
teamId,
parsed.data.email,
parsed.data.role,
"current-user-id" // Replace with actual auth
);
// Send invite email
const inviteUrl = `${process.env.NEXT_PUBLIC_APP_URL}/invite/${invite.token}`;
// await sendInviteEmail(parsed.data.email, inviteUrl, teamName);
return NextResponse.json({ invite, inviteUrl }, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to create invite" },
{ status: 400 }
);
}
}
Step 4: Accept Invite Route
// app/api/invites/[token]/accept/route.ts
import { NextRequest, NextResponse } from "next/server";
import { acceptInvite } from "@/lib/invites";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
try {
const invite = await acceptInvite(token, "current-user-id"); // Replace with actual auth
return NextResponse.json({
success: true,
teamId: invite.teamId,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to accept invite" },
{ status: 400 }
);
}
}
Step 5: Invite Accept Page
// app/invite/[token]/page.tsx
import { db } from "@/db";
import { teamInvites, teams } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { AcceptInviteButton } from "./AcceptInviteButton";
export default async function InvitePage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const [invite] = await db
.select({
id: teamInvites.id,
email: teamInvites.email,
role: teamInvites.role,
status: teamInvites.status,
expiresAt: teamInvites.expiresAt,
teamName: teams.name,
})
.from(teamInvites)
.innerJoin(teams, eq(teams.id, teamInvites.teamId))
.where(eq(teamInvites.token, token));
if (!invite) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold">Invalid Invite</h1>
<p className="mt-2 text-gray-600">This invitation link is not valid.</p>
</div>
</div>
);
}
if (invite.status !== "pending") {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold">Invite {invite.status}</h1>
<p className="mt-2 text-gray-600">
This invitation has already been {invite.status}.
</p>
</div>
</div>
);
}
if (invite.expiresAt < new Date()) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold">Invite Expired</h1>
<p className="mt-2 text-gray-600">
This invitation has expired. Ask the team admin for a new one.
</p>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md rounded-lg border p-8 text-center">
<h1 className="text-2xl font-bold">Join {invite.teamName}</h1>
<p className="mt-2 text-gray-600">
You have been invited to join as a{" "}
<span className="font-medium">{invite.role}</span>.
</p>
<AcceptInviteButton token={token} />
</div>
</div>
);
}
Step 6: Accept Button
// app/invite/[token]/AcceptInviteButton.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function AcceptInviteButton({ token }: { token: string }) {
const [loading, setLoading] = useState(false);
const router = useRouter();
async function handleAccept() {
setLoading(true);
const res = await fetch(`/api/invites/${token}/accept`, {
method: "POST",
});
const data = await res.json();
if (res.ok) {
router.push(`/dashboard/teams/${data.teamId}`);
} else {
alert(data.error);
setLoading(false);
}
}
return (
<button
onClick={handleAccept}
disabled={loading}
className="mt-6 rounded-lg bg-blue-600 px-6 py-3 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Joining..." : "Accept Invitation"}
</button>
);
}
Step 7: Team Members and Invites Management
// components/teams/TeamMembersPanel.tsx
"use client";
import { useState, useEffect } from "react";
interface Invite {
id: string;
email: string;
role: string;
status: string;
createdAt: string;
expiresAt: string;
}
export function PendingInvites({ teamId }: { teamId: string }) {
const [invites, setInvites] = useState<Invite[]>([]);
const [email, setEmail] = useState("");
const [role, setRole] = useState<"admin" | "member" | "viewer">("member");
const [sending, setSending] = useState(false);
useEffect(() => {
fetch(`/api/teams/${teamId}/invites`)
.then((r) => r.json())
.then((d) =>
setInvites(d.invites.filter((i: Invite) => i.status === "pending"))
);
}, [teamId]);
async function sendInvite(e: React.FormEvent) {
e.preventDefault();
setSending(true);
const res = await fetch(`/api/teams/${teamId}/invites`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, role }),
});
if (res.ok) {
const data = await res.json();
setInvites((prev) => [...prev, data.invite]);
setEmail("");
}
setSending(false);
}
async function revokeInvite(inviteId: string) {
await fetch(`/api/teams/${teamId}/invites/${inviteId}`, {
method: "DELETE",
});
setInvites((prev) => prev.filter((i) => i.id !== inviteId));
}
return (
<div className="space-y-4">
{/* Invite form */}
<form onSubmit={sendInvite} className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="teammate@company.com"
required
className="flex-1 rounded-lg border px-3 py-2 text-sm"
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as typeof role)}
className="rounded-lg border px-3 py-2 text-sm"
>
<option value="admin">Admin</option>
<option value="member">Member</option>
<option value="viewer">Viewer</option>
</select>
<button
type="submit"
disabled={sending}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{sending ? "Sending..." : "Invite"}
</button>
</form>
{/* Pending invites */}
{invites.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-500">Pending Invites</h3>
{invites.map((invite) => (
<div
key={invite.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<div>
<p className="text-sm font-medium">{invite.email}</p>
<p className="text-xs text-gray-500">
{invite.role} — expires{" "}
{new Date(invite.expiresAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => revokeInvite(invite.id)}
className="text-sm text-red-600 hover:underline"
>
Revoke
</button>
</div>
))}
</div>
)}
</div>
);
}
Need Team Collaboration Features?
We build multi-user applications with invites, permissions, and real-time collaboration. Contact us to discuss your project.