Integrating Next.js with Express.js using Auth0 for Authentication

Last Updated:

In this post, I am going to walk you through the process of integrating a Next.js 13 (Next.js App Router) frontend with a custom backend Express.js server API. We will use Auth0 for authentication in this example, but the process would be similar for other auth providers.

Between Vercel's push towards serverless functions (No worries, I'm still a fanboy. For now...), and the seemingly widespread adoption of tRPC by much of the Typescript community, I have found the documentation on how to integrate your own backend API with the new Next.js app directory to be a bit sparse.

Now in many cases, if you are using Next.js with Typescript, it probably makes sense to use serverless functions and if you want full type-safety, then it would make sense to use tRPC too.

However, for my current project, there were a few reasons why I didn't want to use serverless or tRPC:

  1. I honestly just wanted more practice with writing REST APIs.
  2. I have yet to determine if this is unfounded, but I didn't really feel like working around cold starts as I expect my app's user base to start small and grow over time.
  3. If my application does gain traction, I didn't want to lock myself into using Typescript+Node.js just because I implemented the MVP using tRPC.

With that said, I do intend on learning those technologies in the future and advise that you also have some good reasons if you are going to build your Next.js application with a traditional API server.

For this post, I am going to assume that you have some basic knowledge of Next.js and Express.js and that you have an Auth0 account set up with a Next.js application. If not, here are some helpful resources to get you to that point:

Now, if you want to code along, I have put the starter code on my GitHub. You can download it here.

In this example, I will have the Express server stored in a server folder and the Next.js app in a client folder. I will run the apps in their own terminals, but the starter code is set up with concurrently if you prefer to run the apps from a single terminal.

Start Application

We will start by getting the base project running locally.

  1. Clone the starter code:
git clone https://github.com/benjamin-chavez/nextjs-express-auth0-template.git
cd nextjs-express-auth0-template
git checkout blog-post-starter-code
  1. Set up the Environment Variables:
cp ./client/.env.local.example ./client/.env.local
cp ./server/.env.example ./server/.env

Be sure to update your Next.js environment variables with the values provided in your Auth0 application dashboard. Your updated file should look something like this:

client/.env.local
# Secret key for Auth0 - To generate, run `openssl rand -hex 32` in the command line
AUTH0_SECRET='e2ebaf05e08f75b2eb3605ce28e6bf3ef0e68c54d9672ec87b4e69fae2f38460'
 
# The base URL for your application
AUTH0_BASE_URL='http://localhost:3000'
 
# Your Auth0 tenant domain
AUTH0_ISSUER_BASE_URL='https://dev-mqyygar8pnsvarul.us.auth0.com'
 
# Your Auth0 client ID
AUTH0_CLIENT_ID='oMv0Lmf1v2uTITiqQ67hhRJBL4XKdhO4'
 
# Your Auth0 client secret
AUTH0_CLIENT_SECRET='I_CFaneP_CyYQ-WBvXzwVlZKjsv4qL9YzY-hE8FE9HrsDNNtCJJnyp5s0vtt_yg1'
  1. From the root directory, install the project dependencies:
pnpm i
  1. Start the Next.js frontend application
cd client
pnpm dev
  1. Open a second terminal and start the Express.js backend-server
cd server
pnpm dev

Now, if you open your browser to http://localhost:3000 and http://localhost:5000/api, you will see the following:

Screenshot

Declare Rewrites

In Next.js a rewrite allows us to map any incoming navigation and/or client request paths to a specified destination path.

We are going to use rewrites to map all incoming API requests over to our Express server path.

Now, with both your frontend and backend running, go to http://localhost:3000/api in your browser. You will see a 404 page not found error.

  1. Stop your Next.js client-server and add the following code to your client/next.config.js file.
client/next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  async rewrites() {
    return [
      {
        source: '/api/:path*',
        destination: 'http://localhost:5000/api/:path*',
      },
    ];
  },
};
 
module.exports = nextConfig;

Start up your Next.js client-server again and go back to http://localhost:3000/api in your browser.

The 404 should be gone, and you should now see Server Running... :

Screenshot

This "Server Running..." message is coming directly from your Express.js server because out request was mapped from the source path of http://localhost:3000/api over to the destination path of http://localhost:5000/api.

