Go back

Authentication in Next.js with cookies and getServerSideProps

Frontend security is a field in web development that is rarely talked about by developers although a majority of web applications have one or more security patterns they use.

One niche in this field is authentication and there are various authentication patterns out there. From the oldest and most common one, password authentication to passwordless auth, single sign-on (SSO) auth, session-cookie auth, token-based auth, HTTP Basic Authentication, etc.

Here's how authentication works in general;

The user sends a request through the client — a web browser mostly — to a resource on the server, say a protected route like the web app's dashboard

If the user did not provide the credentials that allow them access to such resource, the server responds with an "unauthorized" error with the 401 status code. This pattern is common in the HTTP Basic Authentication pattern where you'd find developers passing an authorization parameter in the request header like so:

const getData = async () => {
  const request = await fetch('/some-api/endpoint', {
    method: 'GET' | 'POST' | 'PATCH',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `bearer ${token}`,
    },
  })
 
  const response = await request.json()
}

In scenarios where this pattern is absent, the user is required to input their credentials (email or username and password) for them to be authenticated.

When the user signs in successfully, a common pattern used by developers is assigning an authentication token that can be used for subsequent requests — with the Authorization parameter aforementioned — to the server in the app.

If the information supplied by the user matches what's on the server, access is granted to the user. If not, it returns with the 401: Unauthorized status code.

There are tons of authentication patterns out there. But, the scope of this article focuses on this basic approach with Next.js' getServerSideProps and cookies to preserve the authentication state.

Managing form and authentication state

The first time I tried implementing auth with Next.js I went the crude way. Talk about input validation with document.querySelector, client-side auth-state with createContext — which in turn led to a flash (around 500ms or so) of protected resources in the dashboard because the redirect was done on the client-side.

// /pages/dashboard/index.ts
import { useRouter } from 'next/router'
 
React.useEffect(() => {
  if (!token) {
    router.push('/signin')
  }
}, [])

The last project I worked on — more like what I'm currently working on — sort of necessitated me to use a tool like Formik to handle the state of the forms, I highly recommend you give it a try.

Since Yup was recommended in the Formik docs for validation, I resorted to it as my go-to approach for validating user inputs. Although, I did not go with the approach that extended some form components of Formik for the sake of "reducing boilerplate" code.

I yearned for flexibility and the ability to be able to customize the little interactions resulting from the actions carried out by whoever interacts with the form's interface.

If you happen to be reading this article, I want to assume that you're not using an auth provider like next-auth, rather, you probably have an API endpoint that you're required to send a POST request to — perhaps to sign a user up or sign them in to use your app with their credentials.

That will be the basis of this experience that I'm trying to walk you through.

Because, various security patterns concern client-side authentication, of the many approaches towards preserving auth state, the use of browser cookies seems to be the most secure way to keep the JSON Web Token (JWT) that is assigned to a user when they sign in.

I knew that interacting with the browser cookie — with document.cookie — as means of storing the token may be futile since that will be more or less the first time I'll be doing so. After searching for a while, I found a package that seemed suitable with Next.js — nookies

It is a collection of cookie helper function for Next.js with SSR support. I can use it to store a cookie — setCookie(), read the content of a cookie — parseCookies() and delete a cookie — destroyCookie(). This comes in handy when you want to implement a logout feature.

Setting up the form

The previous section was more of a primer on how the use of Formik and nookies makes the process a bit easier.

Ideally, one would use the state hook in react — useState() — to track user inputs and handle the submit process. But, with Formik, you might not need to worry about that, and validation becomes a bit less strenuous with Yup.

Let's create a file that we can always fall back to in the course of this article. /utils/token.js|ts will hold the token variable that we can always refer to.

import { setCookie, parseCookies } from 'nookies'
 
const cookies = parseCookies()
export const token = cookies['auth-token']

The snippet above assumes that you have used setCookie() — which you'll see in a short while — to store the token in a browser cookie with the identifier name, "auth-token". If you choose to give it a different name, then, token becomes:

