DEV Community

Cover image for How to build a simple login with Nextjs? (and react hooks)
Martín Granados García
Martín Granados García

Posted on

How to build a simple login with Nextjs? (and react hooks)

This post is in no way endorsed by Zeit but a big shoutout to those guys because what they are building is amazing. As close as you can get to wizardry and superpowers (with Javascript at least).

I find the Next.JS framework to be amazingly simple and fast to learn. The documentation is great itself and they even have provided a learning site. Please do check it out.

You can review the full code in my repo:
https://github.com/mgranados/simple-login

And the final product, that uses this login with some improvements you can find it over here: Booktalk.io A page for sharing book reviews inspired heavily on Hacker News as you could notice. I will provide more intel on how to create more features and the full project on upcoming posts. Follow me if you are interested!

The setup 🛠

You need to have Node +10 installed and yarn or npm. I personally prefer yarn and will be using that through the tutorial but npm is perfectly fine as well. Commands are a bit different, that's it.

Create a Nextjs app

As per Next.js team recommendation the preferred way to do this is:

yarn create next-app

(Assuming you have Node and Yarn installed)

That will create a folder structure that will look like this:
Next folder structure

The local development with Nextjs

That's it! You got it alright. Now to get to test the app you can run

yarn dev

And that should fire up the next dev build and expose a dev version on your http://localhost:3000/.

Let's build the API! 🏗

Now for starting crafting the API on NextJS 9.2 you can add a folder /pages/api and everything that you build there would be exposed as a serverless function when building for production in things like Now. How magical that is!?

Something quite interesting here is that you can use ES6 and things like import instead of require as you would in a NodeJS file using CommonJS

Let's build the relevant endpoints for a login:

  • POST /users According to REST principles this is the preferred way to created a user resource. Which can be translated to: creating a user. Which is what happens when someone signs up.
  • POST /auth This is a personal preference of mine for naming the endpoint that the frontend hits when the users logins.
  • GET /me This is also a personal preference for the endpoint that will get hit and retrieve the user data if it is logged in correctly.

Let's get to it

POST /users

The first part of the file is devoted to importing relevant libraries and creating a connection to the DB.

/pages/api/users.js

const MongoClient = require('mongodb').MongoClient;
const assert = require('assert');
const bcrypt = require('bcrypt');
const v4 = require('uuid').v4;
const jwt = require('jsonwebtoken');
const jwtSecret = 'SUPERSECRETE20220';

const saltRounds = 10;
const url = 'mongodb://localhost:27017';
const dbName = 'simple-login-db';

const client = new MongoClient(url, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
});

MongoClient is obviously used for connecting to mongodb and storing the data that the api will be consuming. I like using the assert module as a simple validator for the request body and the required data on the endpoints. bcrypt is useful for hashing and verifying a password without actually storing it as plain text. (Please never do that)

The v4 function is a nice way to create random ids for the users and finally jwt is what allows to create a nice session that is secure from the frontend and verified in the backend as well.

I would strongly recommend storing the jwtSecret from a .env because it is a really bad idea storing it as part of the code commited to github or gitlab since it would be exposed publicly.

Finally you need to setup dbName and a mongo Client for connecting to the db and writing and reading from there.

Manipulating the DB (to get users and create new ones)

function findUser(db, email, callback) {
  const collection = db.collection('user');
  collection.findOne({email}, callback);
}

function createUser(db, email, password, callback) {
  const collection = db.collection('user');
  bcrypt.hash(password, saltRounds, function(err, hash) {
    // Store hash in your password DB.
    collection.insertOne(
      {
        userId: v4(),
        email,
        password: hash,
      },
      function(err, userCreated) {
        assert.equal(err, null);
        callback(userCreated);
      },
    );
  });
}

Here's a simple function to findUser by email which basically wraps the collection.findOne() function and just queries by email and passes the callback.

The createUser function is a bit more interesting because first the password needs to be hashed and the insertOne() happens with the hashed password instead of the plain text version.

The rest of the code which actually will handle the api request, the NextJS as follows:

export default (req, res) => {
  if (req.method === 'POST') {
    // signup
    try {
      assert.notEqual(null, req.body.email, 'Email required');
      assert.notEqual(null, req.body.password, 'Password required');
    } catch (bodyError) {
      res.status(403).json({error: true, message: bodyError.message});
    }

    // verify email does not exist already
    client.connect(function(err) {
      assert.equal(null, err);
      console.log('Connected to MongoDB server =>');
      const db = client.db(dbName);
      const email = req.body.email;
      const password = req.body.password;

      findUser(db, email, function(err, user) {
        if (err) {
          res.status(500).json({error: true, message: 'Error finding User'});
          return;
        }
        if (!user) {
          // proceed to Create
          createUser(db, email, password, function(creationResult) {
            if (creationResult.ops.length === 1) {
              const user = creationResult.ops[0];
              const token = jwt.sign(
                {userId: user.userId, email: user.email},
                jwtSecret,
                {
                  expiresIn: 3000, //50 minutes
                },
              );
              res.status(200).json({token});
              return;
            }
          });
        } else {
          // User exists
          res.status(403).json({error: true, message: 'Email exists'});
          return;
        }
      });
    });
  }
};

export default (req, res) => {} Here's where the magic happens and you get the req, res in a very similar way as you get in an Express app. One of the only things that are required as setup here if you intend to only process the POST requests that happen to the endpoint happens here:

if (req.method === 'POST') { }

other HTTP methods could be processed with additional conditions.

The code basically verifies that the body of the request has an email and password otherwise there's not enough info of the user to try to create.

    try {
      assert.notEqual(null, req.body.email, 'Email required');
      assert.notEqual(null, req.body.password, 'Password required');
    } catch (bodyError) {
      res.status(403).json({error: true, message: bodyError.message});
    }

After basically we verify if a user exists with that email, if it does we throw an error because then it will not make sense to create a second one! Uniqueness should be enforced at least on a field, email is perfect for this.

      findUser(db, email, function(err, user) {
        if (err) {
          res.status(500).json({error: true, message: 'Error finding User'});
          return;
        }

Finally if no user exists with that email we are safe to go ahead and create it.

   createUser(db, email, password, function(creationResult) {
            if (creationResult.ops.length === 1) {
              const user = creationResult.ops[0];
              const token = jwt.sign(
                {userId: user.userId, email: user.email},
                jwtSecret,
                {
                  expiresIn: 3000, //50 minutes
                },
              );
              res.status(200).json({token});
              return;
            }
          });

Another relevant thing that is happening here is that the jwt sign is happening. The details of jwt can be found here But if all went alright we create a token that contains the userId and email, set it up for some time, 50 minutes in this case and send that as response.

We'll see how to handle that on the frontend.

Let's add the /pages 🎨

Let's build an index.js that displays some content all the time in case visitors don't have a login nor an account. And let's add the logic if the users want to sign up and login for them to see the page a bit different.

Also add the login.js and the signup.js

The /pages/signup

The most relevant part of the signup page has to be the submit function that handles the request to the api whenever the user has clicked the submit button.

  function handleSubmit(e) {
    e.preventDefault();
    fetch('/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        email,
        password,
      }),
    })
      .then((r) => r.json())
      .then((data) => {
        if (data && data.error) {
          setSignupError(data.message);
        }
        if (data && data.token) {
          //set cookie
          cookie.set('token', data.token, {expires: 2});
          Router.push('/');
        }
      });
  }

e.preventDefault() stops the submission from following the standard procedure and basically redirecting the page.

Then the call to the api happens with the fetch('/api/users') call. We send the body as a JSON and here it is important to notice that those values are obtained from hooks set onChange of the inputs.

The most interesting part of this is

        if (data && data.error) {
          setSignupError(data.message);
        }
        if (data && data.token) {
          //set cookie
          cookie.set('token', data.token, {expires: 2});
          Router.push('/');
        }

Using the import cookie from 'js-cookie' library we set the cookie from the token obtained and set it's expiration for days. This is a discrepancy maybe it is better to set it to 1 day and the JWT for a bit less than that.

Having the cookie set, whenever we make additional requests that cookie is sent to the server as well and there we can decrypt and review if the user is authed properly and that auth is valid.

POST /auth

This endpoint is very similar to the signup endpoint the main difference and the most interesting part is the Auth method which basically compares the plain text password entered in the body and returns if it matches with the hash stored in the users collection.


function authUser(db, email, password, hash, callback) {
  const collection = db.collection('user');
  bcrypt.compare(password, hash, callback);
}

Instead of creating the user we just verify if the info entered matches an existing user and return the same jwt token

  if (match) {
              const token = jwt.sign(
                {userId: user.userId, email: user.email},
                jwtSecret,
                {
                  expiresIn: 3000, //50 minutes
                },
              );
              res.status(200).json({token});
              return;
     }

The /pages/login

The login page is basically the same form as the signup.js with different texts. Here I would talk a bit more about the hooks used.

const Login = () => {
  const [loginError, setLoginError] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

//...

return (
<input
        name="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />

)
}

Here you can see the basic usage of a react hook. You can store the variable state that you define at the top of your component and set it with the companion function.

Whenever someone changes the email onChange={(e) => setEmail(e.target.value)} kicks and sets the value and makes it available through all the component.

More info on hooks

POST /me

const jwt = require('jsonwebtoken');
const jwtSecret = 'SUPERSECRETE20220';

export default (req, res) => {
  if (req.method === 'GET') {
    if (!('token' in req.cookies)) {
      res.status(401).json({message: 'Unable to auth'});
      return;
    }
    let decoded;
    const token = req.cookies.token;
    if (token) {
      try {
        decoded = jwt.verify(token, jwtSecret);
      } catch (e) {
        console.error(e);
      }
    }

    if (decoded) {
      res.json(decoded);
      return;
    } else {
      res.status(401).json({message: 'Unable to auth'});
    }
  }
};

This endpoint is pretty straightforward yet it is very powerful. Whenever someone makes a api/me call the server will look for a token key in the req.cookies (that is magically managed by Nextjs middleware) if said token exists and passes the jwt.verify it means the user is validly authed and returns the info decoded (userId and email, remember?) and tells the frontend to keep on, otherwise it returns a 401 Unauthorized.

The /pages/index

Now let's protect a part of the index page to change when you are authed. So it has some difference and you can see the full power of the cookies and the api/me endpoint.

No Login

What happens for checking the auth:

  const {data, revalidate} = useSWR('/api/me', async function(args) {
    const res = await fetch(args);
    return res.json();
  });
  if (!data) return <h1>Loading...</h1>;
  let loggedIn = false;
  if (data.email) {
    loggedIn = true;
  }

We make a call to the api/me endpoint (using the nice lib useSWR, also by zeit team) and if that responds with data.email we make the variable loggedIn equal to true and in the render we can display the email of the user that is logged in and a Log Out button actually! (That simply removes the token from the cookies, is that easy!)

      {loggedIn && (
        <>
          <p>Welcome {data.email}!</p>
          <button
            onClick={() => {
              cookie.remove('token');
              revalidate();
            }}>
            Logout
          </button>
        </>
      )}
      {!loggedIn && (
        <>
          <Link href="/login">Login</Link>
          <p>or</p>
          <Link href="/signup">Sign Up</Link>
        </>
      )}

Logged

Full code for the page component:

import Head from 'next/head';
import fetch from 'isomorphic-unfetch';
import useSWR from 'swr';
import Link from 'next/link';
import cookie from 'js-cookie';

function Home() {
  const {data, revalidate} = useSWR('/api/me', async function(args) {
    const res = await fetch(args);
    return res.json();
  });
  if (!data) return <h1>Loading...</h1>;
  let loggedIn = false;
  if (data.email) {
    loggedIn = true;
  }
  return (
    <div>
      <Head>
        <title>Welcome to landing page</title>
        <meta name="viewport" content="initial-scale=1.0, width=device-width" />
      </Head>
      <h1>Simplest login</h1>

      <h2>Proudly using Next.js, Mongodb and deployed with Now</h2>
      {loggedIn && (
        <>
          <p>Welcome {data.email}!</p>
          <button
            onClick={() => {
              cookie.remove('token');
              revalidate();
            }}>
            Logout
          </button>
        </>
      )}
      {!loggedIn && (
        <>
          <Link href="/login">Login</Link>
          <p>or</p>
          <Link href="/signup">Sign Up</Link>
        </>
      )}
    </div>
  );
}

export default Home;

Remember the whole code is available here:
https://github.com/mgranados/simple-login for your review!

That's it! Thanks getting this far! Hope you got a good hold of what it is like to build an api and pages with Next.JS and I hope you are motivated to build your own stuff.

If you liked or have doubts and I could help you with something JS related please ping me on Twitter! @martingranadosg I would love to know what you can build with this! :) or ping me here in dev.to as well 😁

Top comments (8)

Collapse
 
shanegray394 profile image
Shane Dalton Gray

I'm getting this error. Any idea why?

TypeError: Cannot read properties of undefined (reading 'length')
63 | // proceed to Create
64 | createUser(db, email, password, function (creationResult) {

65 | if (creationResult.ops.length === 1) {
| ^
66 | const user = creationResult.ops[0];
67 | const token = jwt.sign(
68 | { userId: user.userId, email: user.email },

Collapse
 
marcocantarero profile image
elCantarero

I have

error - TypeError: Cannot read properties of undefined (reading 'match')

when press submit button both on signup and login page. Tried to rm package.lock.json and node_modules and reinstall them but it not works...any idea?

Collapse
 
neilmorgan profile image
neil-morgan

Would be great to see the addition of email authentication to this.

Collapse
 
youyiqin profile image
yy1828

Thanks.Love your work.I am a student,your article is so nice.

Collapse
 
beznet profile image
Bennett Dungan

Thanks so much for the tutorial! It's hard to find anything in regards to creating a login system from the ground up but this is a perfectly concise guide.

Collapse
 
devsportties profile image
GI HYUN NAM

Hey! Thanks for the article. I am new to NextJS. Did you migrate backend to Nextjs just for simplifying things? Or is this NextJS thingy merging back and front in one folder?

Collapse
 
ekremgunden profile image
Ekrem Günden • Edited

ı'm geting this error
dev-to-uploads.s3.amazonaws.com/up...

Collapse
 
tess_jenkins_1614e446a375 profile image
Tess Jenkins

I am as well. Did you ever find a fix to this?