In the past few years, adding authentication to your app has gone from something obscure and complicated to something you can literally just use an API for.

There’s no shortage of example repos and tutorials for how to implement specific auth schemes in Next.js, but less about the why of what schemes, tools, and tradeoffs are chosen.

This post will walk through what to consider when approaching authentication in Next.js, from picking a provider to building routes for sign-in and choosing between server vs. client side.

Choosing an Authentication Method / Provider

There are basically 1,000 ways to build authentication into your app. Rather than focus on particular providers here (the subject for another blog post), let’s look at types of auth solutions and a few examples of each. In terms of implementation, next-auth is quickly becoming a popular option for integrating your Next.js app with multiple providers, adding SSO, etc.

Traditional Database

This one is as simple as it gets: you store usernames and passwords in a relational database. When a user signs up for the first time, you insert a new row into the `users` table with the provided information. When they log in, you check the credentials against what you have stored in the table. When a user wants to change their password, you update the value in the table.

Traditional database auth is certainly the most popular auth scheme when you look at the totality of existing applications and has existed basically forever. It’s highly flexible, cheap, and doesn’t lock you into any particular vendor. But you do need to build it yourself, and in particular, worry about encryption and making sure those sweet, sweet passwords don’t fall into the wrong hands.

Authentication Solutions From Your Database Provider

Over the past few years (and credit to Firebase, more than a few years ago), it has become relatively standard for managed database providers to offer some sort of managed authentication solution. Firebase, Supabase, and AWS all offer both managed database and managed authentication as a service via a suite of APIs that easily abstracts user creation and session management (more on this later).

Signing a user in with Supabase authentication is as simple as:

async function signInWithEmail() {
  const { data, error } = await supabase.auth.signInWithPassword({
    email: '[email protected]',
    password: 'example-password',
  })
}

Authentication Solutions Not From Your Database Provider

Perhaps even more common than authentication as a service from your DBaaS is authentication as a service as an entire company or product. Auth0 has been around since 2013 (now owned by Okta), and recent additions like Stytch have prioritized developer experience and gained some mindshare.

The UI you get as part of using Auth0 for authentication
Auth0 for authentication

Single Sign On

SSO lets you “outsource” your identity to an external provider, which can range from an enterprise, security-focused vendor like Okta to something more widely adopted like Google or GitHub. Google SSO is ubiquitous in the SaaS world, and some developer-focused tools only authenticate via GitHub.

Whichever provider(s) you choose, SSO is generally an add-on to the other types of authentication above and carries its own idiosyncrasies around integrating with external platforms (be warned: SAML uses XML).

Okay, So Which One Is for Me?

There’s no “correct” choice here – what’s right for your project depends on your priorities. If you want to get things moving fast without a ton of upfront configuration, outsourcing auth makes sense (even outsourcing it completely, UI included, to someone like Auth0). If you anticipate a more complex setup, building your own auth backend makes sense. And if you envision supporting larger customers, you’ll need to add SSO at some point.

Next.js is so popular at this point that most of these authentication providers have Next.js specific docs and integration guides.

Building Routes for Sign-Up and Sign-In, and Tips for Going to Extra Authentication Mile

Some authentication providers like Auth0 actually provide entire hosted web pages for sign-up and sign-in. But if you’re building those from scratch, I find it’s useful to create them early in the process since you’ll need them to provide as redirects when you actually implement your auth.

So it makes sense to create the structure for these pages and then add the requests to the backend after the fact. The most straightforward way to implement auth is to have two of these routes:

  • One for signing up
  • Another for signing in once the user already has an account

Beyond the basics, you’ll need to cover edge cases like when a user forgets their password. Some teams prefer to have the password reset process on a separate route, while others add dynamic UI elements to the regular sign-in page.

A nice sign-up page might not mean the difference between success and failure, but small touches can leave a good impression and overall provide a better UX. Here are a few collated from sites around the web that have put a little extra love into their authentication processes.

1. Update Your Nav Bar if There’s an Active Session

Stripe’s call to action in their navbar changes based on whether you have an authenticated session or not. Here’s what the marketing site looks like if you’re not authenticated. Note the call to action to sign in:

Stripe homepage
Stripe’s homepage changes the CTA based on whether you’re authenticated

And here’s what it looks like if you are authenticated. Note that the call to action changes to take the user to their dashboard instead of signing in:

Stripe homepage changes
Stripe homepage changes

Doesn’t fundamentally change my Stripe experience, but it’s nice.

An interesting technical aside: there’s a good reason that most companies don’t have the navbar on their marketing site “depend” on authentication – it would mean an extra API request to check for authenticated state on every single page load, the majority of which are for visitors who are probably not.

2. Add Some Helpful Content Alongside the Sign-Up Form

Over the past couple of years, especially in SaaS, companies have started adding content to the sign-up page to “encourage” the user to actually complete the sign-up. This can help improve your conversion on the page, at least incrementally.

Here’s a signup page from Retool, with an animation and some logos on the side:

Retool signup page
If you are going to do this, try and make sure the fonts on each side match.

We also do this at Kinsta for our signup page:

MyKinsta sign in
Kinsta sign-up page

The little extra content can help remind the user what they’re signing up for and why they need it.

3. If Using a Password: Suggest or Enforce a Strong One

I feel comfortable saying that it’s common knowledge among developers that passwords are inherently insecure, but it’s not common knowledge among all of the people who will be signing up for your product. Encouraging your users to create secure passwords is good for you and good for them.

Coinbase is pretty strict with signup and requires you to use a secure password that’s more complicated than just your first name:

Coinbase create account
Weak password on Coinbase

After generating one from my password manager instead, I was good to go:

Coinbase password strength
Strong password on Coinbase

The UI didn’t tell me why the password wasn’t secure enough, though, or any requirements beyond having a number. Including those in your product copy will make things smoother for your user and help avoid password retry frustration.

4. Label Your Inputs so They Play Nice With a Password Manager

One in three Americans use a password manager like 1Password, and yet so many forms on the web continue to ignore the `type=` in HTML inputs. Make your forms play ball with password managers:

  • Enclose your input elements in a form element
  • Give inputs a type and a label
  • Add autocomplete capabilities to your inputs
  • Don’t dynamically add fields (I’m looking at you, Delta)

It can make the difference between a 10-second, incredibly smooth sign-in and an annoying manual one, especially on mobile.

Choosing Between Sessions and JWT

Once your user authenticates, you need to choose a strategy for maintaining that state throughout subsequent requests. HTTP is stateless, and we certainly don’t want to ask our user for their password on every single request. There are two popular methods for handling this – sessions (or cookies) and JWTs (JSON web tokens) – and they differ in terms of whether the server or the client does the work.

Sessions, AKA Cookies

In session-based authentication, the logic and work for maintaining authentication is handled by the server. Here’s the basic flow:

  1. The user authenticates via the sign-in page.
  2. The server creates a record that represents this particular browsing “session.” This typically gets inserted into a database with a random identifier and details about the session, like when it started and when it expires.
  3. That random identifier – something like `6982e583b1874abf9078e1d1dd5442f1` – gets sent to your browser and stored as a cookie.
  4. On subsequent requests from the client, the identifier is included and checked against the sessions table in the database.

It’s pretty straightforward and tweakable when it comes to how long sessions last, when they should be revoked, etc. The downside is latency at a significant scale, given all the writes and reads to the DB, but that might not be a major consideration for most readers.

JSON Web Tokens (JWT)

Instead of handling authentication for subsequent requests on the server, JWTs allow you to handle them (mostly) on the client side. Here’s how it works:

  1. The user authenticates via the sign-in page.
  2. The server generates a JWT that contains the identity of the user, the permissions they’re granted, and an expiration date (among potential other things).
  3. The server signs that token cryptographically encrypts the contents of it, and sends the entire thing to the client.
  4. For each request, the client can decrypt the token and verify that the user has permission to make the request (all without needing to communicate with the server).

With all of the work post-initial authentication offloaded to the client, your application can load and work much faster. But there’s one main issue: there’s no way to invalidate a JWT from the server. If the user wants to log out of a device or the scope of their authorization changes, you need to wait until the JWT expires.

Choosing Between Server Side and Client Side Auth

Part of what makes Next.js great is the built-in static rendering – if your page is static, i.e. doesn’t need to make any external API calls, Next.js automatically caches it and can serve it extremely fast via a CDN. Pre-Next.js 13 knows if a page is static if you don’t include any `getServerSideProps` or `getInitialProps` in the file, while any versions post Next.js 13 use React Server Components to do this instead.

For authentication, you have a choice: rendering a static “loading” page and doing the fetching client side or doing everything server side. For pages that require authentication[1], you can render a static “skeleton” and then make authentication requests on the client side. In theory, this means the page loads faster, even if the initial content isn’t fully ready to go.

Here’s a simplified example from the docs that renders a loading state as long as the user object isn’t ready:

import useUser from '../lib/useUser'
 
const Profile = () => {
  // Fetch the user client-side
  const { user } = useUser({ redirectTo: '/login' })
 
  // Server-render loading state
  if (!user || user.isLoggedIn === false) {
    // Build some sort of loading page here
    return <div>Loading...</div>
  }
 
  // Once the user request finishes, show the user
  return (
    <div>
      <h1>Your Account</h1>
      <p>Username: {JSON.stringify(user.username,null)}</p>
      <p>Email: {JSON.stringify(user.email,null)}</p>
      <p>Address: {JSON.stringify(user.address,null)}</p>
    </div>
  )
}
 
export default Profile

Note that you’d need to build some sort of loading UI here to hold space while the client makes requests post-load.

If you want to simplify things and run authentication server side, you can add your authentication request into the `getServerSideProps` function, and Next will wait to render the page until the request is complete. Instead of the conditional logic in the snippet above, you’d run something simpler like this simplified version of the Next docs:

import withSession from '../lib/session'
 
export const getServerSideProps = withSession(async function ({ req, res }) {
  const { user } = req.session
 
  if (!user) {
    return {
      redirect: {
        destination: '/login',
        permanent: false,
      },
    }
  }
 
  return {
    props: { user },
  }
})
 
const Profile = ({ user }) => {
  // Show the user. No loading state is required
  return (
    <div>
      <h1>Your Account</h1>
      <p>Username: {JSON.stringify(user.username,null)}</p>
      <p>Email: {JSON.stringify(user.email,null)}</p>
      <p>Address: {JSON.stringify(user.address,null)}</p>
    </div>
  )
}
 
export default Profile

There’s still logic in here to handle when authentication fails, but it redirects to login instead of rendering a loading state.

Summary

So which one of these is right for your project? Start by evaluating how confident you are in the speed of your authentication scheme. If your requests are taking no time at all, you can run them server side and avoid the loading state. If you want to prioritize rendering something right away and then waiting for the request, skip `getServerSideProps` and run authentication elsewhere.

[1] When using Next, this is a good reason not to blanket-require authentication for every single page. It’s simpler to do so, but means you miss out on the performance benefits of the web framework in the first place.

Justin Gage

Justin is a technical writer and author of the popular Technically newsletter. He did his B.S. in Data Science before a stint in full-stack engineering and now focuses on making complex technical concepts accessible to everyone.