Skip to content

Repository files navigation

Auth Starter — Next.js + Prisma + PostgreSQL + Nodemailer

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 the users table is empty
  • A minimal UI: login/signup page, protected dashboard, forgot-password and reset-password pages

Table of contents

  1. How it works
  2. Project structure
  3. Running the app
  4. API reference
  5. Frontend pages
  6. Testing
  7. Configuring real SMTP email
  8. Security notes

How it works

Database schema

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.

Prisma 7 setup

This project uses Prisma 7, which introduced breaking changes vs. Prisma 6:

  • Driver adapters are mandatory. PrismaClient no longer manages DB connections itself — it must be constructed with an adapter:
    import { 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) });
    The singleton lives in src/lib/prisma.ts (pool + client cached on globalThis to survive Next.js hot reload).
  • url no longer lives in schema.prisma. The datasource block is just provider = "postgresql"; the connection URL is supplied through prisma.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.ts externalizes Prisma and pg so Turbopack doesn't bundle them:
    const nextConfig: NextConfig = { serverExternalPackages: ["@prisma/client", "pg"] };

Session management

Sessions are stateless JWTs stored in an httpOnly cookie:

  1. On login/signup, the server signs a JWT payload { sub: user.id, email } with JWT_SECRET (7-day expiry).
  2. The token is placed in the auth_token cookie with:
    • httpOnly — JavaScript can't read it (XSS-safe)
    • sameSite: "lax" — CSRF protection
    • secure — enabled automatically in production
    • path: "/", maxAge: 7 days
  3. On every protected request, getSessionUser() reads the cookie, verifies the JWT signature/expiry, loads the user row via prisma.user.findUnique, and returns it — or null if anything fails.
  4. Logout simply clears the cookie (no server-side session store to invalidate).

Password hashing uses bcrypt with cost factor 12.

Auth flows

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.

Email delivery

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.

The seed script

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 exist and does nothing.

Credentials can be overridden with env vars: DEFAULT_USER_NAME, DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD.


Project structure

.
├── 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

Running the app

Prerequisites

  • Node.js 18.18+ (this project uses Next.js 16)
  • A running PostgreSQL instance
  • npm (or pnpm/yarn)

1. Install dependencies

npm install

Key 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/*.

2. Configure environment variables

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>"

3. Sync the database schema

npx prisma db push

This creates/updates the users table from prisma/schema.prisma (no migration files needed for prototyping).

4. Seed the default user

npx tsx seed.ts

Expect:

[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.

5. Run the app

Development:

npm run dev
# http://localhost:3000

Production (validate first, see Testing):

npm run build
npm run start

API reference

Base URL: http://localhost:3000 (or your APP_URL).

POST /api/auth/signup

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." }

POST /api/auth/login

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." }

POST /api/auth/logout

Status Body
200 { "ok": true } (clears auth_token)

GET /api/auth/me

Status Body
200 { "user": { ... } } (requires valid auth_token cookie)
401 { "error": "Not authenticated." }

POST /api/auth/forgot-password

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

POST /api/auth/reset-password

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

Frontend pages

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.

Testing

A. Automated validation suite

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).

B. API tests with curl

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/me

4. 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"}'
# 401

5. 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"}'
# 409

7. 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
# 401

8. 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"}'
# 400

C. Password reset end-to-end test

Option 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.

D. Manual UI walkthrough

  1. Open http://localhost:3000 → you see the login/signup card.
  2. Login: admin@example.com / Admin123! → redirected to /dashboard showing your profile. Click Sign out → back to /.
  3. Sign up: switch to "Create account", use a new email → automatically signed in and redirected to /dashboard.
  4. 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.
  5. Try visiting /dashboard while logged out → you're redirected to /.
  6. Visit /reset-password without a token → friendly error asking you to request a new link.

Configuring real SMTP email

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.


Security notes

  • Passwords are hashed with bcrypt (cost 12); plaintext is never stored or logged.
  • Session tokens are JWTs signed with JWT_SECRET stored 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-password returns the same response for existing and non-existing emails to prevent user enumeration.
  • JWT_SECRET in .env is a placeholder — generate a long random value for any real deployment, and keep .env out 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages