Build a Login System with Next.js and FastAPI

Build a full-stack auth system with password hashing and JWT tokens.

Introduction

30 Second Summary

Every time you log into an app, there's a system behind the scenes checking your password and deciding whether to let you in. That system is split into two parts: what you see (the login form) and what happens on the server (verifying your identity and issuing a pass).

In this project, you will build a full-stack authentication system from scratch using Next.js for the frontend and FastAPI for the backend. You'll register users, hash their passwords, issue login tokens, and connect both apps together.

What You'll Build

A two-application login system where a Next.js 16 frontend communicates with a Python FastAPI backend to register users and authenticate them securely.

By the end of this project, you'll have:

  • A FastAPI backend with REST API endpoints for user registration and login, storing hashed passwords in a SQLite database.
  • A Next.js 16 frontend with a login and registration form that sends HTTP requests to your API and displays responses.
  • A working understanding of how password hashing, JWT tokens, and CORS connect a frontend to a secure backend.
  • Secret Mission: Add a protected dashboard page that only authenticated users can access by sending their JWT token with each request.

Are there any prerequisites?

This project assumes basic JavaScript/React knowledge (you know what components and state are) and basic Python knowledge (you can read functions and variables). You do not need prior experience with Next.js or FastAPI.

Set Up Both Projects

Every authentication system has two halves: the frontend the user interacts with, and the backend that handles the security logic. You are going to build both from scratch as separate applications that talk to each other over HTTP.

In this step, you will create both project folders, install all dependencies, and confirm that both development servers start cleanly.

In this step, get ready to:
  • Scaffold a Next.js frontend application.
  • Create a Python virtual environment and install FastAPI.
  • Start both development servers and confirm they respond.
Create the project structure

You need a single root folder that holds both your frontend and backend projects side-by-side. This keeps everything organized and makes it easy to run both servers from the same workspace.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, above Tab).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your root project folder and move into it by running these commands:
mkdir auth-project
cd auth-project
  • Scaffold your Next.js frontend by running this command:
npx create-next-app@latest frontend --yes

What might I see in my terminal?

If this is your first time running npx with create-next-app, you may see a prompt asking to install the package. Type y and press Enter to continue.

The --yes flag skips all interactive prompts and uses the recommended defaults: TypeScript, Tailwind CSS, ESLint, and the App Router. This takes a minute while it downloads dependencies.

  • Confirm the frontend was created by running:
ls

You should see a frontend folder listed in the output.

Not seeing the frontend folder?

  • Check your internet connection. The scaffolding tool downloads packages from the npm registry.
  • Make sure you are inside the auth-project folder. Run pwd to print your current directory.
  • help me troubleshoot why create-next-app did not create my frontend folder
  • Create the backend folder and set up a Python virtual environment by running these commands:
mkdir backend
cd backend
python -m venv venv

What is a virtual environment?

A virtual environment is an isolated Python workspace. Packages you install inside it stay separate from your system Python, so different projects can use different package versions without conflicting.

  • Activate the virtual environment by running the command for your operating system:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Not seeing (venv) in your prompt?

  • On macOS, if python was not found, try using python3 instead for the python -m venv venv command.
  • On Windows, if you see an error about execution policies, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell first, then try the activate command again.
  • help me activate my Python virtual environment
  • Install FastAPI, the password hashing library, and PyJWT by running:
pip install "fastapi[standard]" "pwdlib[argon2]" PyJWT

What are these packages?

  • fastapi[standard] installs FastAPI plus Uvicorn (the server that runs it) and other utilities.
  • pwdlib[argon2] provides secure password hashing using the Argon2 algorithm.
  • PyJWT lets you create and verify JWT tokens for authentication.
  • Wait for the installation to finish. You should see a message ending with "Successfully installed" followed by a list of packages.

Seeing an installation error?

  • Make sure (venv) is visible in your terminal prompt. If it is not, the virtual environment is not active.
  • Check your internet connection. pip downloads packages from the Python Package Index (PyPI).
  • help me fix my pip install error for FastAPI and related packages
Verify both environments work

With both projects set up, you need to confirm that each server starts without errors. You will run the Next.js dev server in one terminal and the FastAPI dev server in another.

  • Open the auth-project folder in VS Code so you can see both projects in the file explorer.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to Desktop, select the auth-project folder, and click Open.

