Custom Login With Nextjs and Amazon Cognito

George Stefanis
George Stefanis

Some prerequisites

In this tutorial I assume you have yarn or npm installed and you can translate commands from one to the other. I will be using yarn here. I also assume that you have the AWS CLI installed and configured. During the configuration stage you will set an AWS user to run all your Amplify commands. This user needs to have all the relevant IAM roles attached to it.

You will also need a new user on your brand new Cognito user pool. I wrote about this here.

Setting up our NextJS things

Go to your favorite folder and do yarn create next-app, follow the instructions and you should have a brand new folder, named the way you chose and everything ready to start. If go into that folder you do yarn dev at this point you will be able to go to http://localhost:3000 and see the basic NextJS screen,

Amplify CLI

Amplify will help us set up the infrastructure and do all the heavy lifting. You will want that installed globally. To achieve that you can do yarn add global @aws-amplify/cli. If after installing it you fail to access to command globally you might need to add the global node_modules folder into your PATH.

Next you will want to configure it. The configuration starts with amplify configure and you just need to follow the directions. One small caveat here. The AWS CLI user that you've set up in the prerequisites needs to have access to the AWS things you are about to use. So if you are planning to go Serverless and use Cognito, Lambda and API Gateway you need to go to the IAM console of a root user and give those permissions to your Amplify user. For further information around configuring amplify you can check here and here for the auth part.

Installing the necessary packages

For the AWS Cognito I will be using Amplify. I also like a lot what the TailwindCSS team is doing so I'll be using that too. Finally I did add Typescript to the project. Lately I can't think of a new project without Typescript. With all that in mind my package.json file looks something like this:

{
  "name": "myNextJSProject",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint --ext .ts,.tsx"
  },
  "dependencies": {
    "@aws-amplify/cli": "^4.44.0",
    "@tailwindcss/forms": "^0.2.1",
    "autoprefixer": "^10.2.4",
    "aws-amplify": "^3.3.20",
    "aws-amplify-react": "^4.2.25",
    "global": "^4.4.0",
    "next": "10.0.7",
    "postcss": "^8.2.6",
    "react": "17.0.1",
    "react-dom": "17.0.1",
    "tailwindcss": "^2.0.3"
  },
  "devDependencies": {
    "@types/node": "^14.14.31",
    "@types/react": "^17.0.2",
    "@typescript-eslint/eslint-plugin": "^4.15.1",
    "@typescript-eslint/parser": "^4.15.1",
    "eslint": "^7.20.0",
    "eslint-config-prettier": "^7.2.0",
    "eslint-plugin-prettier": "^3.3.1",
    "eslint-plugin-react": "^7.22.0",
    "husky": "^5.0.9",
    "prettier": "^2.2.1",
    "pretty-quick": "^3.1.0",
    "typescript": "^4.1.5"
  },
  "husky": {
    "hooks": {
      "pre-commit": "pretty-quick --staged && npm run lint"
    }
  }
}

At minimum you need to do yarn add aws-amplify/cli aws-amplify aws-amplify-react. I won't go into details about setting up Typescript, eslint, prettier and all that. The NextJS team has already a pretty good starting guide here

Bootstrapping Amplify in our project

In NextJS your main pages live in the pages folder. Right now you will see an _app.tsx page (or _app.js if you didn't go with Typescript). There you will need to do all the global things that will be used by all of your pages. Things like global css and of course the original Amplify configure will need to be there.

So go to the top of the file and add the following lines:

import awsExports from '../components/aws-exports';

Amplify.configure({ ...awsExports, ssr: true });

My final file looks like this:

import Amplify from 'aws-amplify';
import { AppProps } from 'next/app';
import awsExports from '../components/aws-exports';
import '../styles/globals.css';

Amplify.configure({ ...awsExports, ssr: true });

function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}

export default MyApp;

Note that the ../components/aws-exports is a location I chose during the amplify configure. Your location will probably be different. But if you did the configuration correctly you'll find it somewhere in your project.

The main page

The main page won't get any design awards right now but we'll just put a login button that will redirect us to the login page. So go into pages/index.tsx and do so. My index.tsx looks like that:

import Head from 'next/head';
import Router from 'next/router';
import styles from '../styles/Home.module.css';

export default function Home() {
  return (
    <div className={styles.container}>
      <Head>
        <title>Create Next App</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <button
          type='button'
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
          onClick={() => Router.push('login')}
        >
          Login
        </button>
      </main>

      <footer className={styles.footer}>
        <a
          href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          Powered by{' '}
          <img src="/vercel.svg" alt="Vercel Logo" className={styles.logo} />
        </a>
      </footer>
    </div>
  );
}

Login Page

This is the final bit. The important one. You probably came here searching for this. The important function that will handle your username and password login dance is the following:

type ILoginState = {
  status: string | null;
  error: string | null;
};

const signIn = async (
  username: string,
  password: string,
  onStateChange: Dispatch<SetStateAction<ILoginState>>
) => {
  try {
    await Auth.signIn(username, password);
    onStateChange({ status: 'signedIn', error: null });
  } catch (err) {
    if (err.code === 'UserNotConfirmedException') {
      await Auth.resendSignUp(username);
      onStateChange({ status: 'confirmSignUp', error: null });
    } else if (err.code === 'NotAuthorizedException') {
      // The error happens when the incorrect password is provided
      onStateChange({
        status: 'NotAuthorizedException',
        error: 'Login failed.',
      });
    } else if (err.code === 'UserNotFoundException') {
      // The error happens when the supplied username/email does not exist in the Cognito user pool
      onStateChange({
        status: 'UserNotFoundException',
        error: 'Login failed.',
      });
      console.error(err);
    } else {
      onStateChange({ status: 'Error', error: 'An error has occurred.' });
      console.error(err);
    }
  }
};

From here all we need to do is use it in our component. This function takes as parameters the username the password and finally a setState function to keep the state in our component.

My component looks like so:

export default function Login() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const initLoginState: ILoginState = { status: null, error: null };
  const [loginState, setLoginState] = useState(initLoginState);

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    await signIn(username, password, setLoginState);
  };

  return (
    <div className="flex flex-col h-screen">
      <div className="relative h-32 w-32">
        <div className="absolute left-0 top-0 h-16 w-16">
          <button
            className="w-32 mt-4 font-medium"
            onClick={() => Router.back()}
          >
            &lt; Go back
          </button>
        </div>
      </div>
      <form
        className="flex flex-col space-y-2 m-auto w-1/3"
        onSubmit={handleSubmit}
      >
        <h1 className="text-xl text-center font-medium tracking-tight">
          Enter your username and password
        </h1>
        <input
          type="text"
          name="username"
          id="username"
          onChange={(e) => setUsername(e.target.value)}
        />
        <input
          type="password"
          name="pass"
          id="pass"
          onChange={(e) => setPassword(e.target.value)}
        />
        <Button title="Login" type="submit" />
      </form>
    </div>
  );
}

Logged in pages

If you were successful you will now find yourself logged in. You can use that into your different pages. A very skeleton of an example of such a page is as follows:

import { withAuthenticator } from 'aws-amplify-react';

function LoggedInPage({ authState, authData }) {
  console.log('session', authState, authData);

  return <h1>Welcome to the Logged in page!</h1>;
}

export default withAuthenticator(LoggedInPage);

The AuthState and the AuthData have all sorts of interesting information you might need. The AuthState is a string telling you what state your sign in workflow is in. The AuthData has all the relevant tokens for doing API requests in the other AWS services you will probably use.

From this point onwards you are ready to use your different APIs that you might have on AWS API Gateway or anything else.