Skip to main content
Back to Blog
Tutorials
5 min read
November 27, 2024

How to Build an Invite System for Teams in Next.js

Create a team invite system with email invitations, invite links, role assignment, and pending invite management in Next.js.

Ryel Banfield

Founder & Lead Developer

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.

invite systemteamscollaborationNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles