Full Example

A full working example of a custom invite activation flow with a UI.

GitHubSourcenpmNpm

Coming soon

This section documents an activation flow that is not available yet. The plugin is currently being updated to support this behavior.

Default behavior

By default, the URL generated by sendInvite points to:

/api/activate-invite

This works out of the box, but it has two important limitations:

  • It has no UI
  • It automatically activates the invite immediately

This is great for simple or headless flows, but some apps want a better user experience (and usually an explicit Accept / Reject step).


Custom UI flow

A common pattern is to make your sendInvite email link point to a page like:

/activate-invite/${token}

This page becomes the handoff where you:

  • ensure the user is authenticated
  • show an Accept / Reject UI
  • activate the invite only when the user explicitly accepts

Configure a custom invite URL

This makes every invite link point to your UI page instead of /api/activate-invite.

auth.ts
import { betterAuth } from "better-auth";
import { admin } from "better-auth/plugins";
import { invite } from "better-invite";

export const auth = betterAuth({
  plugins: [
    admin({
      // ... your admin config
    }),

    invite({
      defaultMaxUses: 1,
      defaultRedirectAfterUpgrade: "/auth/invited",

      // All invite links will now point to:
      // /activate-invite/${token}
      defaultCustomInviteUrl: "/activate-invite",

      async sendUserInvitation({ email, role, url, token, newAccount }) {
        // `url` uses your custom invite URL and automatically appends the token: /activate-invite/${token}
        if (newAccount) {
          await sendInvitationEmail(role, email, url);
          return;
        }

        await sendRoleUpgradeEmail(role, email, url);
      },
    }),
  ],
});

Option B: Override the URL for only one invite

Useful when only specific invites need a custom UI.

await authClient.invite.create({
  email: "person@example.com",
  role: "admin",
  customInviteUrl: "/activate-invite",
});

Once the user lands on:

/activate-invite/[token]

You can implement the following flow.

Your invite email contains something like:

  • /activate-invite/INVITE_TOKEN_HERE

2) Check if the user is logged in

When the page loads, verify whether the visitor is authenticated.

If the user is not logged in, redirect them to login with a callback URL:

authClient.signIn({
  callbackUrl: `/activate-invite/${token}`,
});

After login, they will return to the same invite page.

3) Show an Accept / Reject UI

Once the user is authenticated, show a simple UI:

  • Accept invite
  • Reject invite

4) If they accept, activate the invite

await authClient.invite.activate({ token });

5) If they reject, reject the invite

If the user clicks Reject, you should call:

await authClient.invite.reject({ token });

{/*! Fix this part */}

6) Redirect after the action

After activating or rejecting, redirect the user to wherever makes sense for your app (for example, a dashboard, an onboarding page, or a simple "Invite handled" page).


Full example (Next.js App Router)

Below is a complete example for a real /activate-invite/[token] page.

It:

  • checks authentication on the server
  • redirects to login if needed
  • shows an Accept / Reject UI
  • activates the invite only when accepted
  • redirects after activation
app/activate-invite/[token]/page.tsx
import { redirect } from "next/navigation";
import Link from "next/link";
import { auth } from "@/lib/auth";
import ActivateInviteClient from "./ui";

export default async function ActivateInvitePage({
  params,
}: {
  params: { token: string };
}) {
  const session = await auth.api.getSession();
  const token = params.token;

  // Not logged in: redirect to login and return here after authentication
  if (!session?.user) {
    redirect(`/login?callbackUrl=/activate-invite/${token}`);
  }

  return (
    <div className="mx-auto max-w-md p-6 space-y-4">
      <h1 className="text-2xl font-semibold">You have been invited</h1>

      <p className="text-sm text-muted-foreground">
        Accepting this invite will activate it and apply the role or membership
        configured by the sender.
      </p>

      <ActivateInviteClient token={token} />

      <div className="text-xs text-muted-foreground">
        Not you?{" "}
        <Link href="/logout" className="underline">
          Sign out
        </Link>
      </div>
    </div>
  );
}
app/activate-invite/[token]/ui.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth-client";

export default function ActivateInviteClient({ token }: { token: string }) {
  const router = useRouter();

  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function acceptInvite() {
    setLoading(true);
    setError(null);

    try {
      const res = await authClient.invite.activate({ token });

      // If your plugin returns a redirect URL, prefer it.
      const redirectTo =
        (res as any)?.redirectTo ??
        (res as any)?.data?.redirectTo ??
        "/auth/invited";

      router.push(redirectTo);
      router.refresh();
    } catch (err: any) {
      setError(err?.message ?? "Failed to activate invite.");
      setLoading(false);
    }
  }

  function rejectInvite() {
    router.push("/");
  }

  return (
    <div className="space-y-3">
      <button
        disabled={loading}
        onClick={acceptInvite}
        className="w-full rounded-md bg-black px-4 py-2 text-white disabled:opacity-50"
      >
        {loading ? "Activating..." : "Accept invite"}
      </button>

      <button
        disabled={loading}
        onClick={rejectInvite}
        className="w-full rounded-md border px-4 py-2 disabled:opacity-50"
      >
        Reject
      </button>

      {error ? <p className="text-sm text-red-500">{error}</p> : null}
    </div>
  );
}

Notes

  • The default /api/activate-invite route is intentionally minimal and does not provide a UI.
  • A custom UI flow is recommended for onboarding, role upgrades, and multi-workspace apps.

How is this guide?

Last updated on

On this page