I’m working on a Next.js 14 app with the App Router and next-auth@5. I followed the official Next.js Learn guide on authentication, except I replaced email with username in the database.
I set up middleware.ts, auth.config.ts, and auth.ts (merged the last two into one file).
However, the middleware never triggers — I placed console.log statements inside it and none run. There’s no error in the browser or the server, just no effect.
I suspect I’m missing a required export or config somewhere, but I can’t pinpoint it.
Here’s a simplified version of my setup:
// middleware.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export default NextAuth(authConfig).auth;
export const config = {
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};
// auth.ts
import Credentials from 'next-auth/providers/credentials';
import { authConfig } from './auth.config';
import NextAuth from 'next-auth';
import sql from "@/app/lib/data";
import bcrypt from 'bcrypt';
import { z } from 'zod';
async function getUser(email: string) {
try {
const user = await sql`SELECT * FROM users WHERE username=${email}`;
return user[0];
} catch (error) {
console.error('Failed to fetch user:', error);
throw new Error('Failed to fetch user.');
}
}
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
async authorize(credentials) {
const parsedCredentials = z
.object({ email: z.string().email(), password: z.string().min(6) })
.safeParse(credentials);
if (parsedCredentials.success) {
const { email, password } = parsedCredentials.data;
const user = await getUser(email);
if (!user) return null;
const passwordsMatch = await bcrypt.compare(password, user.password);
if (passwordsMatch) return user;
}
console.log('Invalid credentials');
return null;
},
}),
],
});
//auth.config.ts
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/login',
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
if (isOnDashboard) {
if (isLoggedIn) return true;
return false; // Redirect unauthenticated users to login page
} else if (isLoggedIn) {
return Response.redirect(new URL('/dashboard', nextUrl));
}
return true;
},
},
providers: [], // Add providers with an empty array for now
} satisfies NextAuthConfig;
// /src/app/dashboard/page.tsx
export default function Page() {
return (
<>
<p>Dashboard page</p>
</>
)
}
// /src/app/login/page.tsx
"use client";
import { useActionState } from "react";
import { authenticate } from "@/app/lib/actions";
import { useSearchParams } from "next/navigation";
export default function LoginForm() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/dashboard";
const [errorMessage, formAction, isPending] = useActionState(
authenticate,
undefined,
);
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
name="email"
placeholder="Enter your email address"
required
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
name="password"
placeholder="Enter password"
required
minLength={6}
/>
<input type="hidden" name="redirectTo" value={callbackUrl} />
<button aria-disabled={isPending}>Log in</button>
{errorMessage && (
<>
<p>{errorMessage}</p>
</>
)}
</form>
);
}