Before we continue, we need to add one more rewrite rule so that our client-side requests to the Auth0 API do not get rewritten. We need to do this because Auth0 requires us to use Next.js's API Router when implementing frontend auth inside of a Next.js application.

The new rewrite simply catches requests to the Auth0 API before they are rewritten to our server API and instead sends them to the requested source path unchanged.

client/next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  async rewrites() {
    return [
      {
        source: '/api/auth/:path',
        destination: '/api/auth/:path*',
      },
      {
        source: '/api/:path*',
        destination: 'http://localhost:5000/api/:path*',
      },
    ];
  },
};
 
module.exports = nextConfig;

Be sure to restart your client-side server after making any updates to the client/next.config.js file.

Add Next.js Middleware

Rather than requiring authentication in each of our private pages or components, we will implement our auth with Next.js Middleware, which will allow us to specify which routes we want to make private.

There are several benefits to this approach. The primary benefit is that it enables client-side handling of authentication and authorization in our Next.js application, as opposed to server-side handling in our Express.js server.

If we were to handle authentication on the server, we would need to send a request each time a user navigated to a new page. Consequently, our client-side application would also need to wait for our server to responds to each of those requests before allowing a user to actually view any content. You can imagine how this might become an unesary drag on performance.

Furthermore, the use of middleware allows us to keep our auth logic separate from our other application logic. This separation of concerns is a good design choice as the modularity reduces cognitive load, and if, later, we decide to change auth managers, we only have to update one file as opposed to having to update every private page/component in our application.

  1. Create a new client/src/middleware.ts file and add the following:
client/src/middleware.ts
import { withMiddlewareAuthRequired } from '@auth0/nextjs-auth0/edge';
 
export default withMiddlewareAuthRequired();
 
export const config = {};

If you open your browser back to http://localhost:3000, you will automatically be redirected to the Auth0 login screen:

Screenshot

This redirection occurs because we have effectively made all of our routes private. The function withMiddlewareAuthRequired() informs Next.js and Auth0 that authentication is required for all requests processed by said middleware.

WARNING: If you see the following error instead of a login screen, it means we need to update your Next.js application URI settings in AUTH0.

Screenshot

The updated URI settings should be:

Screenshot

Note that if you log in or sign up, you will again have access to all of the pages of our application.

  1. Define which routes to protect

Now, we don't actually want to make the whole app private, so let's tell our middleware to only run on our /private and /api routes with the following change:

client/src/middleware.ts
import { withMiddlewareAuthRequired } from '@auth0/nextjs-auth0/edge';
 
export default withMiddlewareAuthRequired();
 
export const config = {
   matcher: ['/private/:path*', '/api/:path*'],
};

Ensure you are logged out and navigate back to http://localhost:3000. You should now have access to both our Home and Public pages. Additionally, if you navigate to http://localhost:3000/private, you will once again be redirected to our login screen.

📝

You cannot access the Next.js middleware from server components as Next.js middleware only runs on navigated routes and/or API requests that come from client components.

Protecting Our API Routes

If you return to http://localhost:3000/api, you will notice that now you need to be logged in to see our Server Running... message. This is because we passed our API routes to our middleware matcher with '/api/:path*'.

However, there is still a massive security flaw in our design. If you navigate to http://localhost:5000/api, you will see our Server Running... message, which means that our route isn't actually protected.

This is because nothing is preventing unauthorized users from bypassing our security simply by hitting our backend API routes instead of our frontend UI.

To remedy this, we need to add our Express API to Auth0 and add authentication to the API route in our Express server code.

  1. In your Auth0 dashboard go to Applications>APIs.
Screenshot
  1. Click the + Create API button and update the form's Name and Identifier fields before clicking create:

Screenshot
  1. Update your Environment Variables

    • Create a new AUTH0_AUDIENCE env variable and set its value equal to the identifer from the previous step. Add this variable in both your client/.env.local and server/.env files:

    • Copy the AUTH0_ISSUER_BASE_URL environment variable from the client/.env.local file and add it to the server/.env file.

server/.env
PORT=5000
 
