Build a Simple Next.js Interactive Authentication App

In the digital age, securing user data is paramount. From e-commerce platforms to social media applications, the ability to authenticate users securely is a fundamental requirement for almost every web application. Without proper authentication, your application is vulnerable to security breaches, unauthorized access, and data manipulation. This article will guide you through building a simple, yet robust, interactive authentication application using Next.js. We’ll cover user registration, login, and logout functionalities, all while emphasizing security best practices and providing clear, step-by-step instructions.

Why Build an Authentication App?

Authentication is the process of verifying a user’s identity. It’s the gatekeeper that allows only authorized users to access specific parts of your application. Here’s why building an authentication app is crucial:

  • Security: Protects sensitive user data and prevents unauthorized access.
  • Personalization: Allows you to tailor the user experience based on individual profiles.
  • Data Integrity: Ensures that only authenticated users can modify or contribute to data.
  • Compliance: Helps you meet legal and regulatory requirements for data privacy.

This project will provide you with a solid understanding of how authentication works, the different components involved, and how to implement it effectively using Next.js, a powerful React framework for building web applications.

Prerequisites

Before we dive in, ensure you have the following:

  • Node.js and npm (or yarn): You’ll need these to manage project dependencies. Download and install them from the official Node.js website.
  • A Code Editor: Visual Studio Code, Sublime Text, or any editor of your choice.
  • Basic Understanding of JavaScript and React: Familiarity with these technologies will be helpful.
  • A Next.js Project: If you don’t have one, create a new Next.js project using the following command in your terminal: npx create-next-app@latest authentication-app. Navigate into your project directory using cd authentication-app.

Project Setup and Dependencies

First, let’s install the necessary dependencies for our authentication app. We’ll be using a library called bcryptjs for password hashing and jsonwebtoken for generating and verifying JSON Web Tokens (JWTs). JWTs are a standard way to represent claims securely between two parties. They are commonly used for authentication and authorization.

Open your terminal and run the following command:

npm install bcryptjs jsonwebtoken

This command installs the required packages into your project. Now, let’s create the necessary files and directories to organize our project structure.

Project Structure

We’ll set up the following directory structure:

authentication-app/
├── pages/
│   ├── api/
│   │   ├── auth/
│   │   │   ├── login.js
│   │   │   ├── register.js
│   │   │   └── logout.js
│   ├── index.js
│   └── dashboard.js
├── components/
│   ├── LoginForm.js
│   ├── RegisterForm.js
│   └── ProtectedRoute.js
├── utils/
│   └── auth.js
└── styles/
    └── globals.css

Let’s create these files and directories in your project. This structure will help us organize the different parts of our authentication system.

Implementing the Authentication API Routes

Next.js provides a built-in API route feature. API routes are serverless functions that allow you to create API endpoints within your Next.js application. We’ll use these routes to handle user registration, login, and logout.

1. Register Route (pages/api/auth/register.js)

This route handles user registration. Here’s the code:

import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';

const users = []; // In a real app, this would be a database
const secretKey = process.env.JWT_SECRET || 'your-secret-key';

export default async function handler(req, res) {
  if (req.method === 'POST') {
    const { username, password } = req.body;

    if (!username || !password) {
      return res.status(400).json({ error: 'Username and password are required' });
    }

    const existingUser = users.find(user => user.username === username);
    if (existingUser) {
      return res.status(409).json({ error: 'Username already exists' });
    }

    const hashedPassword = await bcrypt.hash(password, 10);
    const newUser = { username, password: hashedPassword };
    users.push(newUser);

    const token = jwt.sign({ username }, secretKey, { expiresIn: '1h' });

    res.status(201).json({ message: 'User registered successfully', token });
  } else {
    res.status(405).json({ error: 'Method Not Allowed' });
  }
}

Explanation:

  • We import bcrypt for password hashing and jsonwebtoken for creating tokens.
  • We define a placeholder users array (replace this with a database in a real application).
  • We check for required fields (username and password).
  • We hash the password using bcrypt.hash().
  • We store the new user (username and hashed password).
  • We create a JWT using jwt.sign().
  • We return a success message and the JWT.

2. Login Route (pages/api/auth/login.js)

This route handles user login.

import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';

