Introduction to Shadcn UI Authentication
Shadcn UI has rapidly become one of the most popular component libraries in the React ecosystem. Unlike traditional component libraries that ship as npm packages, Shadcn UI gives you the actual source code of each component, allowing you to copy, paste, and modify them directly in your project. While Shadcn UI itself does not include built-in authentication, its flexible architecture makes it an excellent foundation for building secure authentication flows using JWT tokens, session-based authentication, and OAuth integration.
This tutorial walks you through implementing a complete authentication system in a Next.js application using Shadcn UI components. We will cover JWT-based authentication, session management, and OAuth integration with popular providers like GitHub and Google.
What Is Shadcn UI Authentication?
Shadcn UI authentication refers to the practice of building authentication user interfaces and flows using the Shadcn UI component collection. Because Shadcn UI provides primitives such as buttons, inputs, labels, cards, dialogs, and form components, developers can construct polished login, registration, password reset, and OAuth consent screens without relying on a rigid auth UI kit.
The authentication logic itself is typically handled by libraries such as NextAuth.js (Auth.js), Lucia, Clerk, or custom implementations using jose for JWT signing. Shadcn UI simply provides the visual layer that sits on top of these authentication mechanisms.
Why It Matters
- Full ownership of code: Since you own the component source, you can customize every aspect of the auth UI without fighting library abstractions.
- Consistent design system: Authentication screens match the rest of your application because they use the same Tailwind CSS variables and design tokens.
- Accessibility built-in: Shadcn UI components are built on Radix UI primitives, ensuring keyboard navigation and ARIA support in auth forms.
- Framework agnostic potential: While commonly used with Next.js, the components work in any React framework, giving you flexibility in your auth architecture.
- Reduced bundle size: You only include the components you actually use, keeping your auth pages lightweight.
Prerequisites and Project Setup
Before we begin, ensure you have Node.js 18 or higher installed. We will use Next.js with the App Router, Tailwind CSS, and Shadcn UI. Run the following commands to scaffold a new project:
npx create-next-app@latest my-auth-app
cd my-auth-app
npx shadcn@latest init
During the Shadcn UI initialization, select your preferred style (default or New York), base color, and CSS variables. Once initialized, add the components we will need for our authentication forms:
npx shadcn@latest add button input label card form
npx shadcn@latest add sonner avatar separator
Next, install the authentication dependencies. We will use Auth.js (formerly NextAuth.js) for OAuth and session management, and jose for custom JWT handling:
npm install next-auth@beta jose
npm install -D @types/node
Create a .env.local file in the root of your project with the following environment variables:
AUTH_SECRET=your-super-secret-key-at-least-32-chars
AUTH_GITHUB_ID=your-github-oauth-client-id
AUTH_GITHUB_SECRET=your-github-oauth-client-secret
AUTH_GOOGLE_ID=your-google-oauth-client-id
AUTH_GOOGLE_SECRET=your-google-oauth-client-secret
Building the Authentication UI with Shadcn UI
Let us start by creating a reusable card-based layout for our authentication pages. Create a file at components/auth/auth-form-wrapper.tsx:
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { ReactNode } from "react"
interface AuthFormWrapperProps {
title: string
description: string
children: ReactNode
footer?: ReactNode
}
export function AuthFormWrapper({ title, description, children, footer }: AuthFormWrapperProps) {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
{footer && <CardFooter className="flex flex-col gap-4">{footer}</CardFooter>}
</Card>
</div>
)
}
Now let us create the login form component at components/auth/login-form.tsx. This form uses the Shadcn UI form component built on react-hook-form and Zod for validation:
"use client"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { toast } from "sonner"
const loginSchema = z.object({
email: z.string().email("Please enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
})
type LoginValues = z.infer<typeof loginSchema>
export function LoginForm() {
const [isLoading, setIsLoading] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginValues>({
resolver: zodResolver(loginSchema),
})
async function onSubmit(values: LoginValues) {
setIsLoading(true)
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || "Login failed")
}
toast.success("Logged in successfully")
window.location.href = "/dashboard"
} catch (error) {
toast.error(error instanceof Error ? error.message : "Something went wrong")
} finally {
setIsLoading(false)
}
}
return (
<div className="space-y-4">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="you@example.com" {...register("email")} />
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" placeholder="********" {...register("password")} />
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</Button>
</form>
<div className="relative">
<Separator />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
Or continue with
</span>
</div>
<div className="grid grid-cols-2 gap-4">
<Button variant="outline" onClick={() => signInWithProvider("github")}>
GitHub
</Button>
<Button variant="outline" onClick={() => signInWithProvider("google")}>
Google
</Button>
</div>
</div>
)
}
function signInWithProvider(provider: string) {
window.location.href = `/api/auth/signin/${provider}`
}
Create the login page at app/login/page.tsx:
import { AuthFormWrapper } from "@/components/auth/auth-form-wrapper"
import { LoginForm } from "@/components/auth/login-form"
import Link from "next/link"
export default function LoginPage() {
return (
<AuthFormWrapper
title="Welcome back"
description="Enter your credentials to access your account"
footer={
<p className="text-sm text-muted-foreground">
Don't have an account?{" "}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</p>
}
>
<LoginForm />
</AuthFormWrapper>
)
}
Implementing JWT-Based Authentication
JWT (JSON Web Tokens) provide a stateless way to handle authentication. The server issues a signed token containing user claims, and the client includes this token in subsequent requests. Let us implement a custom JWT authentication system using the jose library.
First, create a utility file at lib/jwt.ts for token generation and verification:
import { SignJWT, jwtVerify } from "jose"
const secretKey = process.env.AUTH_SECRET!
const encodedSecret = new TextEncoder().encode(secretKey)
export interface JWTPayload {
userId: string
email: string
role: string
}
export async function signJWT(payload: JWTPayload, expiresIn: string = "7d"): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(expiresIn)
.setIssuer("my-auth-app")
.setAudience("my-auth-app-users")
.sign(encodedSecret)
}
export async function verifyJWT(token: string): Promise<JWTPayload | null> {
try {
const { payload } = await jwtVerify(token, encodedSecret, {
issuer: "my-auth-app",
audience: "my-auth-app-users",
})
return payload as unknown as JWTPayload
} catch (error) {
console.error("JWT verification failed:", error)
return null
}
}
export async function refreshToken(token: string): Promise<string | null> {
const payload = await verifyJWT(token)
if (!payload) return null
return signJWT({ userId: payload.userId, email: payload.email, role: payload.role })
}
Now create the login API route at app/api/auth/login/route.ts. This route validates credentials, issues a JWT, and sets it as an HTTP-only cookie:
import { NextRequest, NextResponse } from "next/server"
import { signJWT } from "@/lib/jwt"
// In production, replace this with a real database lookup
const mockUser = {
id: "user_123",
email: "user@example.com",
password: "$2a$10$hashedPasswordHere",
name: "Test User",
role: "user",
}
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json()
if (!email || !password) {
return NextResponse.json(
{ message: "Email and password are required" },
{ status: 400 }
)
}
// Replace with actual database query and bcrypt comparison
if (email !== mockUser.email || password !== "password123") {
return NextResponse.json(
{ message: "Invalid credentials" },
{ status: 401 }
)
}
const token = await signJWT({
userId: mockUser.id,
email: mockUser.email,
role: mockUser.role,
})
const response = NextResponse.json({
user: { id: mockUser.id, email: mockUser.email, name: mockUser.name },
message: "Login successful",
})
response.cookies.set("auth-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
})
return response
} catch (error) {
console.error("Login error:", error)
return NextResponse.json(
{ message: "Internal server error" },
{ status: 500 }
)
}
}
Create a middleware file at middleware.ts in the project root to protect routes by verifying the JWT on every request:
import { NextRequest, NextResponse } from "next/server"
import { verifyJWT } from "@/lib/jwt"
const protectedRoutes = ["/dashboard", "/profile", "/settings"]
const authRoutes = ["/login", "/register"]
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const token = request.cookies.get("auth-token")?.value
const isProtectedRoute = protectedRoutes.some(route => pathname.startsWith(route))
const isAuthRoute = authRoutes.some(route => pathname.startsWith(route))
if (isProtectedRoute) {
if (!token) {
return NextResponse.redirect(new URL("/login", request.url))
}
const payload = await verifyJWT(token)
if (!payload) {
const response = NextResponse.redirect(new URL("/login", request.url))
response.cookies.delete("auth-token")
return response
}
// Add user info to headers for server components to read
const requestHeaders = new Headers(request.headers)
requestHeaders.set("x-user-id", payload.userId)
requestHeaders.set("x-user-email", payload.email)
requestHeaders.set("x-user-role", payload.role)
return NextResponse.next({
request: { headers: requestHeaders },
})
}
if (isAuthRoute && token) {
const payload = await verifyJWT(token)
if (payload) {
return NextResponse.redirect(new URL("/dashboard", request.url))
}
}
return NextResponse.next()
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}
Session Management
While JWTs are stateless, you often need session management to track user state across your application. There are two primary approaches: cookie-based sessions and database-backed sessions. Let us implement a server-side session helper that reads the JWT from cookies and exposes user data to your components.
Create lib/session.ts:
import { cookies } from "next/headers"
import { verifyJWT, JWTPayload } from "./jwt"
export interface Session {
user: {
id: string
email: string
role: string
}
expires: Date
}
export async function getSession(): Promise<Session | null> {
const cookieStore = await cookies()
const token = cookieStore.get("auth-token")?.value
if (!token) return null
const payload = await verifyJWT(token)
if (!payload) return null
return {
user: {
id: payload.userId,
email: payload.email,
role: payload.role,
},
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
}
}
export async function getCurrentUser() {
const session = await getSession()
return session?.user ?? null
}
export async function requireAuth(): Promise<NonNullable<Awaited<ReturnType<typeof getSession>>>> {
const session = await getSession()
if (!session) {
throw new Error("Unauthorized")
}
return session
}
export async function requireAdmin() {
const session = await requireAuth()
if (session.user.role !== "admin") {
throw new Error("Forbidden: Admin access required")
}
return session
}
Use the session in a server component for your dashboard page at app/dashboard/page.tsx:
import { getCurrentUser } from "@/lib/session"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { redirect } from "next/navigation"
export default async function DashboardPage() {
const user = await getCurrentUser()
if (!user) {
redirect("/login")
}
const initials = user.email
.split("@")[0]
.split(".")
.map(part => part[0]?.toUpperCase())
.join("")
return (
<div className="container mx-auto py-10">
<div className="flex items-center gap-4 mb-8">
<Avatar className="h-12 w-12">
<AvatarImage src="" alt={user.email} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-muted-foreground">{user.email}</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>User ID</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm font-mono">{user.id}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Role</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm capitalize">{user.role}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Status</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-green-600">Active Session</p>
</CardContent>
</Card>
</div>
</div>
)
}
For logging out, create app/api/auth/logout/route.ts:
import { NextResponse } from "next/server"
export async function POST() {
const response = NextResponse.json({ message: "Logged out successfully" })
response.cookies.delete("auth-token")
return response
}
OAuth Integration with Auth.js
OAuth allows users to sign in using their existing accounts on platforms like GitHub, Google, or Twitter. Auth.js (NextAuth.js) is the most popular library for handling OAuth in Next.js applications. Let us integrate it alongside our JWT setup.
Create the Auth.js configuration at auth.ts in the project root:
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import Google from "next-auth/providers/google"
import { signJWT } from "@/lib/jwt"
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
],
pages: {
signIn: "/login",
},
callbacks: {
async jwt({ token, account, user }) {
if (user && account) {
// Issue our custom JWT when user signs in via OAuth
const customToken = await signJWT({
userId: user.id || token.sub!,
email: user.email!,
role: "user",
})
token.customToken = customToken
}
return token
},
async session({ session, token }) {
if (token.customToken) {
session.customToken = token.customToken as string
session.user.id = token.sub!
}
return session
},
},
session: {
strategy: "jwt",
maxAge: 60 * 60 * 24 * 7,
},
})
Create the Auth.js API route handler at app/api/auth/[...nextauth]/route.ts:
import { handlers } from "@/auth"
export const { GET, POST } = handlers
Now update the login form to use Auth.js client-side sign-in. Create components/auth/oauth-buttons.tsx:
"use client"
import { Button } from "@/components/ui/button"
import { useState } from "react"
export function OAuthButtons() {
const [loadingProvider, setLoadingProvider] = useState<string | null>(null)
function handleOAuthSignIn(provider: string) {
setLoadingProvider(provider)
window.location.href = `/api/auth/signin/${provider}`
}
return (
<div className="grid grid-cols-2 gap-4">
<Button
variant="outline"
onClick={() => handleOAuthSignIn("github")}
disabled={loadingProvider !== null}
>
{loadingProvider === "github" ? "Connecting..." : "GitHub"}
</Button>
<Button
variant="outline"
onClick={() => handleOAuthSignIn("google")}
disabled={loadingProvider !== null}
>
{loadingProvider === "google" ? "Connecting..." : "Google"}
</Button>
</div>
)
}
To synchronize the Auth.js session with our custom JWT cookie, create a server action at app/actions/sync-session.ts:
"use server"
import { auth } from "@/auth"
import { cookies } from "next/headers"
export async function syncSessionCookie() {
const session = await auth()
if (!session?.customToken) return
const cookieStore = await cookies()
cookieStore.set("auth-token", session.customToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
path: "/",
})
}
Call this server action in a callback page after OAuth sign-in. Create app/auth/callback/page.tsx:
import { syncSessionCookie } from "@/app/actions/sync-session"
import { redirect } from "next/navigation"
export default async function AuthCallbackPage() {
await syncSessionCookie()
redirect("/dashboard")
}
Building a Registration Form
A complete authentication system needs a registration flow. Create components/auth/register-form.tsx:
"use client"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "sonner"
const registerSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
})
type RegisterValues = z.infer<typeof registerSchema>
export function RegisterForm() {
const [isLoading, setIsLoading] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterValues>({
resolver: zodResolver(registerSchema),
})
async function onSubmit(values: RegisterValues) {
setIsLoading(true)
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: values.name,
email: values.email,
password: values.password,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || "Registration failed")
}
toast.success("Account created successfully. Please sign in.")
window.location.href = "/login"
} catch (error) {
toast.error(error instanceof Error ? error.message : "Something went wrong")
} finally {
setIsLoading(false)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input id="name" placeholder="John Doe" {...register("name")} />
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="you@example.com" {...register("email")} />
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" placeholder="********" {...register("password")} />
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input id="confirmPassword" type="password" placeholder="********" {...register("confirmPassword")} />
{errors.confirmPassword && <p className="text-sm text-destructive">{errors.confirmPassword.message}</p>}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Creating account..." : "Create account"}
</Button>
</form>
)
}
Create the registration API route at app/api/auth/register/route.ts:
import { NextRequest, NextResponse } from "next/server"
import { hash } from "bcryptjs"
import { signJWT } from "@/lib/jwt"
export async function POST(request: NextRequest) {
try {
const { name, email, password } = await request.json()
if (!name || !email || !password) {
return NextResponse.json(
{ message: "All fields are required" },
{ status: 400 }
)
}
// Check if user already exists (replace with database query)
// const existingUser = await db.user.findUnique({ where: { email } })
// if (existingUser) {
// return NextResponse.json({ message: "Email already registered" }, { status: 409 })
// }
const hashedPassword = await hash(password, 12)
// Create user in database (replace with actual database insert)
const newUser = {
id: `user_${Date.now()}`,
name,
email,
password: hashedPassword,
role: "user",
}
const token = await signJWT({
userId: newUser.id,
email: newUser.email,
role: newUser.role,
})
const response = NextResponse.json({
user: { id: newUser.id, email: newUser.email, name: newUser.name },
message: "Registration successful",
})
response.cookies.set("auth-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
path: "/",
})
return response
} catch (error) {
console.error("Registration error:", error)
return NextResponse.json(
{ message: "Internal server error" },
{ status: 500 }
)
}
}
Install bcryptjs for password hashing:
npm install bcryptjs
npm install -D @types/bcryptjs
Best Practices
Security Best Practices
- Always use HTTP-only cookies: Never store JWTs in localStorage or sessionStorage, as they are vulnerable to XSS attacks. HTTP-only cookies prevent JavaScript from accessing the token.
- Set appropriate cookie flags: Use
secure: truein production,sameSite: "lax"or"strict", and always set thepath. - Use strong secrets: Your
AUTH_SECRETshould be at least 32 characters of cryptographically random data. Generate one withopenssl rand -base64 32. - Hash passwords properly: Use bcrypt or argon2 with a cost factor of at least 12. Never store plaintext passwords.
- Implement rate limiting: Protect your login and registration endpoints from brute force attacks using a library like
upstash/ratelimit. - Validate all inputs: Use Zod schemas on both client and server. Never trust client-side validation alone.
JWT Best Practices
- Set expiration times: Always include an
expclaim. Seven days is a reasonable default for web applications. - Include issuer and audience claims: These prevent token reuse across different services or environments.
- Implement token refresh: For long-lived sessions, use short-lived access tokens and longer-lived refresh tokens.
- Keep payloads minimal: Only include necessary claims. Do not store sensitive data like passwords in the JWT payload.
OAuth Best Practices
- Use state parameters: Auth.js handles this automatically, but if implementing OAuth manually, always include a state parameter to prevent CSRF attacks.
- Handle account linking: Decide how to handle users who sign in with OAuth but already have an email-based account. Linking accounts prevents duplicate users.
- Store provider data: Save the provider name and provider account ID in your database for future reference and potential account unlinking.
- Provide multiple providers: Offer at least two OAuth options alongside email/password to give users flexibility.
UI and UX Best Practices
- Show loading states: Disable buttons and show spinners during authentication requests to prevent double submissions.
- Provide clear error messages: Use toast notifications or inline error text to communicate authentication failures. Avoid revealing whether an email exists in the system.
- Redirect after login: Send users back to the page they were trying to access before being redirected to login.
- Test keyboard navigation: Shadcn UI components support keyboard navigation out of the box, but verify that your custom auth flows work without a mouse.
Conclusion
Building authentication with Shadcn UI gives you the best of both worlds: beautiful, accessible, fully-owned UI components paired with a robust authentication backend of your choosing. By combining JWT tokens for stateless authentication, server-side session management for secure user context, and OAuth integration through Auth.js, you can create a production-ready authentication system that scales with your application. The key is to leverage Shadcn UI's composable components for the presentation layer while keeping your security logic in well-tested server-side code. Remember to follow security best practices around cookie handling, password hashing, and input validation, and your authentication system will provide a safe and seamless experience for your users.