Simple Next.js User Authentication System

Next.js provides API routes, which can be used to add simple backend endpoints. We can use Github OAuth + JWT to implement a simple Next.js user authentication system. (If your site only uses Next.js for static site generation and is deployed to static hosting, such as Github Pages, Next.js API routes will not work.)

Github Login

First, you need to create a Github OAuth App for login. Click here to create one. For the Authorization callback URL, enter http://localhost:3000/api/oauth/github/callback (create a new app with your production URL after going live).

Github New Oauth App

After obtaining the Client ID and Client Secret, add them to your local environment config file .env.local as GITHUB_CLIENT_ID and GITHUB_SECRET.

Redirect User to Github Authorization

The first step of OAuth authentication is to redirect the user to the third-party site for app authorization. We use a fixed link to redirect to Github’s authorization page.

Add NEXT_PUBLIC_SITE_URL to your environment file to distinguish between development and production URLs. Locally, set it to http://localhost:3000. Then create /pages/api/connect/github.ts:

import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const redirectURL = `${process.env.NEXT_PUBLIC_SITE_URL}/api/oauth/github/callback`
  const url = `https://github.com/login/oauth/authorize?client_id=${process.env.GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectURL)}&scope=user`
  res.redirect(url)
}

Now, visiting /api/connect/github will redirect the user to the Github authorization page.

Get Github User Info

After the user completes authorization, they are redirected to the Authorization callback URL you registered. The page URL will include a code parameter, which you use to obtain an access_token, and then use the token to access user data. For details, see Authorizing OAuth Apps.

Create /pages/api/oauth/github/callback.ts:

import type { NextApiRequest, NextApiResponse } from 'next'
import axios from 'axios'
import cookie from 'cookie'
import { encode } from '../../../../lib/jwt'
import { User } from '../../../../types/user'

type GithubProfile = {
  login: string
  email: string
  blog?: string
  avatar_url: string
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { code } = req.query
  if (!code) {
    res.status(401).json({"message": "bad request"})
    return
  }
  // Use code to get access_token
  const resp = await axios.post<{access_token: string}>(`https://github.com/login/oauth/access_token`, undefined, {
    params: {
      code,
      client_id: process.env.GITHUB_CLIENT_ID,
      client_secret: process.env.GITHUB_SECRET,
    },
    headers: {
      'Accept': 'application/json'
    }
  })
  const token = resp.data.access_token
  if (!token) {
    res.status(500).json(resp.data)
    return
  }
  // Use access_token to get user data
  const userRes = await axios.get<GithubProfile>('https://api.github.com/user', {
    headers: {
      Authorization: `token ${token}`
    }
  })
  const user: User = {
    name: userRes.data.login,
    email: userRes.data.email,
    avatar: userRes.data.avatar_url,
    url: userRes.data.blog
  }
  // Encrypt user data with JWT
  const userToken = encode(user)
  // Write encrypted data to cookie
  const userCookie = cookie.serialize('user', userToken, { httpOnly: true, maxAge: 60 * 60 * 24 * 365, path: '/' })
  res.status(200).setHeader('Set-Cookie', userCookie).write(setupHTML(user))
  res.end()
}

function setupHTML(user: User) {
  return `
    <html>
      <head>
        <script>
          (function() {
            window.opener.postMessage(JSON.stringify({...${JSON.stringify(user)}, type: 'user'}))
            window.close()
          })()
        </script>
      </head>
    </html>
  `
}

We use a new window for Github authentication. After obtaining the Github user data, we encrypt and store it in a cookie, and use postMessage in the response to send user data to the current page, then automatically close the authentication window. This allows user authentication without refreshing the page.

JWT Encryption

Since we do not use a database to store user data, but store it directly in cookies, we need encryption. The most common method is JWT (JSON Web Tokens).

First, add JWT_SECRET to your environment file. Then create /lib/jwt.ts:

import jwt from 'jsonwebtoken'

const JWT_SECRET = process.env.JWT_SECRET

export function encode(data: string | object) {
  return jwt.sign(data, JWT_SECRET)
}

export function decode<T>(token: string) {
  return jwt.verify(token, JWT_SECRET) as T
}

User Authentication

User Verification

Add an API to read user data from cookies. Create /pages/api/user.ts:

import type { NextApiRequest, NextApiResponse } from 'next'
import cookie from 'cookie'
import { decode, encode } from '../../lib/jwt'

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const userToken = req.cookies.user
  if (!userToken) {
    res.status(401).json({"message": "unauthorized"})
    return
  }
  try {
    const user = decode(userToken)
    res.status(200).json(user)
  } catch (e) {
    res.status(401).json({"message": "unauthorized"})
  }
}

User Logout

Add an API to clear the user cookie to log out. Create /pages/api/logout.ts:

import type { NextApiRequest, NextApiResponse } from 'next'
import cookie from 'cookie'

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const userCookie = cookie.serialize('user', '', { httpOnly: true, expires: new Date(), path: '/' })
  res.status(200).setHeader('Set-Cookie', userCookie)
  res.json({"message": "ok"})
}

User Context (React Context)

After the backend APIs are done, create a context to handle user authentication. Create /context/auth/provider.tsx:

import { createContext, PropsWithChildren, useCallback, useEffect, useState } from "react"
import { User } from "../../types/user"

export type AuthContext = {
  user?: User
  login: () => Promise<User>
  logout: () => Promise<void>
}

export const AuthProviderContext = createContext<AuthContext>({
  login: () => Promise.reject(),
  logout: () => Promise.reject(),
})

export function AuthContextProvider({ children }: PropsWithChildren<{}>) {
  const [user, setUser] = useState<User>()

  const login = useCallback(() => {
    const loginWindow = window.open('/api/connect/github', 'loginWindow', 'height=500,width=500')
    return new Promise<User>((resolve, reject) => {
      if (!loginWindow) return reject()
      const messageHandler = (e: MessageEvent) => {
        try {
          const data = JSON.parse(e.data)
          if (data.type === 'user') {
            setUser(data)
            resolve(data)
          }
        } catch (e) { }
      }
     window.addEventListener('message', messageHandler)
    })
  }, [])

  const logout = useCallback(async () => {
    await fetch(`/api/signout`)
    setUser(undefined)
  }, [])

  useEffect(() => {
    fetch(`/api/user`)
      .then(res => {
        if (res.status !== 200) {
          return Promise.reject(res.status)
        }
        return res.json()
      })
      .then(user => setUser(user))
      .catch(() => {})
  }, [])

  return (
    <AuthProviderContext.Provider value={{ user, login, logout}}>
      { children }
    </AuthProviderContext.Provider>
  )

}

Update /pages/_app.tsx:

import '../styles/globals.css'
import type { AppProps } from 'next/app'

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <AuthContextProvider>
      <Component {...pageProps} />
    </AuthContextProvider>
  )
}

export default MyApp

Now you can use the context in any page or component to get the current user and call login/logout methods.

You can replace Github with any other third party for login, or use passport.js for more options.

© 2025 Yihao.dev. All rights reserved.