Role-Based Notes API with Next.js
Build an auth-protected REST API with role-based access control in Next.js.
Introduction
30 Second Summary
Every app you use with a login decides what you can see and do based on who you are. A notes app might let managers view all notes while regular users only see their own.
In this project, you will build a Notes API with user authentication and role-based access control using Next.js and MongoDB. You'll write the backend logic that registers users, verifies passwords, and restricts data access based on each user's role.
What You'll Build
You'll have a running API where you register two users with different roles, sign in as each one, and watch the access rules enforce themselves: a Member creates and sees only their own notes, while an Admin sees and deletes anyone's.
By the end of this project, you'll have:
- A working authentication system where you register a user, hash their password, and log in to receive a persistent session.
- A notes API that enforces role-based permissions. Create a note as a Member and confirm you can only see your own, then switch to Admin and see all notes appear.
- TypeScript interfaces and Mongoose schemas that give your data strong typing, autocomplete in your editor, and catch mistakes before runtime.
- Secret Mission: A protected admin dashboard page that displays all users and their roles, accessible only to admins.
Are there any prerequisites?
This project assumes you have basic TypeScript knowledge (variables, functions, async/await). You'll also need Node.js installed, Cursor as your code editor, and a MongoDB Atlas account ready to go.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Naming your goal upfront gives you a clear target to measure every step against.
Scaffold the Next.js Project and Install Dependencies
Before you can write any backend logic, you need a running development environment with the right packages installed. Your API will be built with Next.js Route Handlers, which run on Node.js and give you the same backend patterns (request parsing, status codes, middleware) you'd use in any Node.js server.
In this step, you'll scaffold a Next.js project, install the packages for database access, password hashing, and authentication, then configure your environment variables so everything connects on the first run.
In this step, get ready to:
- Scaffold a Next.js 16 project with TypeScript and App Router.
- Install backend packages for MongoDB, password hashing, and auth.
- Configure environment variables and verify the dev server runs.
Create a new Next.js project
You'll use create-next-app to generate a full project scaffold with TypeScript, Tailwind CSS, ESLint, the App Router, and a src/ directory structure. Passing all the flags on the command line means the tool runs without interactive prompts.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop and scaffold the project by running these commands:
cd ~/Desktop
npx create-next-app@latest notes-api --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
What does this command do?
This creates a folder called notes-api on your Desktop with a complete Next.js project inside it.
- --typescript enables TypeScript for type safety across your API.
- --app uses the App Router, which is where Route Handlers live.
- --src-dir places all your code under a src/ folder to keep things organized.
- --import-alias "@/*" lets you import files with clean paths like @/lib/mongodb instead of relative paths.
- Wait for the scaffold to finish. You'll see packages being installed and a success message when it completes.
- Move into the new project folder by running:
cd notes-api
- Confirm the project was created by running:
ls
You should see files like package.json, tsconfig.json, and a src/ folder in the output.
Seeing 'command not found' or a network error?
- If npx is not found, Node.js may not be installed correctly. Run node --version to check.
- If you see a network error, check your internet connection and try again.
- If you see a permission error on macOS, prefix the command with sudo.
Help me troubleshoot my create-next-app scaffold
Now open the project in Cursor so you can work with the files.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the notes-api folder.
- Click Open.
You should see the project's file tree in Cursor's Explorer sidebar, including the src/ folder and package.json.
Install the backend packages
Your API needs three main packages: Mongoose for talking to MongoDB, bcryptjs for hashing passwords, and NextAuth.js v5 for session management.
- Open the integrated terminal in Cursor by pressing Ctrl+` (backtick key, below Escape) on both macOS and Windows.
- Install the main backend packages by running:
npm install mongoose bcryptjs next-auth@beta
- Install the TypeScript type definitions for bcryptjs by running:
npm install -D @types/bcryptjs
What do these packages do?
- mongoose provides schemas and models for interacting with MongoDB.
- bcryptjs hashes passwords so you never store them in plain text.
- next-auth@beta is NextAuth.js v5, built specifically for the App Router. It handles sessions and authentication flows.
- @types/bcryptjs gives TypeScript full autocomplete and type checking for bcryptjs.
You should see output ending with a line like "added X packages" with no errors.
Seeing install errors?
- If you see permission errors, try closing and reopening Cursor, then retry the install.
- If you see peer dependency warnings, those are safe to ignore. Only actual errors (red text with "ERR!") need attention.
Help me fix my npm install errors
Configure environment variables and start the dev server
Your app needs two environment variables to function: a MongoDB connection string and a secret key for signing authentication tokens. You'll store both in a .env.local file that Next.js loads automatically.
- In Cursor's Explorer sidebar, right-click on the empty space below the file list.
- Click New File.
- Name the file .env.local and press Enter.
- Add your MongoDB Atlas connection string by pasting this line into the file:
MONGODB_URI=your-mongodb-atlas-connection-string
- Replace your-mongodb-atlas-connection-string with the actual connection string from your MongoDB Atlas cluster. You can find this by logging into Atlas, clicking Connect on your cluster, and copying the connection string for drivers.
What does a MongoDB connection string look like?
It looks something like mongodb+srv://username:password@cluster0.abc123.mongodb.net/mydb. Make sure you replace the <password> placeholder with your actual database user password.
- Save .env.local.
- Generate the auth secret by running this command in Cursor's terminal:
npx auth secret
What does this command do?
This generates a random secret string and automatically adds it to your .env.local file as AUTH_SECRET. NextAuth.js uses this secret to sign and verify JWT session tokens.
- Open .env.local in Cursor and confirm it now has two lines: MONGODB_URI (your connection string) and AUTH_SECRET (a long random string).
✔️ I see both variables
Your environment is configured. Time to start the dev server and see the app running.
ⓧ AUTH_SECRET is missing
The npx auth secret command may have created a separate .env file instead. Check if a .env file appeared in your project root. If so, cut the AUTH_SECRET=... line from it and paste it into your .env.local file. Then delete the empty .env file.
Before you run the dev server, what do you think you'll see when you open http://localhost:3000 in your browser?
- Start the development server by running:
npm run dev
- Open http://localhost:3000 in your browser.
You should see the default Next.js starter page with the Next.js logo and links to documentation. This confirms your project is scaffolded correctly and the dev server is running.
Dev server not starting?
- If you see a port conflict error, another process is using port 3000. Close other dev servers or run npm run dev -- -p 3001 to use a different port.
- If you see module-not-found errors, run npm install again to make sure all dependencies are present.
- Make sure your terminal is inside the notes-api folder. Run pwd to check.
Help me fix my Next.js dev server
Your development environment is ready. You have a running Next.js app with all the packages you need for database access, authentication, and password hashing. Next up, you'll define TypeScript interfaces and Mongoose models to give your data a clear structure.
Define TypeScript Interfaces and Mongoose Models
Your Next.js project is running and all your packages are installed. Now you need to define the shape of your data before writing any API logic.
Without data models, you have no structure for your users or notes. TypeScript interfaces give you autocomplete and catch errors before runtime. Mongoose schemas enforce that same structure at the database level in MongoDB.
In this step, get ready to:
- Define TypeScript interfaces for users and notes.
- Create Mongoose schemas that map those interfaces to your MongoDB collections.
- Confirm all files compile without errors.
Create your TypeScript interfaces
Interfaces define the exact fields each document will have. When you try to access a field that does not exist, TypeScript flags it immediately in your editor.
- In Cursor's Explorer sidebar on the left, right-click the src folder.
- Click New File.
- Type types/index.ts as the file name and press Enter.
- Paste the following code into the new file:
export interface IUser {
_id?: string;
name: string;
email: string;
password: string;
role: "admin" | "member";
createdAt?: Date;
updatedAt?: Date;
}
export interface INote {
_id?: string;
title: string;
content: string;
author: string;
createdAt?: Date;
updatedAt?: Date;
}
// SafeUser strips the password field for API responses
export type SafeUser = Omit<IUser, "password">;
What does this code do?
IUser defines every field a user document will have. The role: "admin" | "member" is a union type, which means the value can only be one of those two strings. TypeScript rejects anything else at compile time.
INote defines the structure for notes, with an author field that will link back to a user.
SafeUser is a type (not an interface) that uses Omit to create a version of IUser without the password. This shows how types differ from interfaces. Types can transform other types using utility operators like Omit.
- Save src/types/index.ts with Cmd+S (macOS) or Ctrl+S (Windows).
- Check the file in the editor. You should see no red or yellow underlines on any line.
Seeing red underlines?
Make sure the file is saved at exactly src/types/index.ts. If you accidentally created it outside src/, the TypeScript compiler will not pick it up.
If you see an error on the Omit line, confirm your project uses TypeScript by checking that tsconfig.json exists in your project root.
help me fix TypeScript errors in my interfaces file
✔️ Awesome, I've got everything!
Great. Double check you have saved src/types/index.ts before moving on.
ⓧ I'd like to double check the full code
export interface IUser {
_id?: string;
name: string;
email: string;
password: string;
role: "admin" | "member";
createdAt?: Date;
updatedAt?: Date;
}
export interface INote {
_id?: string;
title: string;
content: string;
author: string;
createdAt?: Date;
updatedAt?: Date;
}
export type SafeUser = Omit<IUser, "password">;
Create the Mongoose models
Interfaces live only in TypeScript. To actually store data in MongoDB, you need Mongoose schemas that mirror those interfaces and tell the database what to expect.
- Right-click the src folder again in the Explorer sidebar.
- Click New File and type models/User.ts, then press Enter.
- Paste the following code into src/models/User.ts:
import mongoose, { Schema, models } from "mongoose";
const UserSchema = new Schema(
{
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
role: { type: String, enum: ["admin", "member"], default: "member" },
},
{ timestamps: true }
);
// Reuse existing model in dev (hot reload) or create a new one
const User = models.User || mongoose.model("User", UserSchema);
export default User;
What does this code do?
The schema maps directly to your IUser interface. Each field has a type and validation rules. The role field uses enum to restrict values to "admin" or "member", and defaults to "member" for new users.
The models.User || mongoose.model("User", UserSchema) pattern prevents Mongoose from recompiling the model during Next.js hot reloads in development. Without this check, you would see an "OverwriteModelError" every time you save a file.
- Save src/models/User.ts.
- Confirm no red underlines appear in the file.
Seeing an error on the mongoose import?
If Cursor underlines the mongoose import, your packages may not have installed correctly. Return to your terminal (open a new terminal tab if the dev server is using the current one) and run npm install mongoose again.
help me fix my Mongoose import error
Now create the Note model. Notes link to the user who created them through an author reference field.
- Right-click the models folder you just created in the Explorer sidebar.
- Click New File and type Note.ts, then press Enter.
- Paste the following code into src/models/Note.ts:
import mongoose, { Schema, models } from "mongoose";
const NoteSchema = new Schema(
{
title: { type: String, required: true },
content: { type: String, required: true },
author: { type: Schema.Types.ObjectId, ref: "User", required: true },
},
{ timestamps: true }
);
// Reuse existing model in dev (hot reload) or create a new one
const Note = models.Note || mongoose.model("Note", NoteSchema);
export default Note;
What does the author field do?
The author field uses Schema.Types.ObjectId with ref: "User". This tells MongoDB that each note belongs to a specific user. When you query notes later, you can filter by this field to show users only their own notes.
The { timestamps: true } option automatically adds createdAt and updatedAt fields. These match the optional date fields in your INote interface.
- Save src/models/Note.ts.
- Confirm no red underlines appear in the file.
Red underline on Schema.Types.ObjectId?
Make sure your import at the top says import mongoose, { Schema, models } from "mongoose". If you imported only mongoose without destructuring Schema, the Schema.Types.ObjectId reference will not resolve.
help me fix my Note model TypeScript error
✔️ Awesome, I've got everything!
Great. Make sure both src/models/User.ts and src/models/Note.ts are saved.
ⓧ I'd like to double check the full code
Here is src/models/User.ts:
import mongoose, { Schema, models } from "mongoose";
const UserSchema = new Schema(
{
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
role: { type: String, enum: ["admin", "member"], default: "member" },
},
{ timestamps: true }
);
const User = models.User || mongoose.model("User", UserSchema);
export default User;
Here is src/models/Note.ts:
import mongoose, { Schema, models } from "mongoose";
const NoteSchema = new Schema(
{
title: { type: String, required: true },
content: { type: String, required: true },
author: { type: Schema.Types.ObjectId, ref: "User", required: true },
},
{ timestamps: true }
);
const Note = models.Note || mongoose.model("Note", NoteSchema);
export default Note;
Verify everything compiles
Before you move on, do you expect to see any errors in the terminal where your dev server is running?
- Switch to the terminal tab where npm run dev is running.
- Check the output. You should see no TypeScript compilation errors.
- Open each of your three new files (src/types/index.ts, src/models/User.ts, src/models/Note.ts) and confirm Cursor shows no red squiggly underlines.
All three files should be clean. The interfaces define the shape, and the schemas enforce it in the database.
Your data layer is locked in. Next up, you will build the registration API route that uses these models to create real users in MongoDB.
Build the Registration API Route
Your database models are defined, but there's no way for users to create accounts yet. Before anyone can log in, they need to register with a name, email, and password.
In this step, you'll build two things: a reusable database connection helper and your first Route Handler that parses a JSON request body, hashes a password with bcrypt, saves the user to MongoDB, and returns a proper HTTP status code.
In this step, get ready to:
- Create the MongoDB connection helper that your entire app will reuse.
- Build the user registration endpoint with input validation and password hashing.
- Test the endpoint with a curl request and verify the response.
Create the database connection helper
Every Route Handler that touches the database needs to connect to MongoDB Atlas first. A shared helper function keeps this logic in one place so you never duplicate connection code.
- In Cursor's Explorer sidebar, right-click the src folder and select New Folder.
- Name the folder lib.
- Right-click the new lib folder and select New File.
- Name the file mongodb.ts and paste the following code:
import mongoose from "mongoose";
// Read the connection string from environment variables
const MONGODB_URI = process.env.MONGODB_URI;
async function dbConnect() {
if (!MONGODB_URI) {
throw new Error("Please define the MONGODB_URI environment variable");
}
await mongoose.connect(MONGODB_URI);
return mongoose;
}
export default dbConnect;
What does this code do?
This helper wraps the Mongoose connection call in a reusable function.
- It reads MONGODB_URI from your .env.local file (the connection string you set up in Step 1).
- If the variable is missing, it throws an error immediately instead of failing silently later.
- Mongoose is safe to call connect() multiple times. It no-ops if already connected, so you can call dbConnect() in every route without worrying about duplicate connections.
- Save src/lib/mongodb.ts.
- Confirm no red squiggly underlines appear in Cursor. The file should compile cleanly with no TypeScript errors.
Seeing a red underline on the mongoose import?
Make sure you installed mongoose in Step 1. Check your package.json for a mongoose entry under dependencies. If it's missing, run npm install mongoose in your terminal.
help me fix a mongoose import error in my mongodb.ts file
Build the registration route handler
Now for the main event. You'll create a Route Handler at /api/auth/register that accepts POST requests and creates new user accounts.
- In Cursor's Explorer sidebar, create the following nested folder structure inside src/app: api/auth/register.
- Create a new file called route.ts inside the register folder.
- Paste the following code into route.ts to set up the imports and input validation:
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import dbConnect from "@/lib/mongodb";
import User from "@/models/User";
export async function POST(request: Request) {
try {
// Parse the JSON body from the incoming request
const { name, email, password } = await request.json();
// Return 400 if any required field is missing
if (!name || !email || !password) {
return NextResponse.json(
{ error: "Name, email, and password are required" },
{ status: 400 }
);
}
// Connect to MongoDB
await dbConnect();
// Check if a user with this email already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return NextResponse.json(
{ error: "User with this email already exists" },
{ status: 409 }
);
}
What does this code do?
- The POST function name tells Next.js this Route Handler responds to POST requests only.
- request.json() parses the incoming JSON body into a JavaScript object. This is the same pattern you'd use in Express.
- A 400 Bad Request response fires if any field is missing. This tells the client exactly what went wrong.
- A 409 Conflict response fires if the email is already taken. This prevents duplicate accounts.
- Now add the password hashing, user creation, and response below the code you just pasted (still inside the try block):
// Hash the password with bcrypt (12 salt rounds)
const hashedPassword = await bcrypt.hash(password, 12);
// Create the new user in MongoDB
const user = await User.create({
name,
email,
password: hashedPassword,
role: "member",
});
// Return the created user (excluding the password)
return NextResponse.json(
{
user: {
id: user._id.toString(),
name: user.name,
email: user.email,
role: user.role,
},
},
{ status: 201 }
);
} catch (error) {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
What does this code do?
- bcrypt.hash(password, 12) turns the plain text password into a one-way hash. The 12 sets the salt rounds, which controls how computationally expensive the hash is to crack.
- User.create() saves the new user document to your MongoDB Atlas cluster.
- The response intentionally omits the password. You only return id, name, email, and role. Never send password hashes back to the client.
- A 201 Created status code signals that a new resource was successfully created.
- The catch block returns a generic 500 Internal Server Error for any unexpected failures, keeping internal details hidden from clients.
- Save src/app/api/auth/register/route.ts.
- Confirm no red squiggly underlines appear in the file.
Seeing errors on the bcryptjs import?
If Cursor shows an error on import bcrypt from "bcryptjs", make sure you installed both the package and its types in Step 1. Check that bcryptjs and @types/bcryptjs appear in your package.json.
help me fix a bcryptjs import error in my registration route
✔️ Awesome, I've got everything!
Great. Double check you've saved both files.
ⓧ I'd like to double check the full code
Your src/app/api/auth/register/route.ts should contain, in order:
- The four imports (NextResponse, bcrypt, dbConnect, User).
- The POST function opening with try block.
- JSON parsing and the 400 validation check.
- dbConnect() call and the 409 duplicate-email check.
- Password hashing with bcrypt.hash(password, 12).
- User.create() and the 201 response (returning id, name, email, role only).
- The catch block returning 500.
Your src/lib/mongodb.ts should look like this:
import mongoose from "mongoose";
const MONGODB_URI = process.env.MONGODB_URI;
async function dbConnect() {
if (!MONGODB_URI) {
throw new Error("Please define the MONGODB_URI environment variable");
}
await mongoose.connect(MONGODB_URI);
return mongoose;
}
export default dbConnect;
Test the registration endpoint
Your route is built. Time to see it work against your live MongoDB Atlas cluster.
Before you run this, what do you think the response will look like? Will it include the password?
- Send a POST request to your registration endpoint by running this command in Cursor's terminal:
curl -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d "{\"name\":\"Test User\",\"email\":\"test@example.com\",\"password\":\"password123\"}"
You should see a 201 response with a JSON body containing your created user. The password is not included.
{
"user": {
"id": "...",
"name": "Test User",
"email": "test@example.com",
"role": "member"
}
}
What just happened?
Your Route Handler received the POST request, parsed the JSON body, hashed the password, saved the user to your MongoDB Atlas cluster, and returned a clean response. All of that is pure Node.js backend code running inside a Next.js Route Handler.
The id field is the MongoDB _id converted to a string. Your actual value will be a unique 24-character hex string.
Getting a 500 error or connection failure?
- Check that your MONGODB_URI in .env.local is correct. It should start with mongodb+srv://.
- Make sure your MongoDB Atlas cluster has your current IP address in its Network Access list.
- Confirm your dev server is still running. If you stopped it, restart it with npm run dev.
help me debug a 500 error on my registration route
Your registration endpoint is live and creating users in MongoDB. Next up, you'll configure NextAuth.js so these users can actually sign in and receive a session.
Configure NextAuth.js with Credentials Provider
Your registration endpoint works. Users can create accounts with hashed passwords stored in MongoDB. But right now, there is no way for them to log in or maintain a session between requests.
NextAuth.js handles session management for you, but the actual credential verification logic is code YOU write. You will look up the user by email, compare their password with bcryptjs, and return their data. This bridges your custom registration system to a full session system.
In this step, get ready to:
- Configure NextAuth.js with a custom Credentials provider.
- Create the catch-all auth route handler that powers sign-in.
- Test sign-in with the user you registered earlier.
Create the NextAuth configuration file
The src/auth.ts file is the heart of your authentication system. It tells NextAuth how to verify a user's credentials and what data to include in their session.
- Create a new file at src/auth.ts and paste the provider configuration with its verification logic:
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import dbConnect from "@/lib/mongodb";
import User from "@/models/User";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
await dbConnect();
const user = await User.findOne({ email: credentials.email });
if (!user) return null;
const isValid = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!isValid) return null;
What does this code do?
- The Credentials provider tells NextAuth you are handling authentication yourself (not using OAuth).
- The authorize function is YOUR custom logic. It connects to MongoDB, finds the user by email, and compares the submitted password against the stored hash using bcrypt.compare.
- If any check fails (missing credentials, user not found, wrong password), it returns null which tells NextAuth the login failed.
- Directly below the code you just pasted in src/auth.ts, add the return value and session callbacks:
return {
id: user._id.toString(),
name: user.name,
email: user.email,
role: user.role,
};
},
}),
],
session: { strategy: "jwt" },
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role;
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.role = token.role as string;
session.user.id = token.id as string;
}
return session;
},
},
});
What do the callbacks do?
When authentication succeeds, the authorize function returns the user object with their id, name, email, and role.
The jwt callback fires when a token is created. It copies role and id from the user object into the token so they persist across requests.
The session callback runs whenever your app checks the session. It reads role and id from the token and attaches them to the session object that your Route Handlers can access.
- Save src/auth.ts.
- Confirm there are no red squiggly underlines in Cursor. The file should compile without TypeScript errors.
Seeing TypeScript errors?
- If you see an error on the role property, that is expected. NextAuth's default types do not include role on the user or session objects. The code still works at runtime. You can safely ignore this for now.
- If you see Cannot find module errors on the imports, make sure you installed next-auth@beta in Step 1. Run npm install next-auth@beta again if needed.
Why isn't my auth.ts compiling?
Create the catch-all route handler
NextAuth needs an API route to handle sign-in requests, session checks, and sign-out. A catch-all route at [...nextauth] lets NextAuth respond to all of these under /api/auth/*.
- Create the folder path src/app/api/auth/[...nextauth]/ and add a route.ts file inside it with this code:
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
What does this route do?
This two-line file imports the handlers you exported from src/auth.ts and re-exports them as GET and POST Route Handlers. NextAuth uses these to power built-in endpoints like /api/auth/signin, /api/auth/session, and /api/auth/signout.
- Save src/app/api/auth/[...nextauth]/route.ts.
Folder structure wrong?
The folder must be named exactly [...nextauth] with the square brackets and three dots included. In Cursor, right-click the src/app/api/auth/ folder, click New Folder, and type [...nextauth] as the folder name. Then create route.ts inside it.
Help me fix my [...nextauth] folder structure.
Test sign-in with your registered user
With auth.ts and the route handler in place, NextAuth automatically provides a sign-in page. The built-in /api/auth/session endpoint also lets you check the current session state at any time.
Before you visit the sign-in page, what do you think it will look like? Will it show your database fields, or something else?
- Open your browser and navigate to http://localhost:3000/api/auth/signin.
You should see a sign-in form with Email and Password fields, plus a Sign in with Credentials button. NextAuth generated this page automatically based on your Credentials provider config.
- Enter test@example.com in the Email field.
- Enter password123 in the Password field.
- Click Sign in with Credentials.
You should be redirected to the home page without seeing an error. This means NextAuth successfully looked up the user in MongoDB, verified the password hash, and created a session.
- Confirm the session is active by visiting http://localhost:3000/api/auth/session in your browser.
You should see a JSON response containing your user's name, email, role, and id. This confirms the JWT and session callbacks are correctly attaching your custom fields.
Where does /api/auth/session come from?
You did not create a separate session route file. The [...nextauth] catch-all handler responds to ALL /api/auth/* paths automatically. That includes /api/auth/signin, /api/auth/session, /api/auth/signout, and more.
Seeing a "Sign in" error or redirect loop?
- Make sure your AUTH_SECRET environment variable is set in .env.local. NextAuth requires this to encrypt tokens.
- Check that your MONGODB_URI in .env.local is correct and your Atlas cluster is running. The authorize function connects to the database on every login attempt.
- If you restarted the dev server after adding environment variables, try stopping it with Ctrl+C and running npm run dev again. Next.js only reads .env.local at startup.
Why is my NextAuth sign-in failing?
✔️ Awesome, I've got everything!
Great. Make sure you have saved both src/auth.ts and src/app/api/auth/[...nextauth]/route.ts.
ⓧ I'd like to double check the full code
Here is the catch-all route handler (the short one):
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
For src/auth.ts, scroll up and confirm you have both chunks pasted in order:
- Chunk 1: The imports through if (!isValid) return null; (the credential verification logic).
- Chunk 2: The return { ... } statement, closing brackets, session: { strategy: "jwt" }, and both callbacks through the final });.
The combined file should start with import NextAuth from "next-auth" and end with });.
Your authentication system is fully wired up. Users can register, log in, and maintain sessions that carry their role. Next up, you will use that role to control who can do what with the Notes API.
Build the Notes CRUD API with Role-Based Access Control
Your auth system is live. Users can register, sign in, and receive sessions with their role embedded. But authentication alone only answers "who are you?" The harder question is "what are you allowed to do?"
In this step, you'll build the Notes API and enforce role-based access control on every endpoint. Every request checks the user's session AND their role before touching the database. A member who tries to delete another user's note gets a 403 Forbidden.
In this step, get ready to:
- Create an auth helper that extracts the current user from the session.
- Build GET and POST endpoints for notes with role-based filtering.
- Build a DELETE endpoint that enforces ownership and admin permissions.
Create the auth helper function
Every notes endpoint needs to know who is making the request. Instead of repeating the session-fetching logic in every route handler, you'll create a single helper that wraps the auth() call and returns a clean user object.
- Create a new file at src/lib/auth-helpers.ts and add the following code:
import { auth } from "@/auth";
export async function getCurrentUser() {
const session = await auth();
if (!session?.user) return null;
return {
id: session.user.id,
name: session.user.name,
email: session.user.email,
role: session.user.role,
};
}
What does this code do?
The getCurrentUser() function calls auth() from your NextAuth config to retrieve the current session. If there's no session (the user isn't logged in), it returns null.
If a session exists, it returns only the fields your API needs: id, name, email, and role. This keeps your route handlers clean and consistent.
- Save src/lib/auth-helpers.ts.
- Confirm there are no red squiggly errors in Cursor. The import path @/auth should resolve to your src/auth.ts file.
Seeing a module resolution error?
- Make sure your src/auth.ts file exports auth from the NextAuth() call. Check that the export line reads export const { handlers, auth, signIn, signOut } = NextAuth({...}).
- Confirm your tsconfig.json has the @/* path alias configured. This was set up automatically when you created the project with --import-alias "@/*".
Why can't Cursor resolve my @/auth import?
Build the GET endpoint for listing notes
The GET endpoint is where role-based filtering happens. An admin sees all notes in the database. A member sees only their own. The difference comes down to a single MongoDB query filter.
- Create a new file at src/app/api/notes/route.ts and add the following code:
import { NextResponse } from "next/server";
import dbConnect from "@/lib/mongodb";
import Note from "@/models/Note";
import { getCurrentUser } from "@/lib/auth-helpers";
export async function GET() {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await dbConnect();
// Admins get all notes, members only get their own
const filter = user.role === "admin" ? {} : { author: user.id };
const notes = await Note.find(filter).sort({ createdAt: -1 });
return NextResponse.json({ notes });
}
What does this code do?
- First, it calls getCurrentUser() to check if the request is authenticated. If not, it immediately returns a 401 Unauthorized response.
- The filter variable is the key to role-based access. For admins, it's an empty object {} (which matches all documents). For members, it restricts results to notes where author matches the current user's ID.
- Results are sorted by createdAt: -1 so the newest notes appear first.
- Save src/app/api/notes/route.ts.
- Make sure you're signed in at http://localhost:3000/api/auth/signin (use the test user credentials you registered earlier).
- Visit http://localhost:3000/api/notes in your browser. You should see {"notes":[]} because you haven't created any notes yet.
Seeing Unauthorized instead of an empty notes array?
- You need to be signed in first. Visit http://localhost:3000/api/auth/signin in the same browser, enter your test user credentials, and sign in. Then revisit /api/notes.
- If you signed in but still see the 401, check that your src/auth.ts callbacks correctly attach role and id to the session.
Why am I getting 401 Unauthorized even after signing in?
Now add the POST function that creates new notes. This goes in the same file, below the GET function.
- In src/app/api/notes/route.ts, add the following POST function below your GET function:
export async function POST(request: Request) {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { title, content } = await request.json();
if (!title || !content) {
return NextResponse.json(
{ error: "Title and content are required" },
{ status: 400 }
);
}
await dbConnect();
const note = await Note.create({
title,
content,
author: user.id,
});
return NextResponse.json({ note }, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
What does this code do?
- Like GET, it first checks authentication and returns 401 if the user isn't signed in.
- It parses the JSON body and validates that both title and content are present, returning 400 if either is missing.
- The author: user.id line ties the note to whoever created it. This is how ownership is established for the role-based filtering in GET.
- On success, it returns the created note with a 201 Created status.
- Save src/app/api/notes/route.ts.
- Open your browser's developer console (press Cmd+Option+J on macOS or Ctrl+Shift+J on Windows).
- Create a note by running this fetch call in the console:
fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'My First Note', content: 'Hello from the API!' })
}).then(r => r.json()).then(console.log)
- You should see a response object containing your new note with a title, content, author, and _id field in the console output.
- Refresh http://localhost:3000/api/notes in your browser. You should now see your note in the notes array.
Console showing an error or empty response?
- Make sure you're running the fetch command in the browser console on a page at localhost:3000 (not a different site's console). The session cookie only applies to that domain.
- If you see Unauthorized, sign in again at /api/auth/signin and try the fetch again.
- If you see Title and content are required, double-check that your body includes both title and content fields.
Why is my POST request returning an error?
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/app/api/notes/route.ts with both the GET and POST functions.
ⓧ I'd like to double check the full code
Your src/app/api/notes/route.ts should have two sections. Scroll up and confirm you have:
- The imports and GET function (which uses the filter variable for role-based access).
- The POST function (which validates title and content, then creates the note with author: user.id).
Both functions should reference getCurrentUser() from @/lib/auth-helpers and return 401 when the user is not authenticated.
Build the DELETE endpoint with permission checks
The DELETE route is where access control gets strict. A member can only delete their own notes. An admin can delete any note. Anyone else gets blocked. This is the endpoint that turns "authenticated" into "authorized."
- Create a new file at src/app/api/notes/[id]/route.ts and add the following code:
import { NextResponse } from "next/server";
import dbConnect from "@/lib/mongodb";
import Note from "@/models/Note";
import { getCurrentUser } from "@/lib/auth-helpers";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
await dbConnect();
const note = await Note.findById(id);
if (!note) {
return NextResponse.json({ error: "Note not found" }, { status: 404 });
}
if (user.role !== "admin" && note.author.toString() !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await Note.findByIdAndDelete(id);
return new Response(null, { status: 204 });
}
What does this code do?
- The params object is a Promise in Next.js 16, so you must await it to get the note id from the URL.
- After finding the note, the permission check runs: if the user is NOT an admin AND the note's author doesn't match the current user, it returns 403 Forbidden.
- Only if both checks pass does findByIdAndDelete run, returning 204 No Content on success.
Why is params a Promise?
In Next.js 16, dynamic route parameters in Route Handlers are delivered as a Promise. This is a breaking change from earlier versions. You must write const { id } = await params instead of accessing params.id directly.
- Save src/app/api/notes/[id]/route.ts.
Now it's time to test the full role-based access control. You'll need two users: one member and one admin.
Verify role-based access control with two users
To see the access control in action, you need a second user with the admin role. Since the register endpoint always creates members, you'll promote a user to admin directly in MongoDB Atlas.
- Register a second user by running this in your browser console:
fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Admin User', email: 'admin@example.com', password: 'password123' })
}).then(r => r.json()).then(console.log)
- You should see a 201 response with the new user's details.
- Open MongoDB Atlas in a new browser tab and navigate to your cluster.
- Click Browse Collections (or Data Explorer in the sidebar).
- Select the users collection and find the document with email: "admin@example.com".
- Hover over the document and click the pencil icon to enter edit mode.
- Change the role field value from member to admin.
- Click Update to save.
Now let's create a note as the admin user, then test the member's restricted view.
- Sign out by visiting http://localhost:3000/api/auth/signout in your browser and confirming the sign-out.
- Sign in as the admin user at http://localhost:3000/api/auth/signin using admin@example.com and password123.
- Create a note as the admin by running this in the browser console:
fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Admin Note', content: 'This is from the admin.' })
}).then(r => r.json()).then(console.log)
- Visit http://localhost:3000/api/notes in your browser. As an admin, you should see BOTH notes (your earlier test note and this new admin note).
Before you switch to the member user, think about this: what do you expect to see when a member visits /api/notes? Will they see both notes, or just one?
- Sign out by visiting http://localhost:3000/api/auth/signout and confirming.
- Sign in as your original test user (test@example.com / password123).
- Visit http://localhost:3000/api/notes again.
You should see only the note YOU created earlier ("My First Note"). The admin's note is invisible to you. That single filter line in your GET handler is doing all the work.
Now test the permission guard on DELETE. Copy the _id of the admin's note from when you saw it as admin (or look it up in Atlas), then try to delete it as the member:
- Run this in the browser console, replacing the ID with the admin's note ID from your Atlas collection:
fetch('/api/notes/[[NOTE_ID="the admin note _id from MongoDB Atlas"]]', {
method: 'DELETE'
}).then(r => { console.log('Status:', r.status); return r.text(); }).then(console.log)
You should see Status: 403 in the console. The member tried to delete a note that belongs to someone else, and the API blocked it.
Why does this matter?
Without the role check on line if (user.role !== "admin" && note.author.toString() !== user.id), any authenticated user could delete any note in the database. The 403 you just saw is the difference between authentication ("you are who you say you are") and authorization ("you're allowed to do this specific thing").
Not seeing the expected results?
- If the admin sees only one note instead of both, check that you created the first note while signed in as the member (not while signed out).
- If the member sees ALL notes, double-check the filter line in your GET function. The member's user.role should be "member", which triggers the { author: user.id } filter.
- If delete returns 204 instead of 403, make sure you're signed in as the member (not the admin) and using the admin's note ID.
Help me debug my role-based access control. The wrong user is seeing the wrong notes.
Your API now enforces real access control. Every endpoint checks both identity and permissions before touching data. Next up, you'll extend this with a visual admin dashboard that queries the database directly from a Server Component.
Secret mission
Build a Protected Admin Dashboard Page
Your API enforces role-based access, but there is no visual proof. Build a protected admin dashboard that queries the database directly from a Server Component and displays all users with their note counts.
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 uses a MongoDB Atlas M0 free cluster and runs locally, so there are no ongoing costs.
Resources you used:
- MongoDB Atlas M0 cluster (free tier) with your user and note data.
- Local notes-api project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with the API.
- Your MongoDB Atlas M0 cluster stays free indefinitely with no credit card required.
- Run npm run dev inside your notes-api folder anytime to continue developing.
Pause - I'll come back to this later
Shut down running processes to free up system resources. Your data stays safe on Atlas.
- Stop the dev server by pressing Ctrl+C in the terminal where it is running.
- Your database data remains on Atlas and is accessible whenever you start the dev server again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible.
- Drop your database in MongoDB Atlas:
- Log in to MongoDB Atlas and click Data Explorer in the left sidebar under the Database heading.
- Hover over your database name in the Connections sidebar and click the trash can icon.
- Type the database name in the confirmation dialog and click Drop Database.
- Delete the local project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf notes-api
On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force notes-api
Nice Work!
Nice Work!
Nice work! You just built a full authentication system and role-based Notes API from scratch using Next.js Route Handlers running on Node.js.
You've learned how to:
- Built a complete REST API using Next.js Route Handlers with user registration, password hashing via bcryptjs, and proper HTTP status codes.
- Configured NextAuth.js v5 with a custom Credentials provider and JWT session strategy, writing the credential verification logic yourself.
- Implemented role-based access control that restricts API operations based on user role, returning 401, 403, or 204 depending on permissions.
- Secret Mission: Built a protected admin dashboard using Server Components with server-side auth checks and MongoDB aggregation queries.
Ready to quiz yourself?