Skip to main contentArrow Right
Nextjs hackathon blog thumbnail

Table of Contents

Summarize with AI

Don't have the time to read the entire post? Our human writers will be sad, but we understand. Summarize the post with your preferred LLM here instead.

Looking to add authentication to your Next.js app using NextAuth? You can add authentication to a Next.js app by configuring NextAuth (now part of Auth.js) with Descope, then protecting pages and API routes by reading the session. Descope also offers a managed alternative that adds login, passkeys, and MFA to a Next.js, React, or Node.js app with far less code than wiring up NextAuth by hand.

In this tutorial, we’ll walk through how to integrate Descope, a drag & drop customer and agentic identity platform, into a Next.js project using Auth.js v5 (the current major version of NextAuth). To make this hands-on and practical, we’ll build a hackathon-ready web app that demonstrates real-world usage of authentication, protected routes, and server/client components in the Next.js App Router.

Whether you’re a hacker, developer, or organizer looking to spin up a modern site for your next hackathon, this tutorial is for you.

At a glance

  • You will build a Next.js app with a custom login page, a protected dashboard, and protected API routes, using NextAuth for session handling and Descope as the authentication backend.

  • The stack is the current Next.js version with the App Router, NextAuth (now Auth.js v5), and the Descope SDK.

  • You will learn how to configure the auth provider, sign users in, read the session on the server, and protect API routes with a JWT session.

  • NextAuth handles the session and the JWT, while Descope handles the actual authentication, including passwordless and social login.

  • If you would rather not wire this up by hand, the Descope SDK adds the same login and session handling to a Next.js, React, or Node.js app with far less code.

What you’ll build

  • Descope + NextAuth authentication using the official Descope provider

  • Protected pages and API routes

  • A fully customizable landing page with About, Speakers, Sponsors, and FAQ sections

  • A Team page and personalized Dashboard for attendees

  • Airtable backend for form submissions and acceptance statuses

  • Fully responsive UI (mobile, tablet, computer) built with modern Next.js App Router conventions

Let’s dive in and explore how Next.js and NextAuth work together with Descope to power secure, scalable authentication in just a few lines of code.

Prerequisites

Before we start adding Descope authentication to our Next.js app with NextAuth, make sure you’ve got the basics covered: a current Next.js project using the App Router, Node.js 20.9 or later, and a free Descope project.

All the code for this tutorial lives in our GitHub repository: next-hackathon-template. You’ll find setup instructions in the README.md file to help you get started quickly.

What is NextAuth (now Auth.js)?

NextAuth is an open-source authentication library for Next.js that manages sessions and tokens for you rather than acting as an identity provider itself. The project was renamed Auth.js as it expanded beyond Next.js to support other frameworks like SvelteKit, Qwik, and Express, and the current major version, installed as next-auth@beta, uses a different API from the older NextAuth v4. It’s commonly paired with a provider, such as Descope, that handles the actual sign-in and identity verification.

Since Auth.js joined Better Auth in September 2025, it's worth noting that Auth.js itself is still maintained, with security patches and critical fixes continuing, so an existing Auth.js setup is not urgent to change. The Better Auth team recommends Better Auth for new projects going forward, but this tutorial uses Auth.js since it remains a fully supported, widely deployed option and the one most existing NextAuth tutorials and codebases are built on.

Tutorial overview: Next.js + NextAuth via Descope

In this tutorial, we’ll explore how to integrate Descope authentication into a Next.js app using NextAuth with Descope as an official provider. Along the way, we’ll build a fully functional hackathon site using the App Router.

Here’s what we’ll cover:

  • Setting up NextAuth with Descope

  • Creating a custom sign-in page

  • Building a protected dashboard with server-side session handling

  • Validating and protecting API routes

  • Walking through a live demo of the finished hackathon template

Before jumping into the code, we’ll start with the fundamentals of authentication in Next.js and how Auth.js v5 fits into the picture.

How to add authentication to your Next.js app

Step 1: Set up the backend with Descope

Create a free Descope project. You’ll need your Project ID from the project settings page, and an access key from the Access Keys section, to connect Descope to NextAuth in the next step.

Step 2: Configure NextAuth (Auth.js)