const users = []; // In a real app, this would be a database
const secretKey = process.env.JWT_SECRET || 'your-secret-key';

export default async function handler(req, res) {
  if (req.method === 'POST') {
    const { username, password } = req.body;

    if (!username || !password) {
      return res.status(400).json({ error: 'Username and password are required' });
    }

    const user = users.find(user => user.username === username);
    if (!user) {
      return res.status(401).json({ error: 'Invalid username or password' });
    }

    const passwordMatch = await bcrypt.compare(password, user.password);
    if (!passwordMatch) {
      return res.status(401).json({ error: 'Invalid username or password' });
    }

    const token = jwt.sign({ username }, secretKey, { expiresIn: '1h' });

    res.status(200).json({ message: 'Login successful', token });
  } else {
    res.status(405).json({ error: 'Method Not Allowed' });
  }
}

Explanation:

  • We retrieve the username and password from the request body.
  • We search for the user in our (placeholder) users array.
  • We compare the provided password with the stored hashed password using bcrypt.compare().
  • If the passwords match, we generate a JWT.
  • We return a success message and the JWT.

3. Logout Route (pages/api/auth/logout.js)

This route is simple. It doesn’t actually ‘log out’ in the traditional sense, as JWTs are stateless. Instead, we’ll remove the JWT from the client-side (e.g., in local storage or a cookie). This route exists for completeness and can be used to perform any server-side cleanup if needed. For example, invalidating a refresh token (if you implement that).

export default function handler(req, res) {
  if (req.method === 'POST') {
    // In a real application, you might invalidate a refresh token here.
    res.status(200).json({ message: 'Logout successful' });
  } else {
    res.status(405).json({ error: 'Method Not Allowed' });
  }
}

Explanation:

  • This is a placeholder for any server-side logout logic.
  • In a real application, you might invalidate refresh tokens here.
  • The primary logout action is handled client-side (removing the JWT).

Building the Frontend Components

Now, let’s create the React components for our authentication app. These components will handle user input, API calls, and displaying the appropriate UI based on the authentication state.

1. LoginForm Component (components/LoginForm.js)

This component will render the login form.

import { useState } from 'react';

function LoginForm({ onLogin }) {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');

    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ username, password }),
      });

      const data = await response.json();

      if (response.ok) {
        localStorage.setItem('token', data.token);
        onLogin(); // Call the onLogin prop to update the app's state
      } else {
        setError(data.error);
      }
    } catch (err) {
      setError('An error occurred. Please try again.');
    }
  };

  return (
    <form onSubmit={handleSubmit} className="login-form">
      <h2>Login</h2>
      {error && <p className="error-message">{error}</p>}
      <div className="form-group">
        <label htmlFor="username">Username:</label>
        <input
          type="text"
          id="username"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
        />
      </div>
      <div className="form-group">
        <label htmlFor="password">Password:</label>
        <input
          type="password"
          id="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <button type="submit">Login</button>
    </form>
  );
}

export default LoginForm;

Explanation:

  • We use the useState hook to manage the username, password, and error state.
  • The handleSubmit function makes a POST request to the /api/auth/login endpoint.
  • If the login is successful, we store the JWT in local storage and call the onLogin prop (which will be a function passed from the parent component to update the application’s authentication state).
  • If there’s an error, we display an error message.

2. RegisterForm Component (components/RegisterForm.js)

This component will render the registration form.

import { useState } from 'react';

function RegisterForm() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [success, setSuccess] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    setSuccess('');

    try {
      const response = await fetch('/api/auth/register', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ username, password }),
      });

      const data = await response.json();

      if (response.ok) {
        setSuccess('Registration successful! Please login.');
      } else {
        setError(data.error);
      }
    } catch (err) {
      setError('An error occurred. Please try again.');
    }
  };

  return (
    <form onSubmit={handleSubmit} className="register-form">
      <h2>Register</h2>
      {error && <p className="error-message">{error}</p>}
      {success && <p className="success-message">{success}</p>}
      <div className="form-group">
        <label htmlFor="username">Username:</label>
        <input
          type="text"
          id="username"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
        />
      </div>
      <div className="form-group">
        <label htmlFor="password">Password:</label>
        <input
          type="password"
          id="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <button type="submit">Register</button>
    </form>
  );
}

