A fullstack authentication starter built with Next.js (App Router), Prisma ORM (v7, driver adapters), PostgreSQL, JWT session cookies, bcrypt, and Nodemailer.
It ships with:
- Login (
POST /api/auth/login) - Logout (
POST /api/auth/logout) - Sign up (
POST /api/auth/signup) - Forgot password (emails a reset link via Nodemailer —
POST /api/auth/forgot-password) - Reset password (
POST /api/auth/reset-password) - A seed script (
seed.ts) that creates a default admin user only when theuserstable is empty - A minimal UI: login/signup page, protected dashboard, forgot-password and reset-password pages
- How it works
- Project structure
- Running the app
- API reference
- Frontend pages
- Testing
- Configuring real SMTP email
- Security notes
The single model User (table users) is defined in prisma/schema.prisma:
| Column | Type | Notes |
|---|---|---|
id |
uuid |
Primary key, default uuid() |
name |
text |
Required |
email |
text |
Required, unique (stored lowercased) |
password_hash |
text |
bcrypt hash — plaintext is never stored |
is_admin |
boolean |
Default false |
reset_token_hash |
text? |
SHA-256 hash of the active password-reset token |
reset_token_expiry |
timestamptz? |
When the reset token expires (null = none) |
created_at |
timestamptz |
Defaults to now() |
updated_at |
timestamptz |
@updatedAt — auto-set by Prisma on update |
Only the SHA-256 hash of a reset token is stored — never the raw token. This mirrors the way passwords are stored (only a bcrypt hash), so a database leak doesn't expose usable reset links.
This project uses Prisma 7, which introduced breaking changes vs. Prisma 6:
- Driver adapters are mandatory.
PrismaClientno longer manages DB connections itself — it must be constructed with an adapter:The singleton lives inimport { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import { Pool } from "pg"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
src/lib/prisma.ts(pool + client cached onglobalThisto survive Next.js hot reload). urlno longer lives inschema.prisma. The datasource block is justprovider = "postgresql"; the connection URL is supplied throughprisma.config.ts:import "dotenv/config"; import { defineConfig } from "prisma/config"; export default defineConfig({ schema: "prisma/schema.prisma", datasource: { url: process.env.DATABASE_URL }, });
next.config.tsexternalizes Prisma and pg so Turbopack doesn't bundle them:const nextConfig: NextConfig = { serverExternalPackages: ["@prisma/client", "pg"] };
Sessions are stateless JWTs stored in an httpOnly cookie:
- On login/signup, the server signs a JWT payload
{ sub: user.id, email }withJWT_SECRET(7-day expiry). - The token is placed in the
auth_tokencookie with:httpOnly— JavaScript can't read it (XSS-safe)sameSite: "lax"— CSRF protectionsecure— enabled automatically in productionpath: "/",maxAge: 7 days
- On every protected request,
getSessionUser()reads the cookie, verifies the JWT signature/expiry, loads the user row viaprisma.user.findUnique, and returns it — ornullif anything fails. - Logout simply clears the cookie (no server-side session store to invalidate).
Password hashing uses bcrypt with cost factor 12.
SIGN UP
client ── POST /api/auth/signup {name,email,password}
→ validate (400 on missing/bad input, 409 on duplicate email)
→ bcrypt hash password
→ prisma.user.create
→ sign JWT + set auth_token cookie
→ 201 { user }
LOGIN
client ── POST /api/auth/login {email,password}
→ prisma.user.findUnique by email (lowercased)
→ bcrypt.compare(password, hash) (401 on failure)
→ sign JWT + set auth_token cookie
→ 200 { user }
LOGOUT
client ── POST /api/auth/logout
→ clear auth_token cookie
→ 200 { ok: true }
CURRENT USER
client ── GET /api/auth/me
→ getSessionUser() from cookie
→ 200 { user } or 401 { error }
FORGOT PASSWORD
client ── POST /api/auth/forgot-password {email}
→ always responds the same generic message (no email enumeration)
→ if account exists: generate 32-byte random token,
store SHA-256(token) + 1h expiry on the user row
→ send email via Nodemailer with link /reset-password?token=<raw>
→ 200 { message } (+ devResetUrl in dev mode without SMTP)
RESET PASSWORD
client ── POST /api/auth/reset-password {token,password}
→ hash token, find user via prisma.user.findFirst where resetTokenHash = hash
→ reject if missing/expired (400)
→ bcrypt hash the new password, clear token fields
→ 200 { message }
Rate limiting / security note: the forgot-password endpoint deliberately returns the same response whether or not the email exists. In a production deployment you should additionally add rate limiting (e.g.
@upstash/ratelimit) in front of the auth routes.
src/lib/mailer.ts wraps Nodemailer and behaves differently depending on configuration:
SMTP configured? (SMTP_HOST + SMTP_USER + SMTP_PASS) |
Behavior |
|---|---|
| Yes | Sends real email through your SMTP server |
| No (default) | Uses Nodemailer's jsonTransport and logs the full email to the server console — perfect for local development |
The password-reset email contains a nicely formatted HTML button plus a plain-text fallback.
seed.ts (project root) is idempotent:
npx tsx seed.ts
- Queries
prisma.user.count(). - If 0 users exist → creates the default admin user (
Admin/admin@example.com/Admin123!) with a bcrypt-hashed password, and logs the credentials. - If ≥ 1 user exists → prints
Skipped — N user(s) already existand does nothing.
Credentials can be overridden with env vars: DEFAULT_USER_NAME, DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD.
.
├── seed.ts # idempotent seed script (npx tsx seed.ts)
├── prisma/
│ └── schema.prisma # Prisma schema (User model)
├── prisma.config.ts # Prisma CLI config (datasource url)
├── next.config.ts # serverExternalPackages for Prisma/pg
├── .env # DATABASE_URL, JWT_SECRET, SMTP, defaults
├── src/
│ ├── lib/
│ │ ├── prisma.ts # PrismaClient singleton (pg driver adapter)
│ │ ├── auth.ts # bcrypt, JWT, cookie helpers, getSessionUser
│ │ └── mailer.ts # Nodemailer transport (SMTP or dev fallback)
│ ├── app/
│ │ ├── page.tsx # "/" login/signup landing page
│ │ ├── dashboard/page.tsx # protected dashboard (redirects if logged out)
│ │ ├── forgot-password/page.tsx
│ │ ├── reset-password/page.tsx # ?token=... link target
│ │ └── api/
│ │ ├── health/route.ts
│ │ └── auth/
│ │ ├── signup/route.ts
│ │ ├── login/route.ts
│ │ ├── logout/route.ts
│ │ ├── me/route.ts
│ │ ├── forgot-password/route.ts
│ │ └── reset-password/route.ts
│ └── components/
│ ├── auth-form.tsx # login/signup tabs (client)
│ ├── logout-button.tsx
│ ├── forgot-password-form.tsx
│ └── reset-password-form.tsx
- Node.js 18.18+ (this project uses Next.js 16)
- A running PostgreSQL instance
npm(orpnpm/yarn)
npm installKey runtime deps: next, react, @prisma/client, @prisma/adapter-pg, pg, bcryptjs, jsonwebtoken, nodemailer, dotenv. Dev deps include prisma, tsx (to run seed.ts), typescript, @types/*.
Create/update .env:
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/app_db
# Auth
JWT_SECRET=replace-me-with-a-long-random-secret # use a long random string!
APP_URL=http://localhost:3000
# Default user created by seed.ts (only when users table is empty)
DEFAULT_USER_NAME=Admin
DEFAULT_USER_EMAIL=admin@example.com
DEFAULT_USER_PASSWORD=Admin123!
# SMTP (Nodemailer) — leave unset for the dev jsonTransport fallback
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=no-reply@example.com
# SMTP_PASS=your-password
# SMTP_FROM="Auth App <no-reply@example.com>"npx prisma db pushThis creates/updates the users table from prisma/schema.prisma (no migration files needed for prototyping).
npx tsx seed.tsExpect:
[seed] No users found — created default user:
email: admin@example.com
password: Admin123!
role: admin
Run it again and it will skip. Important: the seed only creates a user when the table is empty — it never overwrites or duplicates existing users.
Development:
npm run dev
# http://localhost:3000Production (validate first, see Testing):
npm run build
npm run startBase URL: http://localhost:3000 (or your APP_URL).
Request:
{ "name": "Jane Doe", "email": "jane@example.com", "password": "Password123" }Responses:
| Status | Body |
|---|---|
201 |
{ "user": { "id", "name", "email", "isAdmin", "createdAt" } } + sets auth_token cookie |
400 |
{ "error": "Name, email, and password are required." } / invalid email / short password |
409 |
{ "error": "An account with this email already exists." } |
Request:
{ "email": "admin@example.com", "password": "Admin123!" }Responses:
| Status | Body |
|---|---|
200 |
{ "user": { ... } } + sets auth_token cookie |
400 |
{ "error": "Email and password are required." } |
401 |
{ "error": "Invalid email or password." } |
| Status | Body |
|---|---|
200 |
{ "ok": true } (clears auth_token) |
| Status | Body |
|---|---|
200 |
{ "user": { ... } } (requires valid auth_token cookie) |
401 |
{ "error": "Not authenticated." } |
Request: { "email": "admin@example.com" }
| Status | Body |
|---|---|
200 |
{ "message": "If an account exists for that email, a password reset link has been sent." } — same response whether or not the account exists |
200 (dev only) |
Additionally includes devResetUrl when NODE_ENV !== "production" and SMTP is not configured |
Request: { "token": "<raw token from email>", "password": "NewPassword123" }
| Status | Body |
|---|---|
200 |
{ "message": "Your password has been reset. You can now sign in." } |
400 |
missing token / password < 8 chars / invalid or expired token |
| Route | Behavior |
|---|---|
/ |
Shows the login/signup card. Logged-in users are redirected to /dashboard. Shows the seeded default credentials as a hint. |
/dashboard |
Protected. Server-side getSessionUser() check; redirects to / when logged out. Shows the user's profile + sign-out button. |
/forgot-password |
Email input; on success shows the generic message, and in dev mode (no SMTP) also shows the clickable devResetUrl. |
/reset-password?token=... |
New password + confirmation. On success redirects to / after ~1.8s. |
Run these three commands from the project root — they must all pass before shipping:
# 1. Generate route types (Next.js)
bash -lc 'set -o pipefail; npx next typegen 2>&1 | tee /tmp/next-typegen.log'
# 2. TypeScript check
bash -lc 'set -o pipefail; npm exec tsc -- --noEmit --pretty false 2>&1 | tee /tmp/tsc.log'
# 3. Production build
bash -lc 'set -o pipefail; npm run build 2>&1 | tee /tmp/build.log'If any fail, inspect the corresponding log (/tmp/next-typegen.log, /tmp/tsc.log, /tmp/build.log).
Start the app (npm run dev or npm run build && npm run start), then from a second terminal:
1. Health check
curl -s http://localhost:3000/api/health
# {"ok":true}2. Login with the seeded user (save cookies to a jar)
curl -s -c /tmp/cookies.txt \
-X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"Admin123!"}'3. Fetch current user with the session cookie
curl -s -b /tmp/cookies.txt http://localhost:3000/api/auth/me4. Wrong password → 401
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"wrong"}'
# 4015. Sign up (new user)
curl -s -c /tmp/cookies2.txt \
-X POST http://localhost:3000/api/auth/signup \
-H "Content-Type: application/json" \
-d '{"name":"Jane Doe","email":"jane@example.com","password":"Password123"}'6. Duplicate signup → 409
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:3000/api/auth/signup \
-H "Content-Type: application/json" \
-d '{"name":"Jane Doe","email":"jane@example.com","password":"Password123"}'
# 4097. Logout, then /me → 401
curl -s -b /tmp/cookies2.txt -c /tmp/cookies2.txt \
-X POST http://localhost:3000/api/auth/logout
curl -s -o /dev/null -w "%{http_code}\n" -b /tmp/cookies2.txt http://localhost:3000/api/auth/me
# 4018. Validation errors → 400
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:3000/api/auth/signup \
-H "Content-Type: application/json" \
-d '{"name":"X","email":"not-an-email","password":"Password123"}'
# 400Option 1 — dev server (recommended, no SMTP needed). Run npm run dev, then:
# Request a reset link (dev mode returns devResetUrl in the response)
curl -s -X POST http://localhost:3000/api/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com"}'You'll get a devResetUrl like http://localhost:3000/reset-password?token=<hex>. Grab the token, then:
curl -s -X POST http://localhost:3000/api/auth/reset-password \
-H "Content-Type: application/json" \
-d '{"token":"<hex-token>","password":"NewPass123!"}'
# {"message":"Your password has been reset. You can now sign in."}
# Old password now fails…
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"Admin123!"}'
# 401
# …and the new one works
curl -s -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"NewPass123!"}'Option 2 — production server. In next start the devResetUrl is hidden (that's by design). To test reset on a production build, insert a token directly into the DB and hit the endpoint:
TOKEN=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')
HASH=$(node -e "console.log(require('crypto').createHash('sha256').update('$TOKEN').digest('hex'))")
psql postgresql://postgres:postgres@127.0.0.1:5432/app_db -q \
-c "UPDATE users SET reset_token_hash='$HASH', reset_token_expiry=now() + interval '1 hour' WHERE email='admin@example.com';"
curl -s -X POST http://localhost:3000/api/auth/reset-password \
-H "Content-Type: application/json" \
-d "{\"token\":\"$TOKEN\",\"password\":\"NewPass123!\"}"You can also test expired/invalid tokens by setting an old expiry:
psql postgresql://postgres:postgres@127.0.0.1:5432/app_db -q \
-c "UPDATE users SET reset_token_hash='$HASH', reset_token_expiry=now() - interval '1 hour' WHERE email='admin@example.com';"
# → 400 { "error": "This reset link is invalid or has expired." }And single-use tokens: after a successful reset the token fields are cleared, so reusing the same token returns 400.
Option 3 — with real SMTP. Configure SMTP in .env, then the email is actually delivered to the inbox; click the link in the email and change the password in the browser.
- Open
http://localhost:3000→ you see the login/signup card. - Login:
admin@example.com/Admin123!→ redirected to/dashboardshowing your profile. Click Sign out → back to/. - Sign up: switch to "Create account", use a new email → automatically signed in and redirected to
/dashboard. - Forgot password: on the login card click "Forgot password?", submit your email → generic success message; in dev mode a clickable link appears → click it → set a new password → redirected to sign in → login with the new password.
- Try visiting
/dashboardwhile logged out → you're redirected to/. - Visit
/reset-passwordwithout a token → friendly error asking you to request a new link.
Set these in .env and restart the server:
SMTP_HOST=smtp.example.com # e.g. smtp.gmail.com, smtp.mailgun.org
SMTP_PORT=587 # 465 for SSL
SMTP_USER=no-reply@example.com
SMTP_PASS=your-app-password # use an app password for Gmail
SMTP_FROM="Auth App <no-reply@example.com>"Once SMTP_HOST, SMTP_USER, and SMTP_PASS are all set, the mailer switches from the dev console fallback to real delivery.
Tip: for Gmail use an App Password, not your account password.
- Passwords are hashed with bcrypt (cost 12); plaintext is never stored or logged.
- Session tokens are JWTs signed with
JWT_SECRETstored in an httpOnly, sameSite=lax cookie — protected against XSS reads and basic CSRF. - Reset tokens are 32 random bytes, stored only as SHA-256 hashes, and expire after 1 hour (single-use — cleared after reset).
forgot-passwordreturns the same response for existing and non-existing emails to prevent user enumeration.JWT_SECRETin.envis a placeholder — generate a long random value for any real deployment, and keep.envout of version control.- Prisma 7 requires the pg driver adapter; never construct
new PrismaClient()without an adapter (it throws). - For production, add rate limiting (e.g.
@upstash/ratelimit) and consider rotating the JWT secret.