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:
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 auser
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.
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.
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>
</>
)}
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)
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) {
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?
Would be great to see the addition of email authentication to this.
Thanks.Love your work.I am a student,your article is so nice.
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.
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?
ı'm geting this error
dev-to-uploads.s3.amazonaws.com/up...
I am as well. Did you ever find a fix to this?