export const token = cookies['whatever-name-you-call-it']

Now, that we've established how we'd access the token above. It is time to use it in the form.

import { token } from '@utils/token'
import { useRouter } from 'next/router'
import { useFormik } from 'formik'
import * as Yup from 'yup'
 
const Signin = () => {
  const router = useRouter()
  const [error, setError] = React.useState('')
  const [success, setSuccess] = React.useState('')
 
  React.useEffect(() => {
    if (token) {
      router.push('/dashboard')
    }
  }, [])
 
  return (
    <>
      <div>
        {error ? <AlertComponent type="error" message={error} /> : null}
        {success ? <AlertComponent type="success" message={success} /> : null}
 
        <form onSubmit={formik.handleSubmit}>
          <input
            id="email"
            placeholder="valid email required"
            {...formik.getFieldProps('email')}
            className={
              formik.touched.email && formik.errors.email ? 'shake' : ''
            }
          />
          {formik.touched.email && formik.errors.email ? (
            <p color="var(--blood)">{formik.errors.email}</p>
          ) : null}
 
          <input
            id="password"
            placeholder="enter your password"
            {...formik.getFieldProps('password')}
            className={
              formik.touched.password && formik.errors.password ? 'shake' : ''
            }
          />
          {formik.touched.password && formik.errors.password ? (
            <p color="var(--blood)">{formik.errors.password}</p>
          ) : null}
 
          <button type="submit" disabled={formik.isSubmitting}>
            sign in
          </button>
        </form>
      </div>
    </>
  )
}
 
export default Signin

In the snippet above, you'll see that the appropriate packages that this form depends on, have been imported. One important snippet to note is what's going on in the useEffect hook.

React.useEffect(() => {
  if (token) {
    router.push('/dashboard')
  }
}, [])

If you look closely, you'll see that there's a condition that checks if the authentication token is present in the browser's cookie. If it is, the user is redirected to the dashboard.

Although this approach is completely okay for routes — like /signin, or /signup — that do not have protected resources like the dashboard, that flash of the signin page will come up.

Sometimes it may take a while, depending on your internet connectivity before you get redirected. But, if we're to consider a route that has protected resources — like the dashboard, it'll be important to consider implementing this on the server with getSetverSideProps

You can jump to the section that covers protected routes with getServerSideProps if that's what interests you.

In the snippet again, you'll notice how we're declaring some state variables that hold the error or success state of your request to the API endpoint. This is crucial for error handling.

Now, unto the part that may seem confusing; You may have noticed some: {...formik.getFieldProps} and formik.errors.password expressions flying around and wondered: "What the hell is going on here?".

Hold on a bit, let's walk through it together.

Remember how I mentioned something related to reducing boilerplate code and not wanting to go with that approach because I wanted to be able to customize the experience a bit?

So, this is it. In the snippet below, we'll walk through it.

const formik = useFormik({
  initialValues: {
    email: '',
    password: '',
  },
 
  validationSchema: Yup.object({
    email: Yup.string()
      .email('Invalid email address')
      .required('Email address is Required'),
    password: Yup.string()
      .min(6, 'password must be at least 6 characters')
      .required('You forgot to set a password.'),
  }),
 
  // ...
})

The logic above depends solely on the useFormik hook, you'll also notice how we're using Yup for validation. But, before we get into so many details, remember formik.errors and formik.touched?

They are methods extended from the formik hook that lets us track the error state of the form. But, one unique thing about Formik is how we can use formik.touched to only render an error message for an input field that the user has touched or visited, i.e. a field that the user is currently typing in.

The validationSchema with Yup only ensures that the email field is required and the password field accepts nothing less than six characters. If you want to implement a validationSchema that handles a scenario where people have to confirm their password, the snippet below would suffice.

validationSchema: Yup.object({
  email: Yup.string()
    .email('Invalid email address')
    .required('Email address is Required'),
  username: Yup.string()
    .min(3, 'username should be at least 3 characters')
    .required('username is required'),
  password: Yup.string()
    .min(6, 'password must be at least 6 characters')
    .required('You forgot to set a password.'),
  confirmPassword: Yup.string()
    .oneOf([Yup.ref('password')], 'Passwords do not match')
    .required('Please confirm your password.'),
})

The validationSchema above assumes that you have an email, username, password, and confirmPassword fields in your form. The important part of the schema is where we're using Yup.string().oneOf([Yup.ref("password")])

It simply means you need to have an input field that has a ref (attribute) value of "password" like so:

<input id="password" placeholder="enter your password" />

Now, onto {...formik.getFieldProps}; The general way to use formik in a React form, would resemble the one in the snippet below, where you have to pass in some attributes like onChange, onBlur and value to the element.

<input
  id="username"
  name="username"
  type="text"
  onChange={formik.handleChange}
  onBlur={formik.handleBlur}
  value={formik.values.username}
/>

But, with {...getFieldProps} we get to reduce the number of things we append to the input element, and still obtain the appropriate information of a particular form field. You can read more about it here

The onSubmit method is where you'd place the signin logic of your form. In the snippet below, we're making a request to an API endpoint called /login. Although that is not the exact URL, with Next.js' API routes, I can mask API endpoints in production.

const formik = useFormik({
  // ...
 
  onSubmit: (values, { setSubmitting }) => {
    setTimeout(async () => {
      const request = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify({
          password: values.password,
          email: values.email,
        }),
        headers: {
          'Content-Type': 'application/json',
        },
      })
 
      const result = await request.json()
      const { non_field_errors: requestError, auth_token } = result
      setError(requestError)
    }, 600)
  },
})

From the snippet above, you'll see how we obtained the information from the request by destructuring them. Your API response may be entirely different, but, one thing you be on the lookout for is the auth token variable.

Because, that's what you'll use in preserving the authentication state of the user by storing it in a browser cookie with nookies, as seen in the snippet below. If the request is ok, with a status code of 200 or 201

const formik = useFormik({
  // ...
 
  onSubmit: (values, { setSubmitting }) => {
    // ...
 
    setTimeout(() => {
      if (request.ok) {
        setCookie(null, 'auth-token', auth_token, {
          path: '/',
          sameSite: 'strict',
          maxAge: 3 * 24 * 60 * 60, // expires in 3 days
        })
        setSuccess('Logged in successfully!')
        router.push('/dashboard', undefined, { shallow: true })
      }
 
      setSubmitting(false)
    }, 600)
  },
})

Remember how we established that the token variable should have "auth-token" identifier in the browser cookie? Well, the setCookie function from nookies helps us achieve that.

In it, we have the path property that helps us ensure that the cookie remains active on some specific route. In our case here, it should work across all the routes. The maxAge property specifies how long the cookie stays before it becomes invalid.

One last thing to note is how we're using router.push to redirect the user to the dashboard. By setting the shallow property to be true, we change the url of the page and redirect there without invoking any data-fetching methods or processes again.

Remember to call the handleSubmit and isSubmitting methods on the <form> and <button> elements to make the request to the endpoint and selectively disable the button when no info has been filled into the form, respectively.

<form onSubmit={formik.handleSubmit}>
  <input
    id="email"
    placeholder="valid email required"
    {...formik.getFieldProps('email')}
    className={formik.touched.email && formik.errors.email ? 'shake' : ''}
  />
  {formik.touched.email && formik.errors.email ? (
    <p color="var(--blood)">{formik.errors.email}</p>
  ) : null}
 
  <input
    id="password"
    placeholder="enter your password"
    {...formik.getFieldProps('password')}
    className={formik.touched.password && formik.errors.password ? 'shake' : ''}
  />
  {formik.touched.password && formik.errors.password ? (
    <p color="var(--blood)">{formik.errors.password}</p>
  ) : null}
 
  <button type="submit" disabled={formik.isSubmitting}>
    sign in
  </button>