You should see both backend and frontend folders in the VS Code Explorer sidebar on the left.

  • Create a file called main.py inside the backend folder. Right-click the backend folder in the Explorer sidebar and select New File.
  • Name the file main.py and press Enter.
  • Paste the following code into main.py:
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "Hello World"}

What does this code do?

  • from fastapi import FastAPI imports the framework.
  • app = FastAPI() creates your application instance.
  • @app.get("/") defines a route that responds to GET requests at the root URL.
  • The function returns a JSON response. FastAPI handles the conversion automatically.
  • Save main.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.py before continuing.

ⓧ I'd like to double check the full code

Here is the complete main.py file:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "Hello World"}

Now you will start both servers. Each one needs its own terminal because it runs continuously.

  • Open a new terminal in VS Code by pressing Ctrl+Shift+` (backtick). This will be Terminal 1 for your frontend.
  • Start the Next.js development server by running these commands:
cd frontend
npm run dev

You should see output indicating the server is ready, with a URL showing http://localhost:3000.

  • Open another new terminal by pressing Ctrl+Shift+` again. This will be Terminal 2 for your backend.
  • Start the FastAPI development server by running these commands:

macOS

cd backend
source venv/bin/activate
fastapi dev main.py

Windows

cd backend
venv\Scripts\activate
fastapi dev main.py

Why activate the venv again?

Each terminal session is independent. The virtual environment you activated earlier was in a different terminal tab. You need to activate it in every new terminal where you want to use your installed Python packages.

You should see output indicating Uvicorn is running at http://localhost:8000.

Seeing an error when starting a server?

  • If npm run dev fails, make sure you are inside the frontend folder. Run pwd to check.
  • If fastapi dev main.py says the command is not found, make sure (venv) appears in your prompt. The fastapi command is only available inside the activated virtual environment.
  • If port 3000 or 8000 is already in use, close any other development servers you may have running.
  • help me troubleshoot my development server startup error
Confirm both servers are running
  • Open your web browser and visit http://localhost:3000.
  • You should see the default Next.js welcome page with the Next.js logo.
  • Now visit http://localhost:8000/docs in another browser tab.
  • You should see FastAPI's auto-generated Swagger UI page. This interactive documentation page lists all your API endpoints.

What is Swagger UI?

FastAPI automatically generates interactive API documentation from your code. You can use it to test your endpoints directly in the browser without writing any frontend code or using external tools.

Not seeing one of the pages?

  • Make sure both terminal tabs are still running their respective servers. A server stops if you press Ctrl+C in its terminal.
  • Double-check the URLs. The frontend is on port 3000 and the backend is on port 8000.
  • help me figure out why I cannot see the Next.js or FastAPI page in my browser

Both servers are running and responding. Next up, you will build the actual authentication endpoints in your FastAPI backend.

Build the FastAPI Authentication Backend

Your development environment is ready with both servers running side-by-side. Now it's time to build the actual authentication logic that handles user registration and login.

In this step, you'll write the FastAPI backend that stores users in a SQLite database, hashes their passwords with Argon2, and issues JWT tokens when they log in.

In this step, get ready to:
  • Set up a SQLite database with a users table.
  • Build a /register endpoint that hashes passwords securely.
  • Build a /login endpoint that verifies credentials and issues JWT tokens.
Create the database and user model

Before your backend can register or log in users, it needs a database to store their information and a defined shape for incoming requests.

You'll replace the placeholder code in main.py with the real imports, configuration, database setup, and a Pydantic model that validates request bodies.

  • In VS Code, open main.py inside your auth-project/backend folder.
  • Select all the existing code and replace it with the following imports and configuration:
import sqlite3
import jwt
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, HTTPException
from pwdlib import PasswordHash
from pydantic import BaseModel

# Create the FastAPI app and configure auth settings
app = FastAPI()
password_hash = PasswordHash.recommended()
SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"

