launch.json
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"name": "Run Script: dev",
"request": "launch",
"command": "npm run dev",
"cwd": "${workspaceFolder}"
}
]
}
page.tsx
'use client'
import { FormEventHandler, useCallback, useState } from 'react'
export default function Login() {
const [error, setError] = useState('')
const onFormSubmit = useCallback<FormEventHandler<HTMLFormElement>>(
async (e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/login', {
method: 'POST',
body: formData,
})
if (!res.ok) {
setError('Sorry! something went wrong.')
return
}
const json = (await res.json()) as { success: boolean }
if (!json.success) {
setError('Invalid credentials.')
return
}
location.href = '/dashboard'
},
[]
)
return (
<form className="pt-10" onSubmit={onFormSubmit}>
<div className="w-96 mx-auto border border-gray-300 rounded-md space-y-3 px-6 py-8">
<div className="space-y-5">
<div className="pb-3">
<h2 className="text-xl font-bold text-center">Login</h2>
</div>
<div className="space-y-1">
<label htmlFor="username" className="text-sm font-bold select-none">
Username
</label>
<input
id="username"
name="username"
type="text"
required
tabIndex={1}
placeholder="Username"
className="block w-full text-sm p-3 bg-white border border-gray-300 placeholder:text-gray-400 focus:outline-none focus:border-gray-400 rounded"
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="text-sm font-bold select-none">
Password
</label>
<input
id="password"
name="password"
type="password"
required
tabIndex={2}
placeholder="Password"
className="block w-full text-sm p-3 bg-white border border-gray-300 placeholder:text-gray-400 focus:outline-none focus:border-gray-400 rounded"
/>
</div>
<label className="inline-flex items-center space-x-1.5">
<input type="checkbox" name="remember" tabIndex={3} />
<span className="text-gray-500 text-xs leading-5 font-bold cursor-pointer select-none">
Remember me
</span>
</label>
<div className="flex justify-end">
<button
type="submit"
tabIndex={4}
className="bg-white h-10 border border-gray-400 text-gray-500 hover:border-gray-400 hover:text-black rounded text-sm font-medium px-3"
>
Log In
</button>
</div>
{error && (
<div className="font-medium text-xs text-red-500">{error}</div>
)}
</div>
</div>
</form>
)
}
page.tsx
'use client'
import { FormEventHandler, useCallback, useState } from 'react'
export default function Register() {
const [error, setError] = useState('')
const onFormSubmit = useCallback<FormEventHandler<HTMLFormElement>>(
async (e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/register', {
method: 'POST',
body: formData,
})
if (!res.ok) {
setError('Sorry! something went wrong.')
return
}
const json = (await res.json()) as { success: boolean }
if (!json.success) {
setError('Invalid credentials.')
return
}
location.href = '/login'
},
[]
)
return (
<form className="pt-10" onSubmit={onFormSubmit}>
<div className="w-96 mx-auto border border-gray-300 rounded-md space-y-3 px-6 py-8">
<div className="space-y-5">
<div className="pb-3">
<h2 className="text-xl font-bold text-center">Registration</h2>
</div>
<div className="space-y-1">
<label htmlFor="username" className="text-sm font-bold select-none">
Username
</label>
<input
id="username"
name="username"
type="text"
required
tabIndex={1}
placeholder="Username"
className="block w-full text-sm p-3 bg-white border border-gray-300 placeholder:text-gray-400 focus:outline-none focus:border-gray-400 rounded"
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="text-sm font-bold select-none">
Password
</label>
<input
id="password"
name="password"
type="password"
required
tabIndex={2}
placeholder="Password"
className="block w-full text-sm p-3 bg-white border border-gray-300 placeholder:text-gray-400 focus:outline-none focus:border-gray-400 rounded"
/>
</div>
<div className="flex justify-end">
<button
type="submit"
tabIndex={3}
className="bg-white h-10 border border-gray-400 text-gray-500 hover:border-gray-400 hover:text-black focus:outline-none focus:border-gray-400 rounded text-sm font-medium px-3"
>
Register
</button>
</div>
{error && (
<div className="font-medium text-xs text-red-500">{error}</div>
)}
</div>
</div>
</form>
)
}
layout.tsx
import type { Metadata } from 'next'
import { Navbar } from '@/components/common/Navbar'
import { checkAuthenticated } from '@/lib/server/auth'
export const metadata: Metadata = {
title: 'JWT Auth Rest API | Guest Pages',
}
export default async function GuestLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
await checkAuthenticated('/dashboard')
return (
<div>
<header>
<Navbar />
</header>
<main className="py-10">
<div className="container px-3">{children}</div>
</main>
</div>
)
}
layout.tsx
import { Navbar } from '@/components/common/Navbar'
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<>
<header className="bg-gray-100">
<Navbar />
</header>
<main className="py-10">
<div className="container px-3 space-y-8">{children}</div>
</main>
</>
)
}
page.tsx
export default function Home() {
return (
<>
<h2 className="text-2xl font-bold">
How to build a JWT auth system with Next.js API Routes and jose
</h2>
<p>
<a
className="text-blue-600 underline"
href="https://abdessamadely.com/jwt-authentication-in-next-js-with-api-routes-and-jose"
>
Read how every part in this project work
</a>
</p>
</>
)
}
route.ts
import { z } from 'zod'
import bcrypt from 'bcrypt'
import { addDays } from 'date-fns'
import { cookies } from 'next/headers'
import { signJWT } from '@/lib/server/jwt'
import { getUserByUsername } from '@/lib/server/database'
const schema = z.object({
username: z.string(),
password: z.string().min(3),
remember: z
.string()
.transform((val) => val === 'on')
.catch(false),
})
export async function POST(request: Request) {
const formData = await request.formData()
const parsed = schema.safeParse({
username: formData.get('username'),
password: formData.get('password'),
remember: formData.get('remember'),
})
if (!parsed.success) {
return Response.json({ success: false })
}
const { username, password, remember } = parsed.data
const user = await getUserByUsername(username)
if (!user || !bcrypt.compareSync(password, user.password)) {
return Response.json({ success: false })
}
const token = await signJWT(
{ sub: `${user.id}` },
{ exp: remember ? '7d' : '1d' }
)
const cookieStore = await cookies()
cookieStore.set('token', token, {
path: '/',
domain: process.env.APP_HOST || '',
secure: true,
expires: remember ? addDays(new Date(), 7) : addDays(new Date(), 1),
httpOnly: true,
sameSite: 'strict',
})
return Response.json({ success: true })
}
route.ts
import { cookies } from 'next/headers'
export async function POST() {
const cookieStore = await cookies()
cookieStore.set('token', '', {
path: '/',
domain: process.env.APP_HOST || '',
secure: true,
expires: new Date('0000'),
httpOnly: true,
sameSite: 'strict',
})
return new Response()
}
route.ts
import { cookies } from 'next/headers'
import { getAuthenticatedUser } from '@/lib/server/auth'
export async function GET() {
const user = await getAuthenticatedUser()
if (!user) {
/** Remove expired token from the user's browser */
const cookieStore = await cookies()
cookieStore.set('token', '', {
path: '/',
domain: process.env.APP_HOST || '',
secure: true,
expires: new Date('0000'),
httpOnly: true,
sameSite: 'strict',
})
return Response.json({
authUser: null,
})
}
return Response.json({
authUser: { ...user, password: undefined },
})
}
route.ts
import { z } from 'zod'
import bcrypt from 'bcrypt'
import { saveNewUser } from '@/lib/server/database'
const schema = z.object({
username: z.string(),
password: z.string().min(3),
})
export async function POST(request: Request) {
const formData = await request.formData()
const parsed = schema.safeParse({
username: formData.get('username'),
password: formData.get('password'),
})
if (!parsed.success) {
return Response.json({ success: false })
}
const { username, password } = parsed.data
saveNewUser({
username,
password: bcrypt.hashSync(password, 10),
})
return Response.json({ success: true })
}
layout.tsx
import Link from 'next/link'
import type { Metadata } from 'next'
import { Logout } from '@/components/auth/Logout'
import { checkUnAuthenticated } from '@/lib/server/auth'
export const metadata: Metadata = {
title: 'JWT Auth Rest API | Dashboard Pages',
}
export default async function DashboardLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
await checkUnAuthenticated('/')
return (
<>
<header className="bg-white border-b border-mercury">
<nav>
<div className="container h-16 flex items-center justify-between">
<Link href="/">JWT Auth Rest API</Link>
<Logout />
</div>
</nav>
</header>
<main className="py-10">
<div className="container">{children}</div>
</main>
</>
)
}
page.tsx
export default function Dashboard() {
return (
<main>
<div className="container space-y-6 px-4">
<h2 className="font-bold text-2xl">Authenticated Area</h2>
<p>
Anything under <em>/dashboard</em> is protected by the{' '}
<strong>checkUnAuthenticated()</strong> in
<code>app/dashboard/layout.tsx</code> page.
</p>
</div>
</main>
)
}
globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
layout.tsx
import { AuthProvider } from '@/context/auth.context'
import './globals.css'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'JWT Auth Rest API | Public Pages',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
)
}
Logout.tsx
'use client'
import { MouseEventHandler, useCallback } from 'react'
export function Logout() {
const onClick = useCallback<MouseEventHandler<HTMLButtonElement>>(
async (e) => {
e.preventDefault()
const res = await fetch('/api/logout', {
method: 'POST',
})
if (!res.ok) {
console.error('Sorry! something went wrong.')
return
}
location.href = '/'
},
[]
)
return (
<div>
<button
className="text-sm font-medium hover:text-sky-500"
onClick={onClick}
>
Logout
</button>
</div>
)
}
Navbar.tsx
'use client'
import { AuthContext } from '@/context/auth.context'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useContext } from 'react'
export function Navbar() {
const pathname = usePathname()
const { authUser } = useContext(AuthContext)
return (
<nav className="bg-white border-b border-gray-300 py-4">
<div className="container px-4 flex items-center gap-4">
<Link
className={`text-sm font-medium hover:text-sky-500 ${
pathname === '/' ? 'text-sky-500' : ''
}`}
href="/"
>
Home
</Link>
{authUser && (
<Link
className="text-sm font-medium hover:text-sky-500"
href="/dashboard"
>
Dashboard
</Link>
)}
{!authUser && (
<>
<Link
className={`text-sm font-medium hover:text-sky-500 ${
pathname === '/login' ? 'text-sky-500' : ''
}`}
href="/login"
>
Login
</Link>
<Link
className={`text-sm font-medium hover:text-sky-500 ${
pathname === '/register' ? 'text-sky-500' : ''
}`}
href="/register"
>
Register
</Link>
</>
)}
</div>
</nav>
)
}
auth.context.tsx
'use client'
import { SafeUser } from '@/lib/server/database'
import { createContext, ReactNode, useEffect, useState } from 'react'
type Props = {
children: ReactNode
}
type AuthState = {
authUser: SafeUser | null
}
export const AuthContext = createContext<AuthState>({ authUser: null })
export function AuthProvider({ children }: Props) {
const [authUser, setAuthUser] = useState<SafeUser | null>(null)
useEffect(() => {
fetch('/api/profile', { cache: 'no-store' })
.then((res) => res.json())
.then((json: { authUser: SafeUser | null }) => {
setAuthUser(json.authUser)
})
.catch(() => {
console.error('Sorry! something went wrong.')
})
}, [])
return (
<AuthContext.Provider value={{ authUser }}>{children}</AuthContext.Provider>
)
}
auth.ts
import 'server-only'
import { verifyJWT } from './jwt'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { getUserById, SafeUser } from './database'
/**
* Redirect when a user is unauthenticated
*/
export async function checkUnAuthenticated(url: string = '/') {
const cookieStore = await cookies()
const token = cookieStore.get('token')
if (!token || !token.value) {
redirect(url)
}
const jwtPayload = await verifyJWT(token.value)
if (jwtPayload === false) {
redirect(url)
}
}
/**
* Redirect when a user is authenticated
*/
export async function checkAuthenticated(url: string = '/login') {
const cookieStore = await cookies()
const token = cookieStore.get('token')
if (token && token.value) {
const jwtPayload = await verifyJWT(token.value)
if (jwtPayload !== false) {
redirect(url)
}
}
}
/**
* Get authenticated user from cookie token
*/
export async function getAuthenticatedUser(): Promise<SafeUser | null> {
const cookieStore = await cookies()
const token = cookieStore.get('token')
if (!token || !token.value) {
return null
}
const jwtPayload = await verifyJWT(token.value)
if (jwtPayload === false || !jwtPayload.sub) {
return null
}
return getUserById(+jwtPayload.sub) || null
}
database.json
{"users":[{"id":1,"username":"abdessamad","password":"$2b$10$ozf8WnvIODWMF2iRCfd0nO0sly8MPS73d5PfzQCwkg.CYjkZwQf0a"}]}database.ts
import 'server-only'
import { join } from 'node:path'
import { readFile, writeFile } from 'node:fs/promises'
type User = {
id: number
username: string
password: string
}
type Database = { users: User[] }
export type SafeUser = Omit<User, 'password'>
const databasePath = join(process.cwd(), 'lib/server/database.json')
async function readDatabase(): Promise<Database> {
return JSON.parse(await readFile(databasePath, { encoding: 'utf8' }))
}
async function writeToDatabase(database: Database) {
await writeFile(databasePath, JSON.stringify(database))
}
export async function getUserById(id: number): Promise<User | null> {
const database = await readDatabase()
return database.users.find((user) => user.id === id) || null
}
export async function getUserByUsername(
username: string
): Promise<User | null> {
const database = await readDatabase()
return database.users.find((user) => user.username === username) || null
}
export async function saveNewUser(user: Omit<User, 'id'>) {
const database = await readDatabase()
const recentUser = database.users.slice(-1).pop()
const id = recentUser ? recentUser.id + 1 : 1
database.users.push({ id, ...user })
await writeToDatabase(database)
}
jwt.ts
import 'server-only'
import { getEnvVariable } from '@/lib/env'
import { jwtVerify, SignJWT, JWTPayload } from 'jose'
export function signJWT(
payload: { sub: string },
options: { exp: string }
): Promise<string> {
try {
const alg = 'HS256'
const secret = new TextEncoder().encode(getEnvVariable('JWT_SECRET'))
return new SignJWT(payload)
.setProtectedHeader({ alg })
.setExpirationTime(options.exp)
.setIssuedAt()
.setSubject(payload.sub)
.sign(secret)
} catch (error) {
console.error("Wasn't able to sign the token.", error)
throw new Error("Wasn't able to sign the token.")
}
}
export async function verifyJWT(token: string): Promise<JWTPayload | false> {
const secret = new TextEncoder().encode(getEnvVariable('JWT_SECRET'))
if (!token) {
return false
}
try {
const jwt = await jwtVerify(token, secret)
return jwt.payload
} catch (error) {
if (error instanceof Error) {
console.log('verifyJWT:', error.message)
}
return false
}
}
env.ts
type EnvVariableKey = 'JWT_SECRET'
export function getEnvVariable(key: EnvVariableKey): string {
const value = process.env[key]
if (!value || value.length === 0) {
console.error(`The environment variable ${key} is not set.`)
throw new Error(`The environment variable ${key} is not set.`)
}
return value
}
.gitkeep
*.prettierrc
{
"semi": false,
"tabWidth": 2,
"singleQuote": true
}
eslint.config.mjs
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
next-env.d.ts
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
package.json
{
"name": "nextjs_jwt_auth_system_with_api",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"bcrypt": "^5.1.1",
"date-fns": "^4.1.0",
"jose": "^5.9.6",
"next": "15.1.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/bcrypt": "^5.0.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.1.3",
"postcss": "^8",
"server-only": "^0.0.1",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}postcss.config.mjs
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
tailwind.config.ts
import type { Config } from 'tailwindcss'
export default {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
container: {
center: true,
},
},
plugins: [],
} satisfies Config
tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}