</form>

And, in the input elements, whenever errors and touched conditions are met, a shake class is added to the input field which shakes the input field and sort of draws the attention of the user to the field where there's an error

If you're using a UI library like ChakraUI you can customize your button component to appear intuitive and provide a good UX for the person using your form. One way is to show them a loading state like so:

const ButtonComponent = ({
  width,
  children,
  isSubmitting,
  loadingText,
  ...props
}: BtnProp) => {
  return (
    <>
      <Button
        bg={'#000'}
        py={'1.7em'}
        color={'#fff'}
        fontWeight={'400'}
        w={width ? width : '100%'}
        _hover={{ bg: '#000' }}
        isLoading={isSubmitting}
        loadingText={loadingText}
        {...props}
      >
        {children}
      </Button>
    </>
  )
}

Then in the form, you'll proceed to use it like so:

<ButtonComponent
  textTransform="capitalize"
  type="submit"
  fontWeight="400"
  fontSize="sm"
  isSubmitting={formik.isSubmitting}
  loadingText="preparing your dashboard"
>
  sign in
</ButtonComponent>

Protected routes with getServerSideProps

One of the benefits of using nookies is that you get to be able to parse the information in a cookie from the server side too. And, with that, you can implement a protected route without worrying about that flash of protected resource(s).

Here's what the /page/dashboard/index.tsx file will appear like:

import { parseCookies } from 'nookies'
import { GetServerSidePropsContext } from 'next'
 
const Index = () => {
  return (
    <>
      <Head>
        <title>Dashboard</title>
      </Head>
      <Dashboard data={projects} />
    </>
  )
}
 
export default Index
 
export async function getServerSideProps(context: GetServerSidePropsContext) {
  const { req } = context
  const cookies = parseCookies({ req })
  const token = cookies['auth-token']
 
  // re-route the user to the signin page, if there's no cookie
  if (!token) {
    return {
      redirect: {
        destination: '/auth/signin',
        permanent: false,
      },
    }
  }
 
  const data = ['some data']
 
  return {
    props: {
      data: data,
    },
  }
}

If you look closely, you'll notice that the way we're using nookies here is a bit different. Instead of the normal way, we're accessing the info from the req: Request context of Next.js' GetServerSidePropsContext

In the return props of getServerSideProps, if you don't happen to have any data fetching going on at the server-side, you can return an empty object like so:

return {
  props: {
    data: {},
  },
}

Note: This is only needed if you're writing Typescript.

The use of GetServerSidePropsContext was necessary because the type annotation of context was any and to get rid of that warning, I employed GetServerSidePropsContext

Wrapping up

Although, this may just appear as a walkthrough of auth in a Next.js app. You can decide on whichever pattern feels convenient enough for you.

If you want to implement a logout feature, make sure you pass the exact cookie information that you used while setting up the signup process. Something like the snippet below should suffice. But, feel free to improve it.

import { token } from '@utils/token'
 
const handleLogout = async () => {
  try {
    const request = await fetch('/api/logout', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Token ${token}`,
      },
    })
 
    destroyCookie(null, 'auth-token', {
      path: '/',
      sameSite: 'strict',
      maxAge: 0,
    })
    router.push('/auth/signin')
 
    if (!request.ok) {
      setSuccess('Logged out successfully!')
      setError('')
    }
  } catch (error) {
    setError('Failed to logout')
    setSuccess('')
  }
}

One more thing I'd suggest is the addition of a logic that helps you check when the cookie has expired, if it has, you show a popup or modal that has a higher z-index than the page content with information on what's going on.

A "Your session has expired. You might want to log in again" text may be suitable in this case. Try this out when you get to this point, and tweet at me @kafLamed when you find a solution.

And, that's it — for now.

If you've followed this guide up to this point, you might want to check this guide that walks you through the process of persiting authentication state and choosing the right cookie wrappers.