To handle authentication in our Next.js app, we’re using NextAuth.js, now part of the framework-agnostic Auth.js project. It’s a great fit because:

  • It supports a wide range of authentication providers out of the box, including an official Descope provider

  • It’s fully compatible with Next.js and the App Router

  • It works seamlessly with OAuth, OIDC, and other protocols

Since Descope is an OIDC provider and an official Auth.js provider, integrating it takes just a few lines of configuration. A complete NextAuth.js setup for this tutorial touches three files: the auth.ts config at the project root, the catch-all route handler under app/api/auth/[...nextauth]/, and your .env.local file holding the Descope credentials. Everything else in the app—the sign-in button, the protected dashboard, and the API route —reads from the session that these three pieces establish.

Install the current version of NextAuth:

npm install next-auth@beta

Then create an auth.ts file at the root of your project:

import NextAuth from "next-auth"
import Descope from "next-auth/providers/descope"

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [Descope],
})

Next, create a route.ts file in the following directory: app/api/auth/[...nextauth]/route.ts

import { handlers } from "@/auth"

export const { GET, POST } = handlers

Here’s what’s happening:

  • The [...nextauth] directory creates a catch-all route: any requests to /api/auth/* are handled here.

  • The auth.ts file initializes NextAuth with the Descope provider and exports the handlers, signIn, signOut, and auth functions we’ll use throughout the app.

  • The route handler re-exports those handlers for both GET and POST, which is required in Next.js App Router route handlers.

Behind that single Descope line, the provider is still doing everything the old custom OAuth config used to spell out by hand: it points to Descope’s OIDC discovery document, requests an ID token alongside the access token, talks to Descope’s authorization server to verify consent, and enables PKCE checks to protect the code exchange. The official provider just means you no longer have to configure those pieces yourself.

Add your Descope credentials as environment variables in .env.local:

AUTH_DESCOPE_ID=<your Descope Project ID>
AUTH_DESCOPE_SECRET=<your Descope access key>
AUTH_DESCOPE_ISSUER=<your Descope issuer URL>
AUTH_SECRET=<generate with npx auth secret>
NEXT_PUBLIC_APP_URL=<your app's base URL, e.g. http://localhost:3000>

Auth.js v5 infers most of what it needs from request headers, so AUTH_DESCOPE_ID, AUTH_DESCOPE_SECRET, and AUTH_DESCOPE_ISSUER are automatically picked up by the Descope provider without extra configuration. NEXT_PUBLIC_APP_URL is just there for our own Dashboard-to-API fetch call, not something Auth.js itself requires.

You can generate AUTH_SECRET, the random value NextAuth uses to encrypt tokens, by running:

npx auth secret

This sets the stage for Descope authentication in a secure and standards-compliant way. Next up, we’ll wire up the sign-in button on the frontend.

Step 3. Build the sign-in page

Now that Descope is configured as a provider in NextAuth, it’s time to trigger the sign-in flow from the frontend of our Next.js app. Unlike a traditional Next.js login page built from scratch with your own form fields and password handling, this one only needs a single button, since Descope’s hosted flow handles the actual credential collection and verification.

We’ll do this using the signIn function exported from our auth.ts file. In our case, it’s wired to a button inside the Navbar component:

import { signIn } from "@/auth"

export default function Navbar({ Logo }: { Logo: string }) {
    return (
        <form
          action={async () => {
            "use server"
            await signIn("descope", { redirectTo: "/dashboard" })
          }}
        >
          <button
            type="submit"
            className="text-[#e9e9e9] bg-[#262d3b] py-2 px-7 border-[#45546e] border-4"
          >
            Apply
          </button>
        </form>
    )
}

The signIn function takes two arguments:

  • descope refers to the authentication provider ID we configured in auth.ts.

  • redirectTo tells NextAuth where to send users after a successful login, in this case, the Dashboard page.

Once users click the Apply button, they’ll be redirected to Descope’s hosted login page. After authentication, they’ll land on a protected dashboard we’ll set up next.

Step 4. Build a protected dashboard with server-side session handling

Once a user is signed in with Descope and NextAuth in Next.js, we’ll direct them to a protected dashboard page. This page uses server-side session handling via the auth() function exported from auth.ts to validate access.

The Dashboard page has two main functions:

  • getData fetches data from our Airtable API route

  • Dashboard is the main page component that renders content based on the user’s application status

We’re using Airtable as our backend to store hacker applications and acceptance status. Here’s the code:

...
const getData = async () => {
    const session = await getServerSession(authOptions)
    const email = encodeURIComponent(session?.user?.email || "")

    const res = await fetch(`${process.env.NEXTAUTH_URL}/api/airtable?email=${email}&secret=${process.env.SECRET_TOKEN}`)   
    const data = await res.json()

    return data.body
}


export default async function Dashboard() {
    const session = await getServerSession(authOptions)
    if (!session) {
        redirect("/api/auth/signin?callbackUrl=/dashboard")
    }

    const airtableRecord = await getData()

    return (
        <div className='page space'>
            <div className="w-[90%]">
                <Header />
                {airtableRecord ?
                    <>
                        <Status accepted={airtableRecord['Accepted']} />
                        {airtableRecord['Accepted'] && 
                            <Info data={AnnouncementsList} />
                        }
                        <Application application={airtableRecord} />
                    </>
                    :
                    <Form />
                }
            </div>
        </div>
    )
}

Code block: app/dashboard/page.tsx

Within the fetch request of the getData function are two parameters: 

  • email: We get the user’s email from the auth() session above. The email is passed in the query to identify the user data we are fetching.

  • secret: The secret token acts as the API key that we get from our environment variables as a way for the API to validate the request. Here’s an example from the Next.js docs. 

The Dashboard component is made up of three key parts: 

  • To protect the Dashboard page, we call auth() to get the session and check if it exists. If not, we redirect to the sign-in page with the callback URL set to the Dashboard page.

  • The getData function is called, and the response is an object that can contain an Accepted field to indicate whether the hacker has been accepted into the hackathon.

  • In the return statement, we first check if user data exists in our Airtable. If it doesn’t, we display the Form component. If it does, we display the Status component and Application Component. If the user is accepted, we display the Info component

NOTE: Since the Dashboard component is a server component, you can’t use the client-side useSession hook. Instead, we call the server-side auth() function to access the session and fetch the user’s email securely.

Step 5. Set up and protect your API routes

In this final step, we’ll create a backend API route that securely fetches data from Airtable. This route will be used by our dashboard to retrieve user-specific information after authenticating with Next.js and NextAuth.

Here’s the API route:



import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import Airtable from 'airtable'


export async function GET(request: NextRequest) {
    const { searchParams } = new URL(request.url)
    const email = searchParams.get('email')

    if (searchParams.get('secret') !== process.env.SECRET_TOKEN) {
        return NextResponse.json("Unauthorized", { status: 401 })
    }

    ...

    const res = [{
        "fields": {
            "Name": 'Descope',
            "University": 'University of Waterloo',
            "What year are you?": 'First year',
            "Email": 'example@descope.com',
            'Why AuthHacks?': "Authentication is a fundamental part of any startup, SaaS, or business. The workshops and connections I'll make will profoundly broaden my knowledge of good security practices and industry leaders.",
            'Accepted': true 
        }
    }]
        
    return NextResponse.json(
        {
            body: res[0].fields,
        },
        {
            status: 200,
        },
    );
} 

Code block: app/api/airtable/route.ts

Here’s a list of steps we take to protect and get data:

  • Next.js uses HTTP methods as the route identifier. The GET method is triggered when we call a GET request to /api/airtable.

    We use JavaScript’s built-in URL class to parse the incoming request and get the query parameters: email and secret.

  • We get the secret from the searchParams and check it against our secret token that we have stored as an environment variable. If it does not match, we return an unauthorized response.

  • The res variable contains our hackathon dummy data, which we send to the client in the response body. 

With this API route in place, our dashboard can securely fetch user-specific records from Airtable once authentication is complete.

How JWT sessions work in this setup

NextAuth uses a JWT session strategy by default, which is why this same pattern comes up whenever developers ask how to add JWT auth to a Next.js app. Here’s how the token flows through the setup we just built:

  1. When a user signs in through Descope, NextAuth issues a signed JWT and stores it in an HTTP-only cookie in the browser.

  2. On every subsequent request, that cookie is sent automatically, and NextAuth verifies the token’s signature before trusting anything inside it.

  3. Server-side code, like the auth() call in our Dashboard component, reads and validates that JWT to reconstruct the session without a database lookup.

  4. API routes can apply the same validation before returning protected data, which is what the secret-token check in our Airtable route is standing in for at a smaller scale.

Because the session is a signed, self-contained JWT rather than a reference to server-side storage, this setup scales cleanly across serverless functions and edge runtimes without needing a shared session store.

That said, self-contained also means the JWT can’t easily be invalidated the moment a user’s access should end. If you need to revoke a session immediately, such as when a user is suspended, you either need to keep expiration times short so a revoked JWT stops working quickly on its own, or switch NextAuth to a database session strategy, which trades that scalability for the ability to delete a session record on demand.

Also Read: JWT vs Bearer Token

Adding the same auth to a React or Node.js app

The pattern in this tutorial isn’t unique to Next.js. The same flow—a frontend that handles login and holds a session token, and a backend that validates that token on protected requests—applies to a plain React single-page app backed by a Node.js API, just without NextAuth’s Next.js-specific conventions.

Add JWT auth to a React app

A React single-page app handles the login redirect, receives the session token, and stores it on the client rather than relying on NextAuth’s server-rendered session handling. The Descope React SDK wraps your app in an AuthProvider, then gives you useSession and useUser hooks to check authentication state and attach the session token to outgoing API requests, all without writing token storage or refresh logic by hand.

Add JWT auth to a Node.js app

A Node.js backend validates the JWT on every request before serving protected routes, checking the token’s signature and claims rather than trusting it outright. The Descope Node.js SDK provides a validateSession function that does exactly this: pass it the session token from the request’s Authorization header, and it verifies the signature and returns the authenticated user’s information.

NextAuth vs Descope: which should you use?

NextAuth is a free, open-source session library you configure and maintain yourself, while Descope is a managed authentication platform that adds login methods, passkeys, MFA, and session handling through SDKs and visual flows with less code to maintain. As this tutorial shows, the two are often used together: NextAuth handles the session and cookie plumbing, while Descope handles the actual identity verification behind it.


NextAuth

Descope

What it is

An open-source session and token library for Next.js and other frameworks

A managed authentication platform (CIAM)

What you maintain

Provider configuration, session callbacks, and token refresh logic

Login screens and flows are configured visually, with far less custom code

Login methods included

None built in; requires a separate identity provider like Descope

Passwordless, passkeys, social login, MFA, and SSO built in

Best for

Teams that want a lightweight, self-managed session layer in front of an existing provider

Teams that want login, security, and session handling covered by one platform

Choosing between them isn’t really an either/or question. If you’re already using NextAuth and want a provider that handles the identity side without you building your own auth server, Descope fits directly into the setup in this tutorial. If you’d rather skip configuring NextAuth entirely, the Descope Next.js SDK integrates natively without an intermediate session library. Descope’s own comparison of NextAuth vs. native Next.js integration goes deeper on when each approach makes sense.

Demo day

Here are some screenshots from the hackathon template. You can also check out the live preview here: https://nextjs-hackathon-template.descope.com/

AuthHacks 1
Fig: AuthHacks home page
AuthHacks 2
Fig: AuthHacks home page facts section
AuthHacks 3
Fig: AuthHacks dashboard page, Status and Application component
AuthHacks 4
Fig: AuthHacks Dashboard page, Status, Application, Info component

All the code used in this project is available in the GitHub repository. Feel free to clone, remix, and customize it for your own event or app.

Build Next.js authentication faster with Descope

Descope adds login, passkeys, MFA, and session handling to a Next.js, React, or Node.js app through an SDK and visual flows, so you don’t have to configure NextAuth, write provider callbacks, or manage token refresh logic by hand. Sign up for a Free Forever account to get started, or check out the Next.js SDK quickstart to see the native integration path.

To learn more about Descope and showcase what you’re building, join hundreds of developers in our AuthTown community.

Frequently asked questions about NextJS auth