What does this code do?

  • The imports bring in SQLite for the database, PyJWT for token creation, datetime for expiration times, FastAPI for the web framework, pwdlib for password hashing, and Pydantic for request validation.
  • password_hash = PasswordHash.recommended() creates a hasher that uses the Argon2 algorithm.
  • SECRET_KEY is used to sign JWT tokens. In a production app, this would be a long random string stored as an environment variable.
  • ALGORITHM = "HS256" tells PyJWT which signing algorithm to use.
  • Save main.py.
  • Check the terminal running your FastAPI server (Terminal 2). It should reload without errors.

Seeing an import error?

  • Make sure your virtual environment is activated in Terminal 2. You should see (venv) at the start of the prompt.
  • Confirm you installed all three packages in Step 1: fastapi[standard], pwdlib[argon2], and PyJWT.
  • help me fix this import error in my FastAPI main.py
  • Add the database initialization function below the ALGORITHM line by pasting the following:
def init_db():
    # Connect to (or create) the SQLite database file
    conn = sqlite3.connect("users.db")
    cursor = conn.cursor()
    # Create the users table if it doesn't already exist
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            hashed_password TEXT NOT NULL
        )
    """)
    conn.commit()
    conn.close()

# Run database setup when the server starts
init_db()

What does this code do?

  • init_db() connects to a SQLite database file called users.db. If the file doesn't exist yet, SQLite creates it automatically.
  • The users table has three columns: an auto-incrementing id, a unique username, and a hashed_password.
  • CREATE TABLE IF NOT EXISTS means the function won't error if the table already exists from a previous run.
  • Calling init_db() at module level means the database is created as soon as the server starts.
  • Save main.py.
  • Confirm the database was created by running this in Terminal 2 (stop the server with Ctrl+C first, then check):
ls users.db
  • You should see users.db listed. Restart the server by running:
fastapi dev main.py

Don't see users.db?

  • Make sure you added the init_db() call below the function definition, not inside it. The call must be at module level (no indentation).
  • Check that you saved the file. The server must reload for init_db() to execute.
  • help me figure out why users.db wasn't created
  • Add the request model below the init_db() call by pasting:
# Define the expected shape of registration/login requests
class UserRequest(BaseModel):
    username: str
    password: str

What does this code do?

UserRequest is a Pydantic model that defines the expected shape of the request body. FastAPI uses this model to automatically validate incoming JSON and reject requests that don't include both username and password fields.

  • Save main.py. You won't see a visible change yet. The next chunk uses this model to validate incoming requests.
Build the registration endpoint

The registration endpoint receives a username and password, hashes the password so it's never stored in plain text, and saves the user to the database.

  • Add the registration endpoint below the UserRequest class by pasting:
@app.post("/register")
def register(user: UserRequest):
    # Hash the password so it's never stored in plain text
    hashed = password_hash.hash(user.password)
    try:
        conn = sqlite3.connect("users.db")
        cursor = conn.cursor()
        # Insert the new user into the database
        cursor.execute(
            "INSERT INTO users (username, hashed_password) VALUES (?, ?)",
            (user.username, hashed)
        )
        conn.commit()
        conn.close()
        return {"message": "User registered successfully"}
    except sqlite3.IntegrityError:
        # Username already exists (UNIQUE constraint violated)
        raise HTTPException(status_code=400, detail="Username already exists")

What does this code do?

  • password_hash.hash(user.password) converts the plain-text password into an Argon2 hash. Only the hash is stored in the database, never the original password.
  • The ? placeholders in the SQL query prevent SQL injection. SQLite fills them in safely with the provided values.
  • If the username already exists, SQLite raises an IntegrityError because of the UNIQUE constraint. The except block catches this and returns an HTTP 400 error.
  • Save main.py.
  • Visit http://localhost:8000/docs in your browser. You should see your /register endpoint listed in the Swagger UI.

Don't see /register in Swagger UI?

  • Check the terminal running FastAPI for error messages. A syntax error in the new code will prevent the server from reloading.
  • Make sure the @app.post("/register") decorator is directly above the def register line with no blank lines between them.
  • help me fix my /register endpoint not showing in Swagger UI
Build the login endpoint

The login endpoint checks if the submitted password matches the stored hash, and if it does, creates a JWT token that proves the user is authenticated.

  • Add the login endpoint below the /register endpoint by pasting:
@app.post("/login")
def login(user: UserRequest):
    # Look up the user's stored hash by username
    conn = sqlite3.connect("users.db")
    cursor = conn.cursor()
    cursor.execute("SELECT hashed_password FROM users WHERE username = ?", (user.username,))
    row = cursor.fetchone()
    conn.close()
    # If no user found, return 401
    if not row:
        raise HTTPException(status_code=401, detail="Invalid credentials")
    # Verify the submitted password against the stored hash
    if not password_hash.verify(user.password, row[0]):
        raise HTTPException(status_code=401, detail="Invalid credentials")
    # Create a JWT token with the username and 30-min expiration
    payload = {
        "sub": user.username,
        "exp": datetime.now(timezone.utc) + timedelta(minutes=30)
    }
    token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
    return {"access_token": token}

What does this code do?

  • The query looks up the stored hash for the given username. If no row is found, the user doesn't exist.
  • password_hash.verify() checks if the submitted password matches the stored hash without ever decrypting it. This is how secure password verification works.
  • jwt.encode() creates a signed token containing the username (sub) and a 30-minute expiration (exp). The SECRET_KEY ensures only your server can create valid tokens.
  • Both failure cases return the same generic "Invalid credentials" message. This prevents attackers from discovering which usernames exist in your database.
  • Save main.py.
  • Refresh http://localhost:8000/docs in your browser. You should now see both /register and /login endpoints in Swagger UI.
  • Test the flow: click Try it out on the /register endpoint, enter a username and password, then click Execute.
  • Then do the same on /login with the same credentials. You should see an access_token in the response.

Seeing a 401 or 400 error when testing?

  • If you get a 400 on /register, the username already exists. Try a different username.
  • If you get a 401 on /login, double-check that you're using the exact same username and password you registered with.
  • If the server crashed with a traceback, check the terminal for the error message and fix the syntax issue.
  • help me debug my login endpoint returning 401

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py and both endpoints are working in Swagger UI.

ⓧ I'd like to double check the full code

Here's the complete main.py at this point:

import sqlite3
import jwt
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, HTTPException
from pwdlib import PasswordHash
from pydantic import BaseModel

app = FastAPI()
password_hash = PasswordHash.recommended()
SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"

def init_db():
    conn = sqlite3.connect("users.db")
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            hashed_password TEXT NOT NULL
        )
    """)
    conn.commit()
    conn.close()

init_db()

class UserRequest(BaseModel):
    username: str
    password: str

@app.post("/register")
def register(user: UserRequest):
    hashed = password_hash.hash(user.password)
    try:
        conn = sqlite3.connect("users.db")
        cursor = conn.cursor()
        cursor.execute(
            "INSERT INTO users (username, hashed_password) VALUES (?, ?)",
            (user.username, hashed)
        )
        conn.commit()
        conn.close()
        return {"message": "User registered successfully"}
    except sqlite3.IntegrityError:
        raise HTTPException(status_code=400, detail="Username already exists")

@app.post("/login")
def login(user: UserRequest):
    conn = sqlite3.connect("users.db")
    cursor = conn.cursor()
    cursor.execute("SELECT hashed_password FROM users WHERE username = ?", (user.username,))
    row = cursor.fetchone()
    conn.close()
    if not row:
        raise HTTPException(status_code=401, detail="Invalid credentials")
    if not password_hash.verify(user.password, row[0]):
        raise HTTPException(status_code=401, detail="Invalid credentials")
    payload = {
        "sub": user.username,
        "exp": datetime.now(timezone.utc) + timedelta(minutes=30)
    }
    token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
    return {"access_token": token}

Your backend is fully functional. You can register users, log them in, and issue tokens. Next up, you'll configure CORS so your Next.js frontend can actually talk to this API across ports.

Add CORS and Test the API

Your backend is fully operational. You have registration and login endpoints that hash passwords and issue JWT tokens.

There is one problem left before your Next.js frontend can communicate with this backend. Browsers enforce a security policy called CORS that blocks requests between different origins (different ports count as different origins). In this step, you will configure CORS on your FastAPI server so your frontend at port 3000 can reach your backend at port 8000, then verify the full authentication flow works end-to-end.