export default RegisterForm;

Explanation:

  • Similar to LoginForm, this component uses useState to manage the input fields and error messages.
  • The handleSubmit function sends a POST request to the /api/auth/register endpoint.
  • On successful registration, it displays a success message.

3. ProtectedRoute Component (components/ProtectedRoute.js)

This component is used to protect routes that require authentication. It checks if the user is authenticated (by checking for a JWT in local storage) and redirects to the login page if not.

import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';

function ProtectedRoute({ children }) {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const router = useRouter();

  useEffect(() => {
    const token = localStorage.getItem('token');
    if (token) {
      setIsAuthenticated(true);
    } else {
      router.push('/login'); // Redirect to login if not authenticated
    }
  }, [router]);

  if (!isAuthenticated) {
    return null; // Or a loading indicator
  }

  return <>{children}</>;
}

export default ProtectedRoute;

Explanation:

  • Uses the useEffect hook to check for a token in local storage on component mount and whenever the router changes.
  • If a token exists, the user is considered authenticated.
  • If no token is found, the user is redirected to the login page.
  • Renders the children components only if the user is authenticated.

Building the Pages

Now, let’s create the pages for our authentication app.

1. Index Page (pages/index.js)

This will be our home page. It will display different content based on whether the user is logged in or not.

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import LoginForm from '../components/LoginForm';
import RegisterForm from '../components/RegisterForm';

export default function Home() {
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const router = useRouter();

  useEffect(() => {
    const token = localStorage.getItem('token');
    if (token) {
      setIsLoggedIn(true);
    }
  }, []);

  const handleLogin = () => {
    setIsLoggedIn(true);
    router.push('/dashboard');
  };

  const handleLogout = () => {
    localStorage.removeItem('token');
    setIsLoggedIn(false);
    router.push('/');
  };

  return (
    <div className="container">
      <header>
        <h1>Welcome to the Authentication App</h1>
        {isLoggedIn ? (
          <button onClick={handleLogout}>Logout</button>
        ) : (
          <>
            <button onClick={() => router.push('/login')}>Login</button>
            <button onClick={() => router.push('/register')}>Register</button>
          </>
        )}
      </header>
      <main>
        {isLoggedIn ? (
          <p>You are logged in.  Go to the <a href="/dashboard">Dashboard</a>.</p>
        ) : (
          <p>Please login or register.</p>
        )}
      </main>
    </div>
  );
}

Explanation:

  • We use useState to manage the isLoggedIn state.
  • useEffect checks if a token exists in local storage on component mount.
  • handleLogin sets the isLoggedIn state to true.
  • handleLogout removes the token from local storage and sets isLoggedIn to false.
  • Conditionally renders different content based on the isLoggedIn state.

2. Login Page (pages/login.js)

This page will display the login form.

import LoginForm from '../components/LoginForm';
import { useRouter } from 'next/router';

function LoginPage() {
  const router = useRouter();

  const handleLogin = () => {
    router.push('/dashboard'); // Redirect to dashboard after login
  };

  return (
    <div className="container">
      <LoginForm onLogin={handleLogin} />
    </div>
  );
}

export default LoginPage;

Explanation:

  • Imports the LoginForm component.
  • Passes the handleLogin function as a prop to the LoginForm component. This function will be called by the LoginForm on successful login.
  • Uses the router to redirect to the dashboard after successful login.

3. Register Page (pages/register.js)

This page will display the registration form.

import RegisterForm from '../components/RegisterForm';

function RegisterPage() {
  return (
    <div className="container">
      <RegisterForm />
    </div>
  );
}

export default RegisterPage;

Explanation:

  • Imports the RegisterForm component.
  • Renders the RegisterForm component.

4. Dashboard Page (pages/dashboard.js)

This page will be a protected route. It will only be accessible to logged-in users.

import ProtectedRoute from '../components/ProtectedRoute';

function Dashboard() {
  return (
    <ProtectedRoute>
      <div className="container">
        <h1>Dashboard</h1>
        <p>Welcome to your dashboard!</p>
      </div>
    </ProtectedRoute>
  );
}

export default Dashboard;

Explanation:

  • Wraps the content with the ProtectedRoute component.
  • The ProtectedRoute component will handle the authentication check and redirect if necessary.
  • Displays a welcome message for authenticated users.

