Skip to main content

Version 4: Auth & Redis (MVP)

Focus: NextAuth, Redis Caching, Rate Limiting
Status: MVP Milestone


MVP Milestone

Version 4.0 marks the Minimal Viable Product:

  • ✅ User authentication
  • ✅ Database-backed products
  • ✅ Stripe payments with webhooks
  • ✅ Order history per user
  • ✅ Role-based access control
  • ✅ Performance optimization

Everything after v4 is enhancement, not core functionality.


4A - NextAuth Integration

Objective

Implement user authentication with JWT sessions.

Setup

File: src/auth.ts

import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";

export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
async authorize(credentials) {
const { email, password } = credentials;

// Query user from database
const result = await query(
"SELECT id, email, password_hash, role FROM users WHERE email = $1",
[email]
);

if (result.rows.length === 0) return null;

const user = result.rows[0];

// Verify password
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) return null;

return {
id: user.id.toString(),
email: user.email,
role: user.role,
};
},
}),
],

callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},

async session({ session, token }) {
session.user.id = token.id as string;
session.user.role = token.role as string;
return session;
},
},

session: { strategy: "jwt" },
pages: { signIn: "/auth/signin" },
});

User Schema

CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT,
role TEXT DEFAULT 'customer' CHECK (role IN ('customer', 'admin')),
created_at TIMESTAMPTZ DEFAULT now()
);

Registration API

export async function POST(req: Request) {
const { email, password } = await req.json();

// Validate
if (!email || !password || password.length < 6) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}

// Check existing
const existing = await query("SELECT id FROM users WHERE email = $1", [
email,
]);
if (existing.rows.length > 0) {
return NextResponse.json({ error: "Email exists" }, { status: 400 });
}

// Hash password (10 rounds ≈ 100ms)
const passwordHash = await bcrypt.hash(password, 10);

// Create user
await query("INSERT INTO users (email, password_hash) VALUES ($1, $2)", [
email,
passwordHash,
]);

return NextResponse.json({ success: true }, { status: 201 });
}

Password Security

PropertyImplementation
Hashingbcrypt with 10 rounds
SaltAuto-generated per password
Comparisonbcrypt.compare() (no decrypt)
StorageOnly hash stored, never plain

4B - Middleware Protection

Objective

Route-level access control.

File: src/middleware.ts

import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";

export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;

// Dynamic cookie name (HTTPS vs HTTP)
const isSecure = req.url.startsWith("https");
const cookieName = isSecure
? "__Secure-authjs.session-token"
: "authjs.session-token";

const token = await getToken({
req,
secret: process.env.AUTH_SECRET,
cookieName,
});

const protectedRoutes = ["/orders", "/profile", "/settings"];
const isProtectedRoute = protectedRoutes.some((r) => pathname.startsWith(r));
const isAdminRoute = pathname.startsWith("/admin");

// Authentication check
if ((isProtectedRoute || isAdminRoute) && !token) {
const signInUrl = new URL("/auth/signin", req.url);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);
}

// Authorization check (admin only)
if (isAdminRoute && token?.role !== "admin") {
return NextResponse.redirect(new URL("/", req.url));
}

return NextResponse.next();
}

export const config = {
matcher: [
"/orders/:path*",
"/profile/:path*",
"/settings/:path*",
"/admin/:path*",
],
};

Access Matrix

