← Back

Creating a Timeless Slack Invite

Why I Built This Service

Slack invitations on the free plan have an expiration date. After 30 days, the link becomes invalid — and people who try to use it later see an error. This creates two problems:

  • Old links expire — you have to track and update them manually. If they’re mentioned in multiple places, this gets inconvenient.
  • People complain — they can’t join Slack because they landed on an outdated invite.

I wanted to have a single, stable link, like slack.myproject.com, that always points to a valid invite. And I wanted to be able to update it without redeploying or editing the code manually.

Before I built this service, the link could only be updated in the GitHub repository. That wasn’t ideal, since marketing — not developers — was responsible for the invite. They had to ask someone from the dev team to change it. That created delays and friction.

In the end, I built a small but reliable service that solves this problem. It consists of several parts: a public page, an admin panel, an API, validation logic, and monitoring. In this article, I’ll explain how it works — and how you can build one too.

You can easily adapt it for other use cases, not just Slack invites.


How to Set Up the Project

Before diving into the code, let’s take a look at the technologies used and how to run everything locally.

The project is built with:

  • Next.js 15 with the App Router;
  • Vercel Edge Functions for redirects and APIs;
  • Vercel Edge Config for storing data;
  • Auth0 for authentication;
  • Tailwind CSS for styling.

To run the project locally:

  1. Create a new project using create-next-app or a Next.js starter:
npx create-next-app@latest slack-invite-service --typescript --app
cd slack-invite-service
  1. Install the required packages:
pnpm add next-auth @auth0/nextjs-auth0 tailwindcss postcss autoprefixer
pnpm add -D typescript @types/node @types/react

If you’re using the Edge Config SDK, you can also install it:

pnpm add @vercel/edge-config
  1. Add a .env.local file with all the necessary variables (they’ll be listed later in the article).

  2. Start the local server:

pnpm dev

Now you can go to http://localhost:3000 and see the redirect (if the link is configured) or a fallback screen.


Project Structure

Here’s what the file structure looks like for the service logic:

app/
├── api/
│   ├── invite/route.ts              // GET and POST for working with the invite link
│   ├── invite/validate/route.ts     // POST for manual validation
│   ├── cron/check-invite/route.ts   // Daily link check
│   └── auth/[...auth0]/route.ts     // Auth0 login/logout handler

├── admin/page.tsx                   // Admin panel: view and update the link
├── page.tsx                         // Public redirect page
├── middleware.ts                    // Handles redirects and auth errors

lib/
├── invite-utils.ts                  // Reading, writing, and validating invite links
├── slack-notifications.ts           // Slack notifications
├── auth-utils.ts                    // Session wrappers

Architecture: How the Service Works

Here’s a diagram showing how the service operates:

┌──────────────┐      ┌──────────────────────┐
│   User       │─────▶│ slack.myproject.com  │
└──────────────┘      └──────────────────────┘


   [ Edge Function ]

┌───────▼─────────────┐
│  Check the link     │
│  (valid and active?)│
└───────┬────────────-┘

┌───────▼────────────┐
│ Is Slack invite OK?│
└───────┬────────────┘
        │ Yes

 [ Redirect to link ]


 [ User joins Slack ]


        └─────────────┐

     ┌────────────────────────────────────┐
     │ Cron job checks the link           │
     │ (expiration, HEAD request)         │
     └────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ Admin Panel                                 │
│ - View current link                         │
│ - Add a new one                             │
│ - Manual check                              │
└─────────────────────────────────────────────┘
        │     ▲
        ▼     │
  ┌────────────────────────────┐
  │ Auth0 email authorization  │
  └────────────────────────────┘


  ┌─────────────────────────────┐
  │ Edge Config (URL storage)   │
  └─────────────────────────────┘

Here are the components involved:

1. Public Page (/)

This is the page users land on when visiting slack.myproject.com. It does the following:

  • checks if there’s an active link;
  • validates whether it’s expired or still working;
  • if all is good — performs a redirect;
  • if anything goes wrong — shows a fallback message.