In this step, get ready to:
  • Add CORS middleware so your frontend can communicate with your backend.
  • Test the full registration and login flow using Swagger UI.
  • Decode a JWT token to see what information it contains.
Configure CORS middleware

Your Next.js frontend runs on http://localhost:3000 and your FastAPI backend runs on http://localhost:8000. Browsers treat these as different origins because the port number differs. Without explicit permission from the backend, the browser will refuse to send requests across origins.

  • In VS Code, open auth-project/backend/main.py.
  • Find the existing import lines at the top of the file. Add this new import line below from fastapi import FastAPI, HTTPException:
from fastapi.middleware.cors import CORSMiddleware
  • Now find the line that says app = FastAPI(). Add the following middleware configuration directly below it:
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_methods=["*"],
    allow_headers=["*"],
)

What does this code do?

CORSMiddleware intercepts every incoming request and adds the appropriate response headers that tell the browser "this origin is allowed to talk to me."

  • allow_origins=["http://localhost:3000"] specifies exactly which frontend URL can send requests. Only your Next.js dev server is permitted.
  • allow_methods=["*"] permits all HTTP methods (GET, POST, PUT, DELETE, etc.).
  • allow_headers=["*"] permits any request header (like Content-Type or Authorization).

Why a specific origin instead of allowing everything?

You could set allow_origins=["*"] to allow any website to call your API. In production, that would let malicious sites make requests on behalf of your users. Listing only your frontend's URL keeps the API locked down to trusted callers.

  • Save main.py.
  • Check the terminal running your FastAPI server. Because you started it with fastapi dev main.py, it auto-reloads on save. You should see a log line indicating the server restarted.

Server not reloading?

  • Make sure you saved the file. Unsaved changes won't trigger a reload.
  • Check for a syntax error in the terminal output. A missing comma or bracket in the app.add_middleware() call will crash the server.
  • Confirm the import line is from fastapi.middleware.cors import CORSMiddleware with that exact casing.

Help me fix my CORS middleware configuration in FastAPI

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and that your server reloaded successfully.

ⓧ I'd like to double check the full code

Here is the complete main.py file with CORS middleware added:

import sqlite3
import jwt
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pwdlib import PasswordHash
from pydantic import BaseModel

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_methods=["*"],
    allow_headers=["*"],
)

password_hash = PasswordHash.recommended()
SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"

