Authentication with Next.js

Implementing Spotify Social Authentication with Next.js API routes

Profile photo

by Mark MurrayOctober 20, 20218 min read

Update (August 2026): This Pages Router article now includes OAuth state validation, stores only the data needed by the application, and fixes the original response and cookie examples. For a new application, also evaluate a maintained authentication library rather than owning the entire flow.

Let's just dive straight into it. You're building an app and you're using Next.js. Nice choice!

After setting up social authentication for a project called Spotify Tracklist, I figured I should share my learnings to help those looking to do the same.

Note: this post describes how to set up authentication for Spotify specifically, but you can swap out the integration for any other provider as the flow will be the same.

Setting up a new Next project

If you already have a project using Next.js, you can skip this part.

# Create a new Next.js project
npx create-next-app@latest nextjs

cd nextjs

# Install the dependencies used below
yarn add @hapi/iron axios cookie spotify-web-api-node

# Start your app
yarn dev

Set up a new application in your chosen authentication provider

In this case, we're using Spotify so go to Spotify for Developers, sign in and create a new application.

Once complete, you should have valid client_id and client_secret credentials ready for use in your app. Add these to your .env file like so:

CLIENT_ID="your_spotify_client_id"
CLIENT_SECRET="your_spotify_client_secret"
REDIRECT_URI="http://localhost:3000/api/auth/callback"
SESSION_SECRET="replace_with_at_least_32_random_characters"
OAUTH_STATE_COOKIE="spotify.oauth.state"

Before continuing, please ensure your .env file has been added to the .gitignore file so that it's not committed to version history.

Note: in this case, we've set the REDIRECT_URI value to a local URL. We'll want to change this to a production route before we deploy. Also note that we've set a SESSION_SECRET variable. We'll use this as a password to encrypt/decrypt user sessions.

Creating API routes

Our authentication flow will look like so:

Authentication flow
diagram

  1. Application sends an authorization request to our authentication provider (Spotify)
  2. Spotify requests the user to log in and grant access to our application
  3. User grants access and Spotify redirects the user to our REDIRECT_URI URL (specified in .env)
  4. Our application handles the redirect request under the /api/auth/callback URL
  5. We send an authentication request to Spotify, including a code query parameter that was sent to us via the /api/auth/callback endpoint.
  6. We fetch the user profile from Spotify
  7. We create a session containing token and profile information and return it to the user in the form of a cookie. At this stage the user is logged in.

Authorization route

(This post assumes you only want to set up authentication for a single provider - Spotify. You should change your route names accordingly if you want to use multiple providers)

First you'll want to create an /api/auth/login route. This route will act as the authorization endpoint for the authentication flow.

Create a new route by creating a /api/auth/login.ts file:

// pages/api/auth/login.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto';
import { serialize } from 'cookie';

// These are the application scopes requested from each user logging in
const scopes = [
  'streaming',
  'user-read-playback-state',
  'user-read-email',
  'user-read-private',
  'playlist-read-private',
  'playlist-modify-private',
  'playlist-modify-public',
];

// Pull the values defined in your .env file
const { CLIENT_ID, REDIRECT_URI, OAUTH_STATE_COOKIE } = process.env;

if (!CLIENT_ID || !REDIRECT_URI || !OAUTH_STATE_COOKIE) {
  throw new Error('Spotify OAuth environment variables are required');
}

const buildURL = (scopes: string[], callback: string, state: string) => {
  return (
    'https://accounts.spotify.com/authorize?response_type=code' +
    `&client_id=${CLIENT_ID}` +
    `&scope=${encodeURIComponent(scopes.join(' '))}` +
    `&redirect_uri=${encodeURIComponent(callback)}` +
    `&state=${encodeURIComponent(state)}`
  );
};

export default async (req: NextApiRequest, res: NextApiResponse) => {
  const state = randomBytes(32).toString('hex');

  res.setHeader(
    'Set-Cookie',
    serialize(OAUTH_STATE_COOKIE, state, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 10 * 60,
      path: '/',
    }),
  );

  // Redirect all requests to Spotify auth
  return res.redirect(buildURL(scopes, REDIRECT_URI, state));
};

Setup the Spotify SDK

We'll be using the Spotify web SDK to fetch data from Spotify, the user profile for example, so next let's set up an instance of the Spotify SDK that we can reuse:

// utils/spotify.ts
import Spotify from 'spotify-web-api-node';

// Create a new instance of the Spotify API
const createSpotifyApi = (token: string) => {
  const spotify = new Spotify({
    clientId: process.env.CLIENT_ID,
    clientSecret: process.env.CLIENT_SECRET,
    redirectUri: process.env.REDIRECT_URI,
  });

  spotify.setAccessToken(token);

  return spotify;
};

export default createSpotifyApi;

Authentication route

Now that we have our authorization route in place, we need a route in place to handle redirects from Spotify back to our application - via an /api/auth/callback route.

// pages/api/auth/callback.ts

import axios from 'axios';
import { parse, serialize } from 'cookie';
import { NextApiRequest, NextApiResponse } from 'next';
import querystring from 'querystring';

import createSpotifyApi from '../../../utils/spotify';

// We'll describe this function in the next section
import { setAuthCookie } from '../../../utils/cookies';

const { CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, OAUTH_STATE_COOKIE } =
  process.env;

if (!CLIENT_ID || !CLIENT_SECRET || !REDIRECT_URI || !OAUTH_STATE_COOKIE) {
  throw new Error('Spotify OAuth environment variables are required');
}

export default async (req: NextApiRequest, res: NextApiResponse) => {
  const { code, state } = req.query;
  const stateCookie = parse(req.headers.cookie ?? '')[OAUTH_STATE_COOKIE];

  if (
    typeof code !== 'string' ||
    typeof state !== 'string' ||
    !stateCookie ||
    state !== stateCookie
  ) {
    return res.status(400).json({ message: 'Invalid OAuth callback' });
  }

  try {
    const { data } = await axios.post(
      'https://accounts.spotify.com/api/token',
      querystring.stringify({
        grant_type: 'authorization_code',
        code,
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        redirect_uri: REDIRECT_URI,
      }),
    );

    const spotify = createSpotifyApi(data.access_token);

    const { body: profile } = await spotify.getMe();

    const session = {
      expiresAt: Date.now() + data.expires_in * 1000,
      user: {
        id: profile.id,
        display_name: profile.display_name,
        email: profile.email,
      },
      accessToken: data.access_token,
    };

    // Send the session information to our user in the form of a cookie header.
    // We'll describe this function in the next step
    await setAuthCookie(res, session, {
      maxAge: data.expires_in,
    });

    // Remove the one-time state cookie and redirect after setting the session.
    res.appendHeader(
      'Set-Cookie',
      serialize(OAUTH_STATE_COOKIE, '', { maxAge: 0, path: '/' }),
    );
    return res.redirect(302, '/');
  } catch (error) {
    // You might want to log the error here
    return res.status(500).json({
      statusCode: 500,
      message: 'Something went wrong',
    });
  }
};

Now when Spotify returns users to our site, we'll authenticate them and fetch their profile information. We'll store both their user data and token information in a session.

This session will be persisted in the form of a cookie that will be sent back to the browser.

// utils/cookies.ts

import * as Iron from '@hapi/iron';
import { CookieSerializeOptions, serialize } from 'cookie';
import { NextApiResponse } from 'next';

const { SESSION_SECRET } = process.env;

if (!SESSION_SECRET) {
  throw new Error('SESSION_SECRET is required');
}

export interface UserSession {
  expiresAt: number;
  user: {
    id: string;
    display_name: string;
    email: string;
  };
  accessToken: string;
}

export const setAuthCookie = async (
  res: NextApiResponse,
  session: UserSession,
  options: CookieSerializeOptions = {},
) => {
  const defaults: CookieSerializeOptions = {
    maxAge: 3600,
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
  };
  const opts: CookieSerializeOptions = { ...defaults, ...options };

  try {
    // We're encrypting our session here using the SESSION_SECRET defined in our
    // .env file.
    const signedSession = await Iron.seal(
      session,
      SESSION_SECRET,
      Iron.defaults,
    );

    const stringValue =
      typeof signedSession === 'object'
        ? 'j:' + JSON.stringify(signedSession)
        : String(signedSession);

    if (typeof opts.maxAge === 'number') {
      opts.expires = new Date(Date.now() + opts.maxAge * 1000);
    }

    // Set the cookie in the header of the response
    res.setHeader('Set-Cookie', serialize('auth.session', stringValue, opts));
  } catch (error) {
    console.error('Failed to seal session object', error);
    return;
  }
};

Now that we can log users in and store the auth state in a cookie in the user's browser, we'll need a way to parse the session and check if the user is logged in every time they use the site. To do so, we'll need to update our pages/index.tsx file:

import cookie from 'cookie';
import { GetServerSideProps } from 'next';
import * as Iron from '@hapi/iron';

import App from '../src/App';
import { UserSession } from '../utils/cookies';

export const getSessionCookie = async (
  cookies: Record<string, string>,
): Promise<UserSession> => {
  const cookie = cookies['auth.session'];

  if (!cookie) {
    throw new Error('Auth session not found');
  }

  // Decrypt the auth cookie
  const decoded = await Iron.unseal(
    cookie,
    process.env.SESSION_SECRET,
    Iron.defaults,
  );

  if (decoded.expiresAt <= Date.now()) {
    throw new Error('Auth session has expired');
  }

  return decoded as UserSession;
};

export const getServerSideProps: GetServerSideProps = async ({ req }) => {
  try {
    const cookies = cookie.parse(req.headers.cookie || '');
    const session = await getSessionCookie(cookies);

    return {
      props: {
        user: session.user,
      },
    };
  } catch {
    return {
      props: {},
    };
  }
};

export default function IndexPage(props) {
  return <App user={props.user} />;
}

Add login UI

Great, now it's just a matter of adding a "Login with Spotify" button to our application to facilitate the login flow.

import React from 'react';

const App = ({ user }) => {
  return (
    <div>
      {user ? (
        <div>{user.display_name} is currently logged in</div>
      ) : (
        <a href="/api/auth/login">Login with Spotify</a>
      )}
    </div>
  );
};

export default App;

And there we have it: social authentication with Next.js.


Thanks for reading. Let me know in the comments below if it was helpful or not.

Copyright © Mark Murray, 2026