Discord Bots

Build a Discord Bot Dashboard with Next.js & TypeScript

Tech Setup4 min read
TS

Tech Setup

Published August 11, 2026 · Editorial policy

Build a Discord Bot Dashboard with Next.js & TypeScript

Building a Discord bot is straightforward. Building a production-ready dashboard to manage that bot, view real-time metrics, and configure guild settings requires a robust full-stack architecture.

For Tier-1 developers, the standard stack for this use case is Next.js (App Router), TypeScript, Tailwind CSS, and NextAuth.js interacting with the Discord OAuth2 API and a PostgreSQL database via Prisma.

In this guide, we will architect and build a secure, type-safe Discord bot dashboard from scratch.

Architecture Overview

A Discord bot dashboard acts as the bridge between Discord's API, your database, and your users. The data flow relies on three primary components:

  1. OAuth2 Authentication: Users log in with their Discord accounts. We scope the session to request identify and guilds permissions.
  2. Guild Permission Filtering: We filter the user's guilds to only display servers where they possess the MANAGE_GUILD (Administrator or Manage Server) permission.
  3. Bot Backend Sync: The dashboard updates a shared database (or communicates via a REST/WebSocket API) to change bot settings per guild in real-time.

Ensure you have Node.js 18+ installed and a basic understanding of Next.js App Router and TypeScript.

Step 1: Setting Up the Discord Application

Before writing code, you need an application configured in the Discord Developer Portal.

  1. Navigate to the Discord Developer Portal and click New Application.
  2. Name your application and go to the OAuth2 tab.
  3. Add a redirect URL: http://localhost:3000/api/auth/callback/discord (for local development).
  4. Note your Client ID and Client Secret.
  5. Go to the Bot tab, create a bot, and save the Bot Token. Enable "Server Members Intent" and "Message Content Intent" under Privileged Gateway Intents.

Step 2: Project Initialization and Dependencies

Create a new Next.js project with TypeScript, Tailwind CSS, and the App Router enabled.

npx create-next-app@latest discord-bot-dashboard --typescript --tailwind --app
cd discord-bot-dashboard

Install the required dependencies for authentication, icons, and database management. We will use next-auth for handling the Discord OAuth2 flow.

npm install next-auth@latest lucide-react clsx tailwind-merge
npm install -D prisma @types/node
npx prisma init

Configure your .env.local file in the root directory with your credentials:

DATABASE_URL="postgresql://user:password@localhost:5432/discord_dashboard?schema=public"
NEXTAUTH_SECRET="super-secret-key-change-this"
NEXTAUTH_URL="http://localhost:3000"

DISCORD_CLIENT_ID="your_discord_client_id"
DISCORD_CLIENT_SECRET="your_discord_client_secret"
DISCORD_BOT_TOKEN="your_discord_bot_token"

Step 3: Configuring NextAuth with Discord Provider

NextAuth.js simplifies OAuth2 flows. We need to configure the Discord provider to request the correct scopes (identify and guilds) so we can query the user's servers later.

Create the auth configuration file at app/api/auth/[...nextauth]/route.ts:

import NextAuth from "next-auth";
import DiscordProvider from "next-auth/providers/discord";

export const authOptions = {
  providers: [
    DiscordProvider({
      clientId: process.env.DISCORD_CLIENT_ID!,
      clientSecret: process.env.DISCORD_CLIENT_SECRET!,
      authorization: {
        params: { scope: "identify guilds" },
      },
    }),
  ],
  callbacks: {
    async jwt({ token, account }: { token: any; account: any }) {
      if (account) {
        token.accessToken = account.access_token;
      }
      return token;
    },
    async session({ session, token }: { session: any; token: any }) {
      session.accessToken = token.accessToken;
      return session;
    },
  },
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

To ensure TypeScript recognizes the custom accessToken property on the session, create a type declaration file at types/next-auth.d.ts:

import NextAuth from "next-auth";

declare module "next-auth" {
  interface Session {
    accessToken?: string;
  }
}

Step 4: Building the Authentication and Guild Fetching Logic

To populate the dashboard, we need to fetch the guilds where the authenticated user has administrative privileges. Discord calculates administrative permissions using a bitwise integer. The MANAGE_GUILD permission value is 0x20 (32), and ADMINISTRATOR is 0x8 (8).

Create a utility service in lib/discord.ts to fetch and filter guilds:

export interface DiscordGuild {
  id: string;
  name: string;
  icon: string | null;
  owner: boolean;
  permissions: string;
  features: string[];
}

export async function getUserGuilds(accessToken: string): Promise<DiscordGuild[]> {
  const response = await fetch("https://discord.com/api/v10/users/@me/guilds", {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });

  if (!response.ok) {
    throw new Error("Failed to fetch user guilds from Discord API");
  }

  const guilds: DiscordGuild[] = await response.json();

  // Filter guilds where user has MANAGE_GUILD (0x20) or ADMINISTRATOR (0x8)
  return guilds.filter((guild) => {
    const permissions = BigInt(guild.permissions);
    const ADMINISTRATOR = BigInt(0x8);
    const MANAGE_GUILD = BigInt(0x20);
    return (permissions & ADMINISTRATOR) === ADMINISTRATOR || 
           (permissions & MANAGE_GUILD) === MANAGE_GUILD;
  });
}

Step 5: Designing the Dashboard Layout & Navigation

Next, let's build the core UI layout using Tailwind CSS. We will create a responsive sidebar layout for the dashboard.

Create app/dashboard/layout.tsx:

import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import Link from "next/link";
import { LayoutDashboard, Server, Settings, LogOut } from "lucide-react";

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getServerSession(authOptions);

  if (!session) {
    redirect("/");
  }

  return (
    <div className="flex h-screen bg-zinc-950 text-zinc-100">
      {/* Sidebar */}
      <aside className="w-64 border-r border-zinc-800 bg-zinc-900/50 p-6 flex flex-col justify-between">
        <div>
          <div className="flex items-center gap-2 mb-8">
            <LayoutDashboard className="w-6 h-6 text-indigo-500" />
            <span className="font-bold text-lg tracking-wide">BotDashboard</span>
          </div>

          <nav className="space-y-1">
            <Link
              href="/dashboard"
              className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium hover:bg-zinc-800 transition-colors"
            >
              <Server className="w-4 h-4 text-zinc-400" />
              Guilds
            </Link>
          </nav>
        </div>

        <div className="border-t border-zinc-800 pt-4">
          <div className="flex items-center justify-between">
            <div className="flex items-center gap-3">
              <img
                src={session.user?.image || ""}
                alt="Avatar"
                className="w-8 h-8 rounded-full"
              />
              <span className="text-sm font-medium truncate max-w-[120px]">
                {session.user?.name}
              </span>
            </div>
            <Link
              href="/api/auth/signout"
              className="text-zinc-400 hover:text-red-400 transition-colors"
            >
              <LogOut className="w-4 h-4" />
            </Link>
          </div>
        </div>
      </aside>

      {/* Main Content Area */}
      <main className="flex-1 overflow-y-auto p-8">{children}</main>
    </div>
  );
}

Step 6: Creating the Guild Selection Screen

The main dashboard view (/dashboard) will list all the servers the user can configure. We will fetch the guilds using the server-side NextAuth session and our helper utility.

Create app/dashboard/page.tsx:

import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { getUserGuilds, DiscordGuild } from "@/lib/discord";
import Link from "next/link";
import { Bot, ExternalLink } from "lucide-react";

export default async function DashboardPage() {
  const session = await getServerSession(authOptions);
  let guilds: DiscordGuild[] = [];

  try {
    if (session?.accessToken) {
      guilds = await getUserGuilds(session.accessToken);
    }
  } catch (error) {
    console.error(error);
  }

  return (
    <div>
      <h1 className="text-2xl font-bold mb-2">Select a Server</h1>
      <p className="text-zinc-400 mb-8">
        Choose a Discord server where you have administrative permissions to configure bot settings.
      </p>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {guilds.map((guild) => {
          const iconUrl = guild.icon
            ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`
            : null;

          return (
            <div
              key={guild.id}
              className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 flex flex-col justify-between hover:border-zinc-700 transition-all"
            >
              <div className="flex items-center gap-4 mb-4">
                {iconUrl ? (
                  <img
                    src={iconUrl}
                    alt={guild.name}
                    className="w-12 h-12 rounded-full object-cover"
                  />
                ) : (
                  <div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center font-bold text-lg">
                    {guild.name.charAt(0)}
                  </div>
                )}
                <div>
                  <h2 className="font-semibold text-zinc-100 truncate max-w-[180px]">
                    {guild.name}
                  </h2>
                  <span className="text-xs text-emerald-400 font-medium">
                    Configurable
                  </span>
                </div>
              </div>

              <div className="flex items-center justify-between pt-4 border-t border-zinc-800/80">
                <Link
                  href={`/dashboard/${guild.id}`}
                  className="inline-flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors w-full justify-center"
                >
                  <Bot className="w-4 h-4" />
                  Manage Settings
                </Link>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

Step 7: Building Guild-Specific Settings Pages

Now, let's build a dynamic route to manage settings for a specific guild (/dashboard/[guildId]/page.tsx). We will include form controls for toggling features like welcome messages or setting a custom command prefix.

First, set up Prisma schema models in prisma/schema.prisma to persist settings:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model GuildSettings {
  guildId        String   @id
  prefix         String   @default("!")
  welcomeEnabled Boolean  @default(false)
  welcomeChannel String?
  updatedAt      DateTime @updatedAt
}

Run the migration to create the table in your database:

npx prisma db push

Create a database client utility at lib/db.ts:

import { PrismaClient } from "@prisma/client";

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

Now, create the dynamic guild management page at app/dashboard/[guildId]/page.tsx:

import { prisma } from "@/lib/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { getUserGuilds } from "@/lib/discord";
import { redirect } from "next/navigation";
import { Save } from "lucide-react";

interface PageProps {
  params: {
    guildId: string;
  };
}

export default async function GuildSettingsPage({ params }: PageProps) {
  const session = await getServerSession(authOptions);
  if (!session?.accessToken) redirect("/");

  // Security check: ensure the user actually has access to this guild
  const guilds = await getUserGuilds(session.accessToken);
  const targetGuild = guilds.find((g) => g.id === params.guildId);

  if (!targetGuild) {
    redirect("/dashboard");
  }

  // Fetch or create default settings
  let settings = await prisma.guildSettings.findUnique({
    where: { guildId: params.guildId },
  });

  if (!settings) {
    settings = await prisma.guildSettings.create({
      data: { guildId: params.guildId },
    });
  }

  async function updateSettings(formData: FormData) {
    "use server";
    const prefix = formData.get("prefix") as string;
    const welcomeEnabled = formData.get("welcomeEnabled") === "on";
    const welcomeChannel = formData.get("welcomeChannel") as string;

    await prisma.guildSettings.update({
      where: { guildId: params.guildId },
      data: {
        prefix,
        welcomeEnabled,
        welcomeChannel,
      },
    });
  }

  return (
    <div className="max-w-2xl">
      <h1 className="text-2xl font-bold mb-1">Server Settings</h1>
      <p className="text-zinc-400 mb-8">
        Managing configuration for <span className="text-zinc-200 font-semibold">{targetGuild.name}</span>
      </p>

      <form action={updateSettings} className="space-y-6 bg-zinc-900 border border-zinc-800 p-6 rounded-xl">
        <div>
          <label className="block text-sm font-medium text-zinc-300 mb-2">
            Command Prefix
          </label>
          <input
            type="text"
            name="prefix"
            defaultValue={settings.prefix}
            maxLength={5}
            className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2 text-zinc-100 focus:outline-none focus:border-indigo-500"
          />
          <p className="text-xs text-zinc-500 mt-1">Maximum 5 characters.</p>
        </div>

        <div className="flex items-center justify-between border-t border-zinc-800 pt-6">
          <div>
            <label className="block text-sm font-medium text-zinc-300">
              Welcome Messages
            </label>
            <p className="text-xs text-zinc-500">
              Send a greeting message when new users join the server.
            </p>
          </div>
          <input
            type="checkbox"
            name="welcomeEnabled"
            defaultChecked={settings.welcomeEnabled}
            className="w-4 h-4 accent-indigo-600 rounded bg-zinc-950 border-zinc-800"
          />
        </div>

        <div>
          <label className="block text-sm font-medium text-zinc-300 mb-2">
            Welcome Channel ID
          </label>
          <input
            type="text"
            name="welcomeChannel"
            defaultValue={settings.welcomeChannel || ""}
            placeholder="104938291029384756"
            className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-2 text-zinc-100 focus:outline-none focus:border-indigo-500"
          />
        </div>

        <div className="flex justify-end pt-4 border-t border-zinc-800">
          <button
            type="submit"
            className="inline-flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium px-5 py-2.5 rounded-lg transition-colors"
          >
            <Save className="w-4 h-4" />
            Save Changes
          </button>
        </div>
      </form>
    </div>
  );
}

Step 8: Securing API Routes and Middleware

To prevent unauthorized tampering, ensure that all dashboard endpoints and Server Actions validate authentication and verify that the user possesses correct guild permissions before executing queries.

Create a Next.js middleware file middleware.ts in the root directory to protect all dashboard routes:

export { default } from "next-auth/middleware";

export const config = {
  matcher: ["/dashboard/:path*"],
};

This middleware leverages NextAuth automatically to check for an active user session token, redirecting unauthenticated traffic to the root path before rendering server components.

Best Practices & Production Checklist

Before deploying your dashboard to production platforms like Vercel or Railway, verify the following architecture considerations:

  • Caching Guild Lookups: Discord's API has strict rate limits (/users/@me/guilds). Implement a Redis caching layer (or store tokens safely in an encrypted JWT session) to prevent hitting rate limits during high traffic.
  • Granular Permissions Validation: Never trust client-side guild IDs. Always re-fetch the user's guild memberships via the Discord API inside dynamic route server components and verify their administrative rights using bitwise checks.
  • Database Connection Pooling: Since Next.js uses serverless functions or containerized instances, ensure you configure Prisma connection pooling correctly using PgBouncer to prevent database exhaustion.