2. Admin Panel (/admin)

Only authorized users can access this page. In the admin panel, you can:

  • view the current invite link;
  • check how many days are left until it expires;
  • replace it with a new one.

Authentication is handled via Auth0 and restricted by email domain. I’ll explain this in more detail in the next section.

3. API

All data operations go through the API:

  • GET /api/invite — fetch the current invite link;
  • POST /api/invite — save a new invite (with validation);
  • POST /api/invite/validate — manually check the current link.

4. Data Storage

All data — current invite URL, creation date, active status — is stored in Vercel Edge Config. It works like a key-value store with global low-latency access. Perfect for small services.

5. Validation and Expiration

Each invite lives for a maximum of 30 days. The service automatically checks:

  • if the format is valid (contains join.slack.com, etc.);
  • if the link is reachable via a HEAD request;
  • if the lifetime hasn’t expired.

If something fails, the link is deactivated. The isActive flag is set to false, and users see a message that the invite has expired.

6. Slack Notifications

The service sends Slack notifications to keep the team informed — for example, when the link is updated or deactivated. I’ll cover this in a separate section below.

7. Cron Job

Every morning (or on a custom schedule), a cron route runs to check:

  • how many days are left before the link expires;
  • whether the link is still working;
  • and if something’s wrong — it deactivates the link and sends a notification.

Authentication and Access Protection

To make sure no random person can access the admin panel and replace the invite link with a phishing one, I added authentication via Auth0.

Why Use Auth0

  • Secure login via email;
  • Ability to restrict access by domain (e.g., only @mycompany.com);
  • Easy integration with Next.js.

I used the Auth0 SDK for Next.js and set up login through a Log in button that redirects the user to the login form.

How to Create an Application in Auth0

  1. Sign up at https://auth0.com/ and create a new tenant (e.g., myproject.eu.auth0.com).
  2. Go to Applications and click Create application.
  3. Name your application and choose Regular Web Application.
  4. In the Settings, specify:
Allowed Callback URLs:
http://localhost:3000/api/auth/callback
https://slack.myproject.com/api/auth/callback

Allowed Logout URLs:
http://localhost:3000/admin
https://slack.myproject.com/admin
  1. Copy the Client ID and Client Secret — you’ll need them in .env.
  2. Create environment variables:
AUTH0_SECRET=... # can be generated with openssl
AUTH0_BASE_URL=http://localhost:3000
AUTH0_ISSUER_BASE_URL=https://your-tenant.auth0.com
AUTH0_CLIENT_ID=...
AUTH0_CLIENT_SECRET=...

How to Restrict Access by Domain

Auth0 used to rely on Rules, but the modern way is to use Actions:

  1. Go to Actions → Library and click Create Action → Build from scratch.
  2. Name the action, for example, RestrictDomain.
  3. Paste the following code:
/**
 * @param {Event} event - Details about the user and the context of the login.
 * @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
 */
exports.onExecutePostLogin = async (event, api) => {
  const email = event.user.email || '';

  if (!email.endsWith('@yourdomain.com')) {
    api.access.deny('Access denied: only corporate emails are allowed.');
  }
};

Replace @yourdomain.com with your actual company domain.

  1. Click Deploy to publish the action.
  2. Go to Actions → Triggers → Login (Post Login).
  3. Drag your action from the right panel to the main field and click Apply.

Now only users with emails from your specified domain will be able to access the admin panel.

Handling Login Errors

If an error occurs during login (e.g., access denied), the service redirects the user back to /admin, appending error and error_description parameters to the URL. The interface then displays an appropriate message:

Access denied. Please use your corporate email.

Data Storage: Why Vercel Edge Config

When I was looking for a way to store a single link and its metadata, I didn’t want to spin up a full database just for a couple of fields. The solution had to be:

  • simple and free;
  • low-latency;
  • work well with Vercel Edge Functions.