AUTH0_AUDIENCE=http://localhost:5000/api
AUTH0_ISSUER_BASE_URL='https://dev-mqyygar8pnsvarul.us.auth0.com'
client/.env.local
# Secret key for Auth0 - To generate, run `openssl rand -hex 32` in the command line
AUTH0_SECRET='e2ebaf05e08f75b2eb3605ce28e6bf3ef0e68c54d9672ec87b4e69fae2f38460'
 
# The base URL for your application
AUTH0_BASE_URL='http://localhost:3000'
 
# Your Auth0 tenant domain
AUTH0_ISSUER_BASE_URL='https://dev-mqyygar8pnsvarul.us.auth0.com'
 
# Your Auth0 client ID
AUTH0_CLIENT_ID='Hdyjm0UaxA4siHztbgIdU5POnJlgfeLm'
 
# Your Auth0 client secret
AUTH0_CLIENT_SECRET='vZ1tRndqiVqLUD7UYFH2d4XWGQOw4jNjuj7Abd45BmMNlmCyaVNapERUE3r9KpsQ'
 
# The audience that the Auth0 ID token is intended for
AUTH0_AUDIENCE=http://localhost:5000/api

Now that we've added our API to to our Auth0 dashboard, we can add authentication directly to our server routes.

  1. In your Express.js server directory, install the following npm packages:
#  ENSURE YOU ARE IN YOUR SERVER DIRECTORY
pnpm i express-jwt jwks-rsa
  1. Create a server/src/middleware/authMiddleware.ts middleware file in your backend.
server/src/middleware/authMiddleware.ts
import 'dotenv/config';
 
import { GetVerificationKey, expressjwt as jwt } from 'express-jwt';
import jwksRsa from 'jwks-rsa';
const issuerBaseUrl = process.env.AUTH0_ISSUER_BASE_URL;
const audience = process.env.AUTH0_AUDIENCE || 'http://localhost:5000/';
 
export const checkJwt = jwt({
  secret: jwksRsa.expressJwtSecret({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: `${issuerBaseUrl}/.well-known/jwks.json`,
  }) as GetVerificationKey,
  audience: audience,
  issuer: `${issuerBaseUrl}/`,
  algorithms: ['RS256'],
});

Our Express.js server will use this auth middleware to decrypt any JWT Authentication Tokens that it receives from our Next.js frontend.

  1. In your server/src/routes/index.ts file, import the auth middleware and add it to your individual API routes:
server/src/app.ts
import cookieParser from 'cookie-parser';
import express from 'express';
import flash from 'express-flash';
import morgan from 'morgan';
import {
  generalErrorHandler,
  notFoundHandler,
} from './middleware/errorMiddleware';
import routes from './routes/index';
import { checkJwt } from './middleware/authMiddleware';
 
const app = express();
 
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(flash());
 
app.use('/api', checkJwt, routes);
 
app.use(notFoundHandler);
app.use(generalErrorHandler);
 
export default app;

You could apply the auth middleware to individual routes instead with something like this:

server/src/routes/index.ts
import { Router } from 'express';
import asyncHandler from 'express-async-handler';
import { checkJwt } from '../middleware/authMiddleware';
 
const router = Router();
 
router.get(
  '/',
  checkJwt,
  asyncHandler(async (req, res, next) => {
    res.status(200).send('Server Running...');
  })
);
 
router.get(
  '/private-route',
  checkJwt,
  asyncHandler(async (req, res, next) => {
    res.status(200).send({ message: 'This is a private route' });
  })
);
 
export default router;

If we open back up to http://localhost:5000/api, we will see that we have succesfully protected our backend API routes as well:

Screenshot

Add CORS (optional)

For this specific example, adding CORS is not required because we are rewriting our API requests. TODO: essentially sidestepping TODO: a browser-enforced security measure and doesn't apply to server-to-server communication.

However, it is good practice to add CORS so that we can avoid any cross-origin errors that might come up if we were to establish further communication between our frontend and our backend.

  1. Copy over the AUTH0_BASE_URL Environment variable from your next.js app's .env.local file and paste it into your Express Server's server/.env file:
server/.env
PORT=5000
 
AUTH0_BASE_URL=http://localhost:3000
AUTH0_AUDIENCE=http://localhost:5000/api
AUTH0_ISSUER_BASE_URL='https://dev-q514l5sgo7t68du7.us.auth0.com'
  1. Install the CORS npm package on your server:
