Abdessamad Ely Logo

Nextjs Jwt Auth System With Api

Source code for the Nextjs Jwt Auth System With Api project.

Abdessamad Ely
Abdessamad Ely
Software Engineer

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"]
}