def init_db():
    conn = sqlite3.connect("users.db")
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            hashed_password TEXT NOT NULL
        )
    """)
    conn.commit()
    conn.close()

init_db()

class UserRequest(BaseModel):
    username: str
    password: str

@app.post("/register")
def register(user: UserRequest):
    hashed = password_hash.hash(user.password)
    try:
        conn = sqlite3.connect("users.db")
        cursor = conn.cursor()
        cursor.execute(
            "INSERT INTO users (username, hashed_password) VALUES (?, ?)",
            (user.username, hashed)
        )
        conn.commit()
        conn.close()
        return {"message": "User registered successfully"}
    except sqlite3.IntegrityError:
        raise HTTPException(status_code=400, detail="Username already exists")

@app.post("/login")
def login(user: UserRequest):
    conn = sqlite3.connect("users.db")
    cursor = conn.cursor()
    cursor.execute("SELECT hashed_password FROM users WHERE username = ?", (user.username,))
    row = cursor.fetchone()
    conn.close()
    if not row:
        raise HTTPException(status_code=401, detail="Invalid credentials")
    if not password_hash.verify(user.password, row[0]):
        raise HTTPException(status_code=401, detail="Invalid credentials")
    payload = {
        "sub": user.username,
        "exp": datetime.now(timezone.utc) + timedelta(minutes=30)
    }
    token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
    return {"access_token": token}
Test the full flow using Swagger UI

Your CORS configuration is active, but you have not yet tested the full registration-to-login flow in one go. FastAPI's built-in Swagger UI lets you test your endpoints without building a frontend first.

  • Open your browser and go to http://localhost:8000/docs.
  • Click on the POST /register endpoint to expand it.
  • Click Try it out.
  • In the request body, replace the placeholder JSON with a test user:
{
  "username": "testuser",
  "password": "mypassword123"
}
  • Click Execute.

You should see a 200 response with the message User registered successfully.

  • Now expand the POST /login endpoint.
  • Click Try it out and enter the same credentials you just registered with.
  • Click Execute.

You should see a 200 response containing an access_token field with a long string of characters. This is your JWT token.

  • Copy the entire token string (without the surrounding quotes).
  • Open a new browser tab and go to https://jwt.io.
  • Paste your token into the Encoded Token field on the left side of the page.

The Decoded Payload section on the right should show your username in the sub field and an expiration timestamp in the exp field.

Why can anyone decode my token?

JWT tokens are not encrypted. Anyone can read their contents. The signature at the end proves that YOUR server created the token and that nobody tampered with the data inside it.

This is why you never put sensitive information (like passwords) inside a JWT. The token only carries identity claims (sub) and metadata (exp) that are safe to be public.

Login returning a 401 error?

  • Make sure you registered the user first. The login endpoint checks against stored users.
  • Check that the username and password match exactly what you registered with. Both are case-sensitive.
  • If you restarted the server and deleted the users.db file, you need to register again.

Help me debug why my login endpoint is returning 401

Your backend is complete and battle-tested. You have working endpoints, hashed passwords, JWT tokens, and CORS configured. Next up, you will build the Next.js frontend that talks to this API.

Build the Next.js Login Page

Your FastAPI backend can register users and issue JWT tokens. Now you need a way for users to actually interact with it through a browser.

In this step, you'll create a login page in your Next.js app that communicates with your API using fetch() requests. Along the way, you'll learn how Next.js handles routing, components, and interactivity differently from plain React.

How does Next.js differ from plain React?

In plain React, you would use React Router to define routes, and every component runs in the browser. Next.js works differently in three key ways:

  • File-based routing: Instead of writing route configuration code, you create a file at app/login/page.tsx and the /login route exists automatically. The folder name becomes the URL path.
  • Server Components by default: Every component renders on the server unless you opt out. Server Components cannot use useState, useEffect, or event handlers.
  • The "use client" directive: Adding "use client" at the top of a file makes it a Client Component that runs in the browser. Your login form needs state and click handlers, so it must be a Client Component.
In this step, get ready to:
  • Create a Client Component at the /login route using file-based routing.
  • Write fetch functions that send requests to your FastAPI backend.
  • Style the form with Tailwind CSS to look like a professional login page.
Create the login/register page

In Next.js, creating a new route is as simple as creating a folder with a page.tsx file inside the app directory. The folder name becomes the URL path automatically.

  • In VS Code, expand the frontend/app folder in the **Explorer** sidebar.
  • Create a new folder called login inside the app folder.
  • Inside the login folder, create a new file called page.tsx.

Why this file path?

The path app/login/page.tsx tells Next.js to serve this component at the /login URL. No router configuration needed. This is file-based routing in action.

Your login page needs form state (username, password) and click handlers (register, login). These are browser-only features, so the file must start with the "use client" directive to make it a Client Component.

Here is what your component shell should include:

  • The "use client" directive as the very first line of the file.
  • Import useState from React.
  • Export a default function component called LoginPage.
  • Four state variables: username (string), password (string), message (string), and isSuccess (boolean) to track whether the message is a success or error.

Try writing this yourself, then check your work below.

✔️ I'll try it first

Write the component shell with the four useState hooks and a placeholder return statement. You will fill in the return JSX in the styling substep.

ⓧ Let me check my work

Your file should start like this:

"use client";

import { useState } from "react";

export default function LoginPage() {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [message, setMessage] = useState("");
  const [isSuccess, setIsSuccess] = useState(false);
Connect the form to your API

Your form needs two async functions: one for registration and one for login. Each function sends a POST request to your FastAPI backend with the username and password as JSON.

Here is what each function should do:

  • handleRegister: Send a POST fetch() to http://localhost:8000/register with a JSON body containing username and password. Set the Content-Type header to application/json. If the response is OK, display the success message. Otherwise, display the error detail.
  • handleLogin: Same structure, but POST to http://localhost:8000/login. On success, store the returned token in localStorage under the key token and display it. On failure, display the error.

Why localStorage?

localStorage is a browser API that persists data across page refreshes. Storing the JWT token here lets you send it with future requests to prove the user is logged in. This only works in Client Components because localStorage does not exist on the server.

Try writing both handler functions, then check your work.

✔️ I'll give it a go!

Write both async functions inside your LoginPage component, after the state declarations and before the return statement.

ⓧ Maybe a little help...

Here is the handleRegister function:

  const handleRegister = async () => {
    const res = await fetch("http://localhost:8000/register", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ username, password }),
    });
    const data = await res.json();
    if (res.ok) {
      setIsSuccess(true);
      setMessage(data.message);
    } else {
      setIsSuccess(false);
      setMessage(data.detail || "Registration failed");
    }
  };

And here is the handleLogin function:

  const handleLogin = async () => {
    const res = await fetch("http://localhost:8000/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ username, password }),
    });
    const data = await res.json();
    if (res.ok) {
      localStorage.setItem("token", data.access_token);
      setIsSuccess(true);
      setMessage("Login successful! Token: " + data.access_token);
    } else {
      setIsSuccess(false);
      setMessage(data.detail || "Login failed");
    }
  };

What does this code do?

  • fetch() sends an HTTP request to your FastAPI backend. The method: "POST" and Content-Type header tell the server to expect JSON data in the request body.
  • res.ok is true when the response status is in the 200 range. Your FastAPI endpoints return 400 or 401 on errors, so res.ok will be false for those.
  • localStorage.setItem("token", data.access_token) saves the JWT token in the browser so you can use it in future requests.
Style the page to look like a real login form

Your component needs a return statement that renders the form. Tailwind CSS is already included by Next.js, so you can add utility classes directly to your JSX elements.

Here is what your UI should include:

  • A centered card layout on a light gray background.
  • A heading that says "Welcome".
  • Two labeled inputs (username and password) bound to your state variables.
  • Two buttons: a blue **Register** button and a green **Login** button, each calling the appropriate handler.
  • A message area that shows green text for success and red text for errors.

Give it a try yourself, or check the full return statement below.

✔️ I'll try it first

Write the return statement with Tailwind classes for centering, spacing, colors, and conditional message styling. Use className to apply Tailwind utility classes.

ⓧ Let me check my work

Here is the complete return statement to add after your handler functions:

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-100">
      <div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
        <h1 className="text-2xl font-bold mb-6 text-center">Welcome</h1>
        <div className="mb-4">
          <label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
          <input
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            className="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
            placeholder="Enter username"
          />
        </div>
        <div className="mb-6">
          <label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
            placeholder="Enter password"
          />
        </div>
        <div className="flex gap-3 mb-4">
          <button
            onClick={handleRegister}
            className="flex-1 bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition"
          >
            Register
          </button>
          <button
            onClick={handleLogin}
            className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700 transition"
          >
            Login
          </button>
        </div>
        {message && (
          <p className={`text-sm mt-2 break-all ${isSuccess ? "text-green-600" : "text-red-600"}`}>
            {message}
          </p>
        )}
      </div>
    </div>
  );

What does this code do?

  • min-h-screen flex items-center justify-center makes the outer div fill the viewport and centers the card both vertically and horizontally.
  • Each input uses value={username} and onChange to stay in sync with React state. This is a controlled component pattern.
  • The conditional {message && (...)} only renders the message paragraph when there is something to show. The class toggles between text-green-600 and text-red-600 based on isSuccess.
  • Save app/login/page.tsx.
  • Open your browser and visit http://localhost:3000/login.
  • You should see a centered white card with a "Welcome" heading, two inputs, and blue/green buttons.
  • Register a new user by typing a username and password, then clicking **Register**. A green success message should appear.
  • Click **Login** with the same credentials. You should see a green message displaying the JWT token.
  • Try logging in with wrong credentials. You should see a red error message.

Page not loading at /login?

  • Make sure the file is at frontend/app/login/page.tsx (not frontend/login/page.tsx). The file must be inside the app directory for Next.js routing to find it.
  • Make sure your Next.js dev server is still running in its terminal. If it stopped, run npm run dev again from the frontend folder.
  • If you see a "fetch failed" error when clicking the buttons, confirm your FastAPI server is still running on port 8000 in its own terminal.

help me debug my Next.js login page

✔️ Awesome, I've got everything!

Great. Double check you have saved app/login/page.tsx and both servers are running.

ⓧ I'd like to double check the full code

Here is the complete app/login/page.tsx file:

"use client";

import { useState } from "react";

export default function LoginPage() {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [message, setMessage] = useState("");
  const [isSuccess, setIsSuccess] = useState(false);

  const handleRegister = async () => {
    const res = await fetch("http://localhost:8000/register", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ username, password }),
    });
    const data = await res.json();
    if (res.ok) {
      setIsSuccess(true);
      setMessage(data.message);
    } else {
      setIsSuccess(false);
      setMessage(data.detail || "Registration failed");
    }
  };

  const handleLogin = async () => {
    const res = await fetch("http://localhost:8000/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ username, password }),
    });
    const data = await res.json();
    if (res.ok) {
      localStorage.setItem("token", data.access_token);
      setIsSuccess(true);
      setMessage("Login successful! Token: " + data.access_token);
    } else {
      setIsSuccess(false);
      setMessage(data.detail || "Login failed");
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-100">
      <div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
        <h1 className="text-2xl font-bold mb-6 text-center">Welcome</h1>
        <div className="mb-4">
          <label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
          <input
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            className="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
            placeholder="Enter username"
          />
        </div>
        <div className="mb-6">
          <label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
            placeholder="Enter password"
          />
        </div>
        <div className="flex gap-3 mb-4">
          <button
            onClick={handleRegister}
            className="flex-1 bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition"
          >
            Register
          </button>
          <button
            onClick={handleLogin}
            className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700 transition"
          >
            Login
          </button>
        </div>
        {message && (
          <p className={`text-sm mt-2 break-all ${isSuccess ? "text-green-600" : "text-red-600"}`}>
            {message}
          </p>
        )}
      </div>
    </div>
  );
}

Your frontend and backend are now fully connected. Next up, you'll take on the Secret Mission and protect a route using JWT tokens.

Secret mission

Add a Protected Dashboard

Your login system works, but anyone can visit any page right now. In real applications, certain pages should only be visible to authenticated users. Can you build a protected dashboard that kicks out unauthenticated visitors?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The auth-project folder on your Desktop (contains both the frontend and backend apps).
  • Python virtual environment (venv folder inside auth-project/backend).
  • SQLite database file (users.db inside auth-project/backend).
  • Next.js dev server running on port 3000.
  • FastAPI dev server running on port 8000.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your authentication system.

  • Both dev servers remain running in your terminal windows.
  • Your registered users stay in the users.db database file.
  • You can keep testing at http://localhost:3000/login and http://localhost:8000/docs.

Pause - I'll come back to this later

Shut down the running servers to free up your terminal and ports. Your files and database stay on disk for when you return.

  • In the terminal running the Next.js dev server, press Ctrl+C to stop it.
  • In the terminal running the FastAPI dev server, press Ctrl+C to stop it.
  • When you come back, re-activate the virtual environment and start both servers again.

Delete - I don't want to use this again

Remove all project files, the database, and the virtual environment. This frees up disk space and removes everything you created.

  • In the terminal running the Next.js dev server, press Ctrl+C to stop it.
  • In the terminal running the FastAPI dev server, press Ctrl+C to stop it.
  • If your virtual environment is still active (you see (venv) in your prompt), deactivate it by running:
deactivate
  • Navigate to your Desktop and delete the entire project folder.

macOS

cd ~/Desktop
rm -rf auth-project

Windows

cd ~\Desktop
rmdir /s /q auth-project
  • Confirm the folder is gone by running:
ls

You should no longer see auth-project in the output.

Nice Work!

Nice Work!

You just built a full-stack authentication system from scratch. You now have a working login system with two applications communicating securely over HTTP.

You've learned how to:

  • Built a FastAPI backend with REST API endpoints for user registration and login, secured with Argon2 password hashing.
  • Generated and validated JWT tokens for stateless authentication, understanding how tokens carry user identity without server-side sessions.
  • Built a Next.js 16 frontend using the App Router and Client Components, connecting it to your backend with fetch() and proper CORS configuration.
  • Secret Mission: Protected a route using JWT token verification on both frontend and backend, with programmatic navigation via Next.js's useRouter.

Ready to quiz yourself?