⚠️ MANDATORY: COPY THIS CODE EXACTLY - DO NOT MODIFY
This skill implements NextAuth.js (Auth.js) v5 with Next.js App Router.
Step 1: Install dependencies
```bash npm install next-auth@beta @auth/drizzle-adapter drizzle-orm ```
Step 2: Auth configuration (copy exactly)
```typescript // lib/auth.ts import NextAuth from 'next-auth'; import Google from 'next-auth/providers/google'; import GitHub from 'next-auth/providers/github'; import Credentials from 'next-auth/providers/credentials'; import { DrizzleAdapter } from '@auth/drizzle-adapter'; import { db } from '@/lib/db'; import bcrypt from 'bcryptjs';
export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: DrizzleAdapter(db), 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) { if (!credentials?.email || !credentials?.password) return null;
// Replace with your user lookup logic
const user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.email, credentials.email as string),
});
if (!user || !user.password) return null;
const isValid = await bcrypt.compare(credentials.password as string, user.password);
if (!isValid) return null;
return { id: user.id, name: user.name, email: user.email };
},
}),
], session: { strategy: 'jwt' }, pages: { signIn: '/login', error: '/login', }, callbacks: { async jwt({ token, user }) { if (user) { token.id = user.id; } return token; }, async session({ session, token }) { if (session.user) { session.user.id = token.id as string; } return session; }, }, }); ```
Step 3: API route handler (copy exactly)
```typescript // app/api/auth/[...nextauth]/route.ts import { handlers } from '@/lib/auth'; export const { GET, POST } = handlers; ```
Step 4: Middleware for protected routes (copy exactly)
```typescript // middleware.ts import { auth } from '@/lib/auth'; import { NextResponse } from 'next/server';
export default auth((req) => { const isLoggedIn = !!req.auth; const isAuthPage = req.nextUrl.pathname.startsWith('/login') || req.nextUrl.pathname.startsWith('/register'); const isProtectedRoute = req.nextUrl.pathname.startsWith('/dashboard') || req.nextUrl.pathname.startsWith('/settings');
// Redirect logged-in users away from auth pages if (isLoggedIn && isAuthPage) { return NextResponse.redirect(new URL('/dashboard', req.nextUrl)); }
// Redirect unauthenticated users to login if (!isLoggedIn && isProtectedRoute) { return NextResponse.redirect(new URL('/login', req.nextUrl)); }
return NextResponse.next(); });
export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], }; ```
Step 5: Session provider (copy exactly)
```tsx // app/providers.tsx 'use client'; import { SessionProvider } from 'next-auth/react';
export function Providers({ children }: { children: React.ReactNode }) { return <SessionProvider>{children}</SessionProvider>; } ```
Step 6: Wrap app in providers
```tsx // app/layout.tsx import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <Providers>{children}</Providers> </body> </html> ); } ```
Step 7: Login page (copy exactly)
```tsx // app/login/page.tsx 'use client'; import { signIn } from 'next-auth/react'; import { useState } from 'react'; import { useRouter } from 'next/navigation';
export default function LoginPage() { const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false);
const handleCredentialsLogin = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError('');
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Invalid email or password');
setLoading(false);
} else {
router.push('/dashboard');
}
};
return ( <div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg"> <div className="text-center"> <h2 className="text-3xl font-bold">Sign in</h2> <p className="mt-2 text-gray-600">Welcome back</p> </div>
{/* OAuth Buttons */}
<div className="space-y-3">
<button
onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continue with Google
</button>
<button
onClick={() => signIn('github', { callbackUrl: '/dashboard' })}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Continue with GitHub
</button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with email</span>
</div>
</div>
{/* Credentials Form */}
<form onSubmit={handleCredentialsLogin} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-lg">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-black text-white rounded-lg hover:bg-gray-800 transition disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<p className="text-center text-sm text-gray-600">
Don't have an account?{' '}
<a href="/register" className="font-medium text-black hover:underline">
Sign up
</a>
</p>
</div>
</div>
); } ```
Step 8: Registration page (copy exactly)
```tsx // app/register/page.tsx 'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation';
export default function RegisterPage() { const router = useRouter(); const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false);
const handleRegister = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError('');
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Registration failed');
}
router.push('/login?registered=true');
} catch (err: any) {
setError(err.message);
setLoading(false);
}
};
return ( <div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg"> <div className="text-center"> <h2 className="text-3xl font-bold">Create account</h2> <p className="mt-2 text-gray-600">Get started for free</p> </div>
<form onSubmit={handleRegister} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-lg">
{error}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-black text-white rounded-lg hover:bg-gray-800 transition disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
<p className="text-center text-sm text-gray-600">
Already have an account?{' '}
<a href="/login" className="font-medium text-black hover:underline">
Sign in
</a>
</p>
</div>
</div>
); } ```
Step 9: Registration API route (copy exactly)
```typescript // app/api/auth/register/route.ts import { NextResponse } from 'next/server'; import bcrypt from 'bcryptjs'; import { db } from '@/lib/db'; import { users } from '@/lib/db/schema';
export async function POST(req: Request) { try { const { name, email, password } = await req.json();
if (!name || !email || !password) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
// Check if user exists
const existing = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.email, email),
});
if (existing) {
return NextResponse.json({ error: 'Email already registered' }, { status: 400 });
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
await db.insert(users).values({
id: crypto.randomUUID(),
name,
email,
password: hashedPassword,
});
return NextResponse.json({ success: true });
} catch (error) { console.error('Registration error:', error); return NextResponse.json({ error: 'Registration failed' }, { status: 500 }); } } ```
Step 10: User profile component (copy exactly)
```tsx // components/user-profile.tsx 'use client'; import { useSession, signOut } from 'next-auth/react'; import Image from 'next/image';
export function UserProfile() { const { data: session, status } = useSession();
if (status === 'loading') { return <div className="animate-pulse h-10 w-10 bg-gray-200 rounded-full" />; }
if (!session?.user) { return ( <a href="/login" className="px-4 py-2 bg-black text-white rounded-lg hover:bg-gray-800"> Sign in </a> ); }
return ( <div className="flex items-center gap-3"> {session.user.image ? ( <Image src={session.user.image} alt={session.user.name || 'User'} width={40} height={40} className="rounded-full" /> ) : ( <div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center"> {session.user.name?.[0] || session.user.email?.[0] || '?'} </div> )} <div className="hidden sm:block"> <p className="text-sm font-medium">{session.user.name}</p> <p className="text-xs text-gray-500">{session.user.email}</p> </div> <button onClick={() => signOut({ callbackUrl: '/' })} className="px-3 py-1.5 text-sm text-gray-600 hover:text-black" > Sign out </button> </div> ); } ```
Step 11: Server-side session access (copy exactly)
```tsx // app/dashboard/page.tsx import { auth } from '@/lib/auth'; import { redirect } from 'next/navigation';
export default async function DashboardPage() { const session = await auth();
if (!session?.user) { redirect('/login'); }
return ( <div className="min-h-screen p-8"> <h1 className="text-2xl font-bold mb-4">Dashboard</h1> <p className="text-gray-600">Welcome back, {session.user.name || session.user.email}!</p>
<div className="mt-8 p-6 bg-white rounded-xl shadow">
<h2 className="font-semibold mb-4">Your Profile</h2>
<dl className="space-y-2">
<div>
<dt className="text-sm text-gray-500">Name</dt>
<dd>{session.user.name || 'Not set'}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Email</dt>
<dd>{session.user.email}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">User ID</dt>
<dd className="font-mono text-sm">{session.user.id}</dd>
</div>
</dl>
</div>
</div>
); } ```
Step 12: Database schema for users (copy exactly)
```typescript // lib/db/schema.ts import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', { id: text('id').primaryKey(), name: text('name'), email: text('email').unique().notNull(), emailVerified: integer('email_verified', { mode: 'timestamp' }), image: text('image'), password: text('password'), // For credentials provider createdAt: integer('created_at', { mode: 'timestamp' }).default(Date.now()), });
export const accounts = sqliteTable('accounts', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), type: text('type').notNull(), provider: text('provider').notNull(), providerAccountId: text('provider_account_id').notNull(), refresh_token: text('refresh_token'), access_token: text('access_token'), expires_at: integer('expires_at'), token_type: text('token_type'), scope: text('scope'), id_token: text('id_token'), session_state: text('session_state'), });
export const sessions = sqliteTable('sessions', { id: text('id').primaryKey(), sessionToken: text('session_token').unique().notNull(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), expires: integer('expires', { mode: 'timestamp' }).notNull(), }); ```
Environment variables needed:
- •AUTH_SECRET (run: openssl rand -base64 32)
- •GOOGLE_CLIENT_ID (from Google Cloud Console)
- •GOOGLE_CLIENT_SECRET
- •GITHUB_CLIENT_ID (from GitHub Developer Settings)
- •GITHUB_CLIENT_SECRET
❌ DO NOT:
- •Never store plain text passwords
- •Never expose AUTH_SECRET to client
- •Never skip the middleware for protected routes
- •Never trust client-side auth checks alone
✅ DO:
- •Always hash passwords with bcrypt (12+ rounds)
- •Always use middleware for route protection
- •Always validate session server-side for sensitive operations
- •Use secure, httpOnly cookies (NextAuth handles this)
- •Implement rate limiting on login attempts