User Invitation System via Email
Invitation flow: user invites a colleague who receives an email with a link, registers, and is automatically added to the needed organization. Without an invitation, access is denied (for closed B2B products).
Data Schema
model Invitation {
id String @id @default(cuid())
email String
organizationId String
role OrganizationRole @default(MEMBER)
token String @unique @default(cuid())
invitedById String
status InvitationStatus @default(PENDING)
expiresAt DateTime
createdAt DateTime @default(now())
acceptedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id])
invitedBy User @relation(fields: [invitedById], references: [id])
}
enum InvitationStatus {
PENDING
ACCEPTED
EXPIRED
REVOKED
}
enum OrganizationRole {
OWNER
ADMIN
MEMBER
VIEWER
}
Server Action: Sending Invitation
// app/organization/invite/actions.ts
'use server';
import { auth } from '@/auth';
import { db } from '@/lib/db';
import { sendEmail } from '@/lib/email';
import { z } from 'zod';
const inviteSchema = z.object({
email: z.string().email(),
role: z.enum(['ADMIN', 'MEMBER', 'VIEWER']).default('MEMBER'),
});
export async function inviteUser(
organizationId: string,
formData: FormData
) {
const session = await auth();
if (!session) throw new Error('Unauthorized');
// Check invitation permissions
const membership = await db.organizationMember.findFirst({
where: {
organizationId,
userId: session.user.id,
role: { in: ['OWNER', 'ADMIN'] },
}
});
if (!membership) throw new Error('Insufficient permissions');
const { email, role } = inviteSchema.parse(Object.fromEntries(formData));
// Check: already a member?
const existingMember = await db.user.findFirst({
where: {
email,
organizations: { some: { organizationId } }
}
});
if (existingMember) throw new Error('User already a member');
// Check: is there an active invitation already?
const existingInvite = await db.invitation.findFirst({
where: {
email,
organizationId,
status: 'PENDING',
expiresAt: { gt: new Date() },
}
});
if (existingInvite) throw new Error('Invitation already sent');
// Create invitation
const invitation = await db.invitation.create({
data: {
email,
organizationId,
role,
invitedById: session.user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
include: {
organization: true,
invitedBy: true,
}
});
// Send email
await sendEmail({
to: email,
subject: `You're invited to ${invitation.organization.name}`,
template: 'invitation',
variables: {
organizationName: invitation.organization.name,
inviterName: invitation.invitedBy.name!,
inviteUrl: `${process.env.APP_URL}/invite/${invitation.token}`,
expiresAt: invitation.expiresAt.toLocaleDateString('en-US'),
role: ROLE_LABELS[role],
},
});
return { success: true, invitationId: invitation.id };
}
Invitation Acceptance Page
// app/invite/[token]/page.tsx
import { db } from '@/lib/db';
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function InvitationPage({
params,
}: {
params: { token: string };
}) {
const invitation = await db.invitation.findUnique({
where: { token: params.token },
include: { organization: true, invitedBy: true },
});
if (!invitation || invitation.status !== 'PENDING') {
return <InvitationExpired />;
}
if (invitation.expiresAt < new Date()) {
await db.invitation.update({
where: { id: invitation.id },
data: { status: 'EXPIRED' }
});
return <InvitationExpired />;
}
return (
<InvitationAcceptPage
invitation={invitation}
token={params.token}
/>
);
}
// Accept invitation
export async function acceptInvitation(token: string) {
'use server';
const session = await auth();
const invitation = await db.invitation.findUnique({
where: { token, status: 'PENDING' },
});
if (!invitation || invitation.expiresAt < new Date()) {
throw new Error('Invalid or expired invitation');
}
// If user is not logged in — redirect to registration with token saved
if (!session) {
redirect(`/register?invite=${token}&email=${encodeURIComponent(invitation.email)}`);
}
// Check email match
if (session.user.email !== invitation.email) {
throw new Error('This invitation was sent to a different email');
}
await db.$transaction([
// Add to organization
db.organizationMember.create({
data: {
organizationId: invitation.organizationId,
userId: session.user.id,
role: invitation.role,
}
}),
// Update status
db.invitation.update({
where: { id: invitation.id },
data: {
status: 'ACCEPTED',
acceptedAt: new Date(),
}
}),
]);
redirect(`/org/${invitation.organizationId}/dashboard`);
}
Email Template (React Email)
// emails/invitation.tsx
import {
Body, Button, Container, Head, Html,
Preview, Section, Text,
} from '@react-email/components';
export function InvitationEmail({
organizationName,
inviterName,
inviteUrl,
expiresAt,
role,
}: InvitationEmailProps) {
return (
<Html>
<Head />
<Preview>{inviterName} invites you to {organizationName}</Preview>
<Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f5f5f5' }}>
<Container style={{ maxWidth: '560px', margin: '40px auto' }}>
<Text>Hi!</Text>
<Text>
<strong>{inviterName}</strong> invites you to join{' '}
<strong>{organizationName}</strong> as <strong>{role}</strong>.
</Text>
<Section style={{ textAlign: 'center', margin: '32px 0' }}>
<Button
href={inviteUrl}
style={{
backgroundColor: '#6366f1',
color: '#fff',
padding: '12px 32px',
borderRadius: '8px',
}}
>
Accept Invitation
</Button>
</Section>
<Text style={{ color: '#666', fontSize: '14px' }}>
Link valid until {expiresAt}. If you didn't expect this invitation, just ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
Setting up invitation flow with email via React Email/Resend and handling edge cases — 2–3 working days.