That’s why I chose Vercel Edge Config — a globally available key-value store with near-instant access. It’s perfect for small configuration data.

What I Store

Here’s the data interface:

interface InviteData {
  url: string; // Slack invite link
  createdAt: string; // When it was created
  isActive: boolean; // Active status flag
}

This structure is saved in Edge Config under a single key — for example, current_invite.

How I Read the Data

When reading, I first try to use Vercel’s SDK. If it fails (which can happen in the edge environment), I fall back to a second method — using the REST API:

const data = await get<InviteData>(INVITE_KEY); // via SDK

If the SDK doesn’t return a result, a fetch request is made directly to the https://api.vercel.com/v1/edge-config/... endpoint with an authorization token.

If that also fails, I return a safe fallback:

{
  url: '',
  createdAt: new Date().toISOString(),
  isActive: false
}

How I Write Data

To write data, I use a PATCH request via the REST API. The request body contains an array with an upsert operation, which replaces or creates a value for the specified key:

{
  items: [
    {
      operation: 'upsert',
      key: 'current_invite',
      value: inviteData,
    },
  ];
}

Security

For all REST requests, I use a Vercel API token stored in .env. This token grants access only to Edge Config and does not affect other parts of the project.

When a user saves a new link in the admin panel, it’s important to ensure that the link actually works. The service performs several checks:

1. Format Check

Slack invites come in different forms, but they all include subdomains like join.slack.com or slack.com/signup. So I first verify that the link looks like a real Slack invite:

if (!url.includes('join.slack.com') && !url.includes('slack.com/signup')) {
  return false;
}

2. Availability Check (HEAD request)

Even if the format looks valid, the link may already be inactive. So I send a HEAD request with a 10-second timeout:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);

const res = await fetch(url, {
  method: 'HEAD',
  signal: controller.signal,
  headers: { 'User-Agent': 'SlackInviteValidator/1.0' },
});

clearTimeout(timeout);
return res.ok;

If the HEAD request returns 2xx or 3xx, the link is still active.

3. Expiration Check

Slack invites are valid for 30 days. I store the creation date and calculate the difference:

const created = new Date(invite.createdAt);
const now = new Date();
const daysPassed = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);

const isExpired = daysPassed > 30;

If the link is expired, it’s automatically marked as inactive.

When and How Validation Happens

  • When adding a new link — if the format is invalid or the HEAD request fails, the link won’t be saved.
  • On every visit to the main page — if a link exists, it gets validated and deactivated if necessary. This is important because Slack invites can expire not only by time (30 days) but also by usage limit (usually around 400 joins). So making a HEAD request on each visit adds an extra layer of safety.
  • In the admin panel — manual validation can be triggered.
  • Via a cron job — the link is automatically re-validated daily.

Slack Notifications

The service sends notifications to Slack to make sure important events are not missed:

  • A new link was saved — someone updated the invite. It’s important for the team to be aware of this change.
  • The link expired and was deactivated — either as a result of a cron check or when a user tried to follow an inactive link.

For this, a Slack Incoming Webhook is used.

How to Set It Up

  1. Go to https://api.slack.com/apps and create a new app.
  2. In the Incoming Webhooks section, enable the functionality.
  3. Create a webhook URL for the desired channel (e.g., #alerts).
  4. Copy the URL and save it to .env.local:
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...

Where and How Notifications Are Triggered

Create a file lib/slack-notifications.ts with helper functions:

export const SlackNotifications = {
  async linkUpdated(url: string, email: string) {
    await fetch(process.env.SLACK_WEBHOOK_URL!, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: `🔗 Link updated by ${email}: ${url}`,
      }),
    });
  },

  async linkInvalid(url: string) {
    await fetch(process.env.SLACK_WEBHOOK_URL!, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: `⚠️ Slack invite is invalid and was deactivated: ${url}`,
      }),
    });
  },
};
  • linkUpdated is called in POST /api/invite when a user saves a new link.
  • linkInvalid is called in the cron job if the link is expired or not working.