Adding Styles (styles/globals.css)

Let’s add some basic styling to make our app look presentable. Modify your styles/globals.css file with the following:

/* styles/globals.css */
body {
  font-family: sans-serif;
  margin: 0;
  padding: 0;
  background-color: #f4f4f4;
}

.container {
  max-width: 800px;
  margin: 20px auto;
  padding: 20px;
  background-color: #fff;
  border-radius: 8px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}

header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.form-group {
  margin-bottom: 15px;
}

label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
}

input[type="text"],
input[type="password"] {
  width: 100%;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 16px;
}

button {
  padding: 10px 20px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
}

button:hover {
  background-color: #0056b3;
}

.error-message {
  color: red;
  margin-bottom: 10px;
}

.success-message {
  color: green;
  margin-bottom: 10px;
}

This CSS provides basic styling for the layout, forms, and buttons. Feel free to customize it to your liking.

Running the Application

Now, let’s run the application. Open your terminal and navigate to your project directory. Run the following command:

npm run dev

This will start the Next.js development server. Open your browser and go to http://localhost:3000. You should see the home page. You can then navigate to the login and register pages, and after successful login, you’ll be redirected to the dashboard.

Common Mistakes and How to Fix Them

Here are some common mistakes and how to fix them when building authentication apps:

  • Storing Passwords in Plain Text: Never store passwords directly in your database. Always hash them using a strong hashing algorithm like bcrypt. The code provided already uses bcrypt.
  • Not Using HTTPS: Always use HTTPS in production to encrypt the data transmitted between the client and the server. This protects against eavesdropping.
  • Insufficient Input Validation: Always validate user input on both the client and server sides to prevent security vulnerabilities like SQL injection and cross-site scripting (XSS). The code provided includes basic input validation.
  • Poor Error Handling: Provide informative error messages to the user, but avoid revealing sensitive information that could be used for attacks. The code provides basic error handling.
  • Not Protecting Sensitive Routes: Ensure that all sensitive routes are protected by authentication. The code uses the ProtectedRoute component.
  • Insecure Token Storage: Avoid storing tokens in local storage if you’re concerned about XSS attacks. Consider using HTTP-only cookies for storing tokens, especially for session-based authentication.

Key Takeaways

Building an authentication app is a crucial step in securing any web application. This guide provides a solid foundation for implementing user registration, login, and logout functionalities with Next.js. Remember to always prioritize security best practices, such as hashing passwords and using HTTPS. By understanding the concepts and following the step-by-step instructions, you can create a secure and user-friendly authentication system. Remember to replace the placeholder users array with a proper database implementation in a production environment. Consider adding features like password reset functionality, email verification, and enhanced security measures such as multi-factor authentication (MFA) to further enhance the security of your application.

Optional FAQ

Here are some frequently asked questions about authentication apps:

  1. What is the difference between authentication and authorization? Authentication is verifying who a user is (e.g., username and password), while authorization is determining what a user is allowed to access (e.g., roles and permissions).
  2. Why use JWTs? JWTs are stateless and easy to implement. They are also relatively small and can be easily stored on the client-side.
  3. How do I store the JWT on the client? You can store the JWT in local storage or in an HTTP-only cookie. Using HTTP-only cookies is generally more secure, as it prevents client-side JavaScript from accessing the token, mitigating the risk of XSS attacks.
  4. How can I implement password reset functionality? You would typically generate a unique token, send it to the user’s email, and provide a link to a password reset form. The user would then enter a new password, and the token would be validated on the server before updating the user’s password.
  5. What is multi-factor authentication (MFA)? MFA adds an extra layer of security by requiring users to provide two or more verification factors to access an account (e.g., password and a code from an authenticator app).

The journey of building a secure authentication system doesn’t end here. Continuous learning and adaptation to the evolving security landscape are essential. Consider exploring advanced topics like OAuth 2.0 and OpenID Connect for more complex authentication scenarios. Regularly update your dependencies and stay informed about the latest security best practices to ensure your application remains secure. The knowledge gained from this project will empower you to build more secure and robust web applications, protecting both your users and their data. The techniques and principles discussed here are fundamental to building secure web applications, and understanding them is a critical step for any developer seeking to build robust and reliable systems.