pnpm i cors
  1. Update the imports and add cors as middleware in your server's app.ts file:
server/src/app.ts
import 'dotenv/config';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import express from 'express';
import flash from 'express-flash';
import morgan from 'morgan';
import {
  generalErrorHandler,
  notFoundHandler,
} from './middleware/errorMiddleware';
import routes from './routes/index';
import { checkJwt } from './middleware/authMiddleware';
 
const app = express();
const baseUrl = process.env.AUTH0_BASE_URL;
 
app.use(morgan('dev'));
app.use(cors({ origin: baseUrl }));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(flash());
 
app.use('/api', checkJwt, routes);
 
app.use(notFoundHandler);
app.use(generalErrorHandler);
 
export default app;

Securely passing Auth Tokens

So far, this is great, we have both our frontend and backend protected. The only problem is that now we have to pass auth tokens to our server API to gain access to protected backend API routes.

If we are making requests from a server component, this would be easy! We would simply get the auth token from our session and pass it with the request:

example-server-component.tsx
// THIS IS EXAMPLE CODE ONLY AND WILL NOT USED IN OUR APPLICATION
import { getAccessToken } from '@auth0/nextjs-auth0';
 
export async function getMyProducts() {
  const { accessToken } = await getAccessToken();
 
  const res = await fetch(`http://localhost:5000/api`, {
    method: 'GET',
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });
 
  return res.json();
}

However, client components cannot use the getAccessToken() method...

We could get the access token in a server component and pass it as props to our client component. That would work, but it wouldn't be the most secure solution since we would be sending the auth token to the client.

A more secure solution is to add the authToken to our API Request's headers, in our frontend middleware, before rewriting our requests to our backend API.

Up to now, we have manually created each of our requests by entering the url in our browser. So to set this up we will need to program a new request that we can attach our authentication token to.

  1. Convert the Private page to a client component by adding the 'use client' directive to the top of the file.
'use client';
  1. Import React's useState hook and write a callApi() function that fetches data from our Express server.
client/src/app/private/page.tsx
import { useState } from 'react';
 
export default function PrivatePage() {
  const [state, setState] = useState({
    isLoading: false,
    response: undefined,
    error: undefined,
  });
 
  const callApi = async () => {
    setState((previous) => ({ ...previous, isLoading: true }));
 
    try {
      const response = await fetch('/api/private-route');
      const data = await response.json();
 
      setState((previous) => ({
        ...previous,
        response: data,
        error: undefined,
      }));
    } catch (error) {
      setState((previous) => ({
        ...previous,
        response: undefined,
        error,
      }));
    } finally {
      setState((previous) => ({ ...previous, isLoading: false }));
    }
  };
 
  const { isLoading, response, error } = state;
  ...
}
  1. Add a button that we can use to initiate API requests
<button
  onClick={callApi}
  className="mt-10 block rounded bg-pink-800/50 px-2 py-1 text-white hover:opacity-70"
>
  Call API
</button>
  1. If we are not in a loading state, display the server response
if (!isLoading) {
  return (
    <>
      ...
      <div className="mt-20">
        {response && <div>{JSON.stringify(response)}</div>}
      </div>
    </>
  );
}

Your fully updated Private page should look like this:

client/src/app/private/page.tsx
'use client';
 
import { useState } from 'react';
 
export default function PrivatePage() {
  const [state, setState] = useState({
    isLoading: false,
    response: undefined,
    error: undefined,
  });
 
  const callApi = async () => {
    setState((previous) => ({ ...previous, isLoading: true }));
 
    try {
      const response = await fetch('/api/private-route');
      const data = await response.json();
 
      setState((previous) => ({
        ...previous,
        response: data,
        error: undefined,
      }));
    } catch (error) {
      setState((previous) => ({
        ...previous,
        response: undefined,
        error,
      }));
    } finally {
      setState((previous) => ({ ...previous, isLoading: false }));
    }
  };
 
  const { isLoading, response, error } = state;
 
  if (!isLoading) {
    return (
      <>
        <h1 className="mt-10 text-4xl font-bold tracking-tight text-white sm:text-6xl">
          Private Page
        </h1>
 
        <button
          onClick={callApi}
          className="mt-10 block rounded bg-pink-800/50 px-2 py-1 text-white hover:opacity-70"
        >
          Call API
        </button>
 
        <div className="mt-20">
          {response && <div>{JSON.stringify(response)}</div>}
        </div>
      </>
    );
  }
}