Manual link validation (POST /api/invite/validate) does not send a notification — it’s just a utility for the admin panel.


Interface and Pages

The service uses three key pages and a middleware layer:


Public Page

When a user visits https://slack.myproject.com/, they land on this page. It does the following:

  • fetches the link via GET /api/invite;
  • checks if the link is active;
  • validates it with a HEAD request;
  • if all is good — uses redirect();
  • if something goes wrong — renders a fallback component with a message.
// app/page.tsx
import { redirect } from 'next/navigation';
import { readInviteData, validateSlackInviteLink } from '@/lib/invite-utils';

export default async function HomePage() {
  const invite = await readInviteData();

  if (invite?.url && invite.isActive) {
    const isValid = await validateSlackInviteLink(invite.url);
    if (isValid) {
      redirect(invite.url);
    }
  }

  return (
    <main className="flex flex-col items-center justify-center min-h-screen p-4 text-center">
      <h1 className="text-2xl font-bold">Link is unavailable</h1>
      <p className="mt-2">Please contact the community administrator.</p>
    </main>
  );
}

Admin Panel

A page for managing the invite link. Available only after logging in via Auth0. It shows the current link, the number of days remaining, and a form to replace the link:

// app/admin/page.tsx

'use client';

import { useEffect, useState } from 'react';
import { useUser } from '@auth0/nextjs-auth0/client';

export default function AdminPage() {
  const { user, error, isLoading } = useUser();
  const [data, setData] = useState<any>(null);

  useEffect(() => {
    fetch('/api/invite')
      .then((res) => res.json())
      .then(setData);
  }, []);

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Error: {error.message}</p>;
  if (!user) return <a href="/api/auth/login">Log in</a>;

  return (
    <main className="p-4 max-w-xl mx-auto">
      <h1 className="text-xl font-bold mb-2">Current Link</h1>
      <pre className="p-2 bg-gray-100 rounded">
        {data?.url || 'No link available'}
      </pre>
      <form className="mt-4">
        {/* form for submitting a new link can be added here */}
      </form>
      <a className="text-sm underline mt-4 block" href="/api/auth/logout">
        Log out
      </a>
    </main>
  );
}

Middleware