RouteGuestCustomerAdmin
/products
/cart
Checkout✅ (email)
/orders❌ → Login
/admin/*❌ → Login❌ → Home

4C - Redis Caching

Objective

Reduce database load and improve response times.

Setup

File: src/lib/redis.ts

import { Redis } from "@upstash/redis";

export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

export const CACHE_KEYS = {
PRODUCTS_ALL: "products:all",
};

export const CACHE_TTL = {
PRODUCTS: 60 * 10, // 10 minutes
};

Cache-Aside Pattern

export async function GET() {
// 1. Check cache
const cached = await redis.get(CACHE_KEYS.PRODUCTS_ALL);
if (cached) {
return NextResponse.json({ products: cached, source: "cache" });
}

// 2. Query database
const result = await query("SELECT * FROM products ORDER BY id ASC");

// 3. Populate cache
await redis.set(CACHE_KEYS.PRODUCTS_ALL, JSON.stringify(result.rows), {
ex: CACHE_TTL.PRODUCTS,
});

return NextResponse.json({ products: result.rows, source: "database" });
}

Performance Results

EndpointWithout CacheWith CacheImprovement
Products~450ms~8ms56x faster

Why Upstash?

FeatureStandard RedisUpstash
Edge Runtime❌ TCP required✅ HTTP-based
Connection poolingRequiredNot needed
ServerlessComplexZero config

4D - Rate Limiting

Objective

Protect against abuse.

Implementation

async function rateLimitFixedWindow(
key: string,
limit: number,
windowSeconds: number
): Promise<boolean> {
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
return count <= limit;
}

Checkout Rate Limiting

export async function POST(req: Request) {
const session = await auth();

// Identify by user ID or IP
const identifier = session?.user?.id
? `user:${session.user.id}`
: `ip:${req.headers.get("x-forwarded-for") || "unknown"}`;

const rateLimitKey = `ratelimit:checkout:${identifier}`;
const count = await redis.incr(rateLimitKey);

if (count === 1) {
await redis.expire(rateLimitKey, 60);
}

if (count > 10) {
return NextResponse.json(
{ error: "Too many attempts. Try again in a minute." },
{ status: 429 }
);
}

// ... continue with checkout
}

Rate Limits

EndpointLimitWindow
Checkout1060s
Register5/IP10min
Login10/IP15min

4E - Order History

Objective

Link orders to authenticated users.

Schema Update

ALTER TABLE orders ADD COLUMN user_id INTEGER REFERENCES users(id);

My Orders API

export async function GET(req: Request) {
const session = await auth();

if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const result = await query(
`
SELECT o.id, o.total, o.status, o.created_at,
json_agg(json_build_object(
'name', p.name,
'quantity', oi.quantity,
'price', oi.price
)) as items
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
LEFT JOIN products p ON oi.product_id = p.id
WHERE o.user_id = $1
GROUP BY o.id
ORDER BY o.created_at DESC
`,
[session.user.id]
);

return NextResponse.json({ orders: result.rows });
}

Guest vs Authenticated Checkout

// Checkout associates user if logged in
const userId = session?.user?.id ? parseInt(session.user.id) : null;

await query(
"INSERT INTO orders (email, total, user_id, ...) VALUES ($1, $2, $3, ...)",
[email, total, userId] // userId is NULL for guests
);
Checkout Typeuser_idVisible in "My Orders"
GuestNULL
AuthenticatedUser ID

Architecture (MVP)

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│ Browser │────▶│ Next.js │────▶│ PostgreSQL │
│ + JWT │ │ + NextAuth │ │ (Neon) │
└─────────────┘ └──────┬──────┘ └─────────────┘
│ │
│ ▼
│ ┌─────────────┐
│ │ Redis │ ← Cache + Rate Limit
│ │ (Upstash) │
│ └─────────────┘


┌─────────────┐
│ Middleware │ ← Route Protection
│ (Edge) │
└─────────────┘

Security Layers

Layer 1: TypeScript      → Compile-time checking
Layer 2: Input Validation → API-level sanitization
Layer 3: Parameterized SQL → Injection prevention
Layer 4: DB Constraints → Data integrity
Layer 5: Middleware → Route protection
Layer 6: Rate Limiting → Abuse prevention
Layer 7: bcrypt → Password security
Layer 8: JWT → Stateless sessions

Files Created

FilePurpose
src/auth.tsNextAuth configuration
src/middleware.tsRoute protection
src/lib/redis.tsRedis client + helpers
src/app/api/auth/register/route.tsUser registration
src/app/api/orders/my-orders/route.tsUser's orders
src/app/auth/signin/page.tsxLogin page
src/types/next-auth.d.tsType extensions

MVP Checklist

  • User can register
  • User can login
  • User can browse products
  • User can add to cart
  • User can checkout (guest or logged in)
  • User can view order history
  • Admin can access admin routes
  • Products are cached
  • Endpoints are rate limited
  • Passwords are hashed
  • Sessions are JWT-based

Next Version Preview

Version 5.0 will add an admin panel, Google OAuth, search functionality, and email notifications.