Now back in your browser, if you log into the app and navigate to http://localhost:3000/private, you should see the updated Private page with our new Call API button.

Screenshot

Set Authentication Headers

Finally, we want to add our authentication token to the headers of the private request as it passes through our Next.js middleware on its way to our Server API.

  1. Create a custom middleware function and pass it as an argument to the existing withMiddlewareAuthRequired() function.

    The new custom middleware function should take in a NextRequest object, copy any existing headers over to a new NextResponse object.

client/src/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
 
export default withMiddlewareAuthRequired(function middleware(
  req: NextRequest,
) {
  if (req.nextUrl.pathname.startsWith('/api/auth')) {
    return;
  }
 
  const requestHeaders = new Headers(req.headers);
  const user = await getSession(
    req,
    NextResponse.next({
      request: {
        headers: requestHeaders,
      },
    }),
  );
  requestHeaders.set('Authorization', `Bearer ${user?.idToken}`);
});
...
  1. Note that we need to import getSession from Auth0 so that we can access our our user session and auth token:
client/src/middleware.ts
import { getSession, withMiddlewareAuthRequired } from '@auth0/nextjs-auth0/edge';
 
...
 
// Be sure to add the `async` modifier to your function
export default withMiddlewareAuthRequired(async function middleware(
  req: NextRequest
) {
  if (req.nextUrl.pathname.startsWith('/api/auth')) {
    return;
  }
 
  const requestHeaders = new Headers(req.headers);
  const user = await getSession(
    req,
    NextResponse.next({
      request: {
        headers: requestHeaders,
      },
    }),
  );
  requestHeaders.set('Authorization', `Bearer ${user?.idToken}`);
});
...
  1. Remove the Authorization header from our newly created NextResponse object so that it is not exposed:
client/src/middleware.ts
const token = user?.accessToken;
 
const response = NextResponse.next({
  request: {
    headers: requestHeaders,
  },
});
 
return response;

The fully updated middleware will look like this:

client/src/middleware.ts
import {
  getSession,
  withMiddlewareAuthRequired,
} from '@auth0/nextjs-auth0/edge';
import { NextRequest, NextResponse } from 'next/server';
 
export default withMiddlewareAuthRequired(async function middleware(
  req: NextRequest,
) {
  if (req.nextUrl.pathname.startsWith('/api/auth')) {
    return;
  }
 
  const requestHeaders = new Headers(req.headers);
  const user = await getSession(
    req,
    NextResponse.next({
      request: {
        headers: requestHeaders,
      },
    }),
  );
  requestHeaders.set('Authorization', `Bearer ${user?.idToken}`);
 
  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
  return response;
});
 
export const config = {
  matcher: ['/private/:path*', '/api/:path*'],
};

Now, if we navigate back to http://localhost:3000/private and click our Call API button, we will see the response data object proudly displayed on our Private page, which means we can securely access our protected Server API:

Screenshot

In this article, we learned how to integrate a Next.js frontend with a custom backend Express.js server API using Auth0 for authentication. We covered topics such as setting up a custom backend server, configuring Auth0 for authentication, and integrating the backend with the frontend using rewrites and middleware. By following the steps outlined in this tutorial, you should now have a solid understanding of how to integrate Next.js with Express.js using Auth0 for authentication.

The final code for this post is saved as a project template in this GitHub repo. If you found this code helpful, give the repo a star! ⭐

The authentication methods discussed in this post are techniques I learned and applied in the development of my startup, tentatively named Audio Archive. Audio Archive is a multi-seller marketplace built with node.js, where music producers can buy and sell Digital Audio Workstation (DAW) files."

If you enjoyed this post, stay tuned as I will continue documenting what I learn in the development of Audio Archive. Lastly, if you would like to see the WIP of that project you can find the code here: github.com/benjamin-chavez/AudioArchive.

Contact