Used to protect the admin page. If the user isn’t logged in, they are redirected to the login page:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(req: NextRequest) {
  if (req.nextUrl.pathname.startsWith('/admin')) {
    const loggedIn = req.cookies.has('appSession');

    if (!loggedIn) {
      return NextResponse.redirect(new URL('/api/auth/login', req.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/admin'],
};

The service uses separate routes in app/api to retrieve, store, and validate Slack invites. Below is the step-by-step implementation of each one.

GET /api/invite

Returns the current invite link (if available).

// app/api/invite/route.ts
import { readInviteData } from '@/lib/invite-utils';
import { NextResponse } from 'next/server';

export async function GET() {
  try {
    const invite = await readInviteData();
    return NextResponse.json(invite);
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to read invite data' },
      { status: 500 }
    );
  }
}

This route does not require authentication and is used as a public endpoint within the system.


POST /api/invite

Adds a new invite link. Available only to authenticated users.

Add the following second handler to the same file app/api/invite/route.ts:

// app/api/invite/route.ts
import { getSession } from '@auth0/nextjs-auth0';
import { writeInviteData, validateSlackInviteLink } from '@/lib/invite-utils';
import { SlackNotifications } from '@/lib/slack';

export async function POST(request: Request) {
  try {
    const { user } = await getSession();
    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const { url } = await request.json();
    const isValid = await validateSlackInviteLink(url);

    if (!isValid) {
      return NextResponse.json(
        { error: 'Invalid Slack invitation URL' },
        { status: 400 }
      );
    }

    const inviteData = {
      url: url.trim(),
      createdAt: new Date().toISOString(),
      isActive: true,
    };

    await writeInviteData(inviteData);
    await SlackNotifications.linkUpdated(url, user.email);

    return NextResponse.json(inviteData);
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to save invite data' },
      { status: 500 }
    );
  }
}

Here:

  • the session is checked first;
  • then the link is validated;
  • if everything is fine, the link is saved and a Slack notification is sent.

POST /api/invite/validate

Allows manual validation of the current link. If the link is no longer valid, it will be deactivated. This endpoint does not send a Slack notification — it’s intended solely for use from the admin panel.

// app/api/invite/validate/route.ts
import { getSession } from '@auth0/nextjs-auth0';
import {
  readInviteData,
  validateSlackInviteLink,
  getDaysLeft,
  writeInviteData,
} from '@/lib/invite-utils';
import { SlackNotifications } from '@/lib/slack';
import { NextResponse } from 'next/server';

export async function POST() {
  try {
    const { user } = await getSession();
    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const invite = await readInviteData();
    if (!invite.url) {
      return NextResponse.json(
        { error: 'No invitation URL found' },
        { status: 404 }
      );
    }

    const isValid = await validateSlackInviteLink(invite.url);

    if (!isValid) {
      const updatedInvite = { ...invite, isActive: false };
      await writeInviteData(updatedInvite);

      return NextResponse.json(
        { error: 'Link is no longer valid and has been deactivated' },
        { status: 400 }
      );
    }

    return NextResponse.json({
      message: 'Link is valid and active',
      daysLeft: getDaysLeft(invite.createdAt),
    });
  } catch (error) {
    return NextResponse.json({ error: 'Validation failed' }, { status: 500 });
  }
}

This endpoint is convenient to call from the admin panel to check whether the link has expired before the 30-day limit.


GET /api/cron/check-invite

This route is triggered on a schedule (e.g., using Vercel’s built-in cron jobs) and checks the current link:

  • how many days are left before it expires;
  • whether it’s still active;
  • if not — it deactivates the link and sends a Slack notification.
import {
  readInviteData,
  validateSlackInviteLink,
  getDaysLeft,
  writeInviteData,
} from '@/lib/invite-utils';
import { SlackNotifications } from '@/lib/slack';
import { NextResponse } from 'next/server';

export async function GET() {
  try {
    const invite = await readInviteData();
    if (!invite.url || !invite.isActive) {
      return NextResponse.json({ message: 'No active invite to check' });
    }

    const isValid = await validateSlackInviteLink(invite.url);
    const daysLeft = getDaysLeft(invite.createdAt);

    if (!isValid || daysLeft <= 0) {
      await writeInviteData({ ...invite, isActive: false });
      await SlackNotifications.linkInvalid(invite.url);

      return NextResponse.json({ message: 'Link deactivated' });
    }

    return NextResponse.json({ message: 'Link still valid', daysLeft });
  } catch (error) {
    return NextResponse.json({ error: 'Cron check failed' }, { status: 500 });
  }
}

If you’re hosting the project on Vercel, use the built-in Scheduled Functions by adding the following block to vercel.json:

// vercel.json
{
  "crons": [
    {
      "path": "/api/cron/check-invite",
      "schedule": "0 9 * * *"
    }
  ]
}

This will run the check every day at 9 AM UTC. If you’re hosting outside of Vercel, configure a cron job in GitHub Actions or use an external scheduler.


Conclusion

We’ve built a simple yet powerful service that solves the common problem of outdated Slack invite links using modern cloud technologies. Key benefits of this architecture include:

  • Slack-compliant: Works only with official invitation links.
  • Global performance: Vercel Edge Functions and Edge Config provide instant access worldwide.
  • Security: Auth0 authentication with domain restriction ensures a high level of admin panel security.
  • Reliability: Automatic monitoring via cron jobs and Slack notifications helps detect and respond to issues quickly.
  • Minimal maintenance: The service requires very little manual effort — mainly updating the Slack link every 30 days.
  • Adaptability: This service is easy to adapt for other teams or projects — just change the domain restrictions in Auth0 and the Slack Webhook URL.