CtrlK
BlogDocsLog inGet started
Tessl Logo

nextauth-skill

Set up NextAuth.js v5 (Auth.js) with providers, callbacks (jwt, session, signIn), adapter pattern (Prisma/Drizzle), middleware protection, session management, and custom pages.

87

Quality

85%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

SKILL.md
Quality
Evals
Security

NextAuth Skill

Set up NextAuth.js v5 (Auth.js) with providers, callbacks (jwt, session, signIn), adapter pattern (Prisma/Drizzle), middleware protection, session management, and custom pages.

Prerequisites

  • Next.js >= 14 (App Router)
  • Node.js >= 20.x
  • A database (for adapter-based sessions) or JWT-only mode

Scaffold Command

npm install next-auth@beta

# With Prisma adapter
npm install @auth/prisma-adapter

# With Drizzle adapter
npm install @auth/drizzle-adapter

# Generate AUTH_SECRET
npx auth secret

Project Structure

app/
  api/
    auth/
      [...nextauth]/
        route.ts                  # NextAuth route handler
  auth/
    signin/
      page.tsx                    # Custom sign-in page
    error/
      page.tsx                    # Custom error page
  dashboard/
    page.tsx                      # Protected page example
src/
  auth.ts                         # NextAuth configuration (main file)
  auth.config.ts                  # Provider and callback config (edge-compatible)
  middleware.ts                   # Route protection middleware

Key Conventions

  • NextAuth v5 uses a single auth.ts config file that exports handlers, auth, signIn, and signOut.
  • Split config into auth.config.ts (edge-compatible, no DB adapter) and auth.ts (full config with adapter) for middleware compatibility.
  • Use database adapters (Prisma/Drizzle) for persistent sessions. Use JWT strategy for stateless/edge deployments.
  • Protect routes via middleware (for page-level auth) and auth() in server components/API routes (for data-level auth).
  • Custom pages (signin, error) are defined in the NextAuth config, not by overriding the default routes.
  • Use callbacks.jwt to add custom claims, callbacks.session to expose them to the client.

Essential Patterns

Auth Config (Edge-Compatible) (src/auth.config.ts)

import type { NextAuthConfig } from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";

export const authConfig: NextAuthConfig = {
  pages: {
    signIn: "/auth/signin",
    error: "/auth/error",
  },

  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
    Credentials({
      name: "credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        // Validate credentials against your database
        // This runs server-side only
        const { email, password } = credentials as {
          email: string;
          password: string;
        };

        // Replace with actual DB lookup + bcrypt compare
        const user = await findUserByCredentials(email, password);
        if (!user) return null;

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
          image: user.avatar,
        };
      },
    }),
  ],

  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isProtected = nextUrl.pathname.startsWith("/dashboard");
      if (isProtected && !isLoggedIn) {
        return Response.redirect(new URL("/auth/signin", nextUrl));
      }
      return true;
    },

    jwt({ token, user, trigger, session }) {
      // Add custom fields to JWT on sign-in
      if (user) {
        token.id = user.id;
        token.role = (user as any).role ?? "user";
      }
      // Handle session update (e.g., after profile change)
      if (trigger === "update" && session) {
        token.name = session.name;
      }
      return token;
    },

    session({ session, token }) {
      // Expose custom fields to client session
      if (token) {
        session.user.id = token.id as string;
        session.user.role = token.role as string;
      }
      return session;
    },

    signIn({ user, account, profile }) {
      // Block sign-in for unverified emails (OAuth)
      if (account?.provider === "google") {
        return (profile as any)?.email_verified === true;
      }
      return true;
    },
  },

  session: {
    strategy: "jwt",
    maxAge: 30 * 24 * 60 * 60, // 30 days
  },
};

// Helper — replace with actual implementation
async function findUserByCredentials(email: string, password: string) {
  // const user = await prisma.user.findUnique({ where: { email } });
  // if (!user || !await bcrypt.compare(password, user.password)) return null;
  // return user;
  return null;
}

Full Auth Config with Adapter (src/auth.ts)

import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { authConfig } from "./auth.config";

export const {
  handlers,
  auth,
  signIn,
  signOut,
} = NextAuth({
  adapter: PrismaAdapter(prisma),
  ...authConfig,
});

Route Handler (app/api/auth/[...nextauth]/route.ts)

import { handlers } from "@/auth";

export const { GET, POST } = handlers;

Middleware (src/middleware.ts)

import NextAuth from "next-auth";
import { authConfig } from "@/auth.config";

const { auth } = NextAuth(authConfig);

export default auth;

export const config = {
  // Match all routes except static files and API routes that don't need auth
  matcher: [
    "/((?!api/public|_next/static|_next/image|favicon.ico).*)",
  ],
};

Type Augmentation (types/next-auth.d.ts)

import "next-auth";

declare module "next-auth" {
  interface User {
    role?: string;
  }

  interface Session {
    user: {
      id: string;
      role: string;
      email: string;
      name: string;
      image?: string;
    };
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    id: string;
    role: string;
  }
}

Server Component — Access Session

// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth();

  if (!session?.user) {
    redirect("/auth/signin");
  }

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Welcome, {session.user.name}</p>
      <p>Role: {session.user.role}</p>
    </div>
  );
}

Client Component — Session Hook

"use client";

import { useSession, signIn, signOut } from "next-auth/react";

export function AuthButton() {
  const { data: session, status } = useSession();

  if (status === "loading") {
    return <div>Loading...</div>;
  }

  if (session) {
    return (
      <div>
        <p>Signed in as {session.user.email}</p>
        <button onClick={() => signOut()}>Sign Out</button>
      </div>
    );
  }

  return <button onClick={() => signIn()}>Sign In</button>;
}

Session Provider (Layout)

// app/layout.tsx
import { SessionProvider } from "next-auth/react";
import { auth } from "@/auth";

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth();

  return (
    <html lang="en">
      <body>
        <SessionProvider session={session}>
          {children}
        </SessionProvider>
      </body>
    </html>
  );
}

Custom Sign-In Page (app/auth/signin/page.tsx)

import { signIn } from "@/auth";

export default function SignInPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-md space-y-4 rounded-lg border p-8">
        <h1 className="text-2xl font-bold">Sign In</h1>

        <form
          action={async () => {
            "use server";
            await signIn("google", { redirectTo: "/dashboard" });
          }}
        >
          <button type="submit" className="w-full rounded bg-blue-600 px-4 py-2 text-white">
            Continue with Google
          </button>
        </form>

        <form
          action={async () => {
            "use server";
            await signIn("github", { redirectTo: "/dashboard" });
          }}
        >
          <button type="submit" className="w-full rounded bg-gray-800 px-4 py-2 text-white">
            Continue with GitHub
          </button>
        </form>

        <div className="relative py-4">
          <div className="absolute inset-0 flex items-center">
            <div className="w-full border-t" />
          </div>
          <div className="relative flex justify-center text-sm">
            <span className="bg-white px-2 text-gray-500">Or</span>
          </div>
        </div>

        <form
          action={async (formData) => {
            "use server";
            await signIn("credentials", {
              email: formData.get("email"),
              password: formData.get("password"),
              redirectTo: "/dashboard",
            });
          }}
          className="space-y-3"
        >
          <input
            name="email"
            type="email"
            placeholder="Email"
            required
            className="w-full rounded border px-3 py-2"
          />
          <input
            name="password"
            type="password"
            placeholder="Password"
            required
            className="w-full rounded border px-3 py-2"
          />
          <button type="submit" className="w-full rounded bg-green-600 px-4 py-2 text-white">
            Sign In with Email
          </button>
        </form>
      </div>
    </div>
  );
}

Server Action — Protected Route

"use server";

import { auth } from "@/auth";

export async function getSecretData() {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");
  if (session.user.role !== "admin") throw new Error("Forbidden");

  return { secret: "admin-only data" };
}

Environment Variables (.env.local)

AUTH_SECRET=generate-with-npx-auth-secret
AUTH_URL=http://localhost:3000

GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret

DATABASE_URL=postgresql://app:secret@localhost:5432/myapp

Common Commands

# Generate AUTH_SECRET
npx auth secret

# Start development
npm run dev

# Test auth flow
open http://localhost:3000/auth/signin

# Debug: view session
open http://localhost:3000/api/auth/session

Integration Notes

  • Prisma adapter: Requires User, Account, Session, and VerificationToken models in your Prisma schema. See prisma-schema-starter skill for the complete schema definition.
  • Drizzle adapter: See drizzle-starter skill and add the required auth tables (users, accounts, sessions, verification_tokens).
  • JWT Auth: For APIs consumed by non-browser clients, pair with jwt-auth-skill for token-based auth. NextAuth is best for browser-based Next.js apps.
  • React: The frontend uses useSession() from next-auth/react in client components and auth() in server components.
  • Testing: Mock auth() in tests. For E2E tests with Playwright, set auth cookies programmatically before visiting protected pages.
Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.