27 KiB
Phase 1: Foundation - Research
Researched: 2026-03-19 Domain: Next.js 15 App Router, Auth.js v5 credentials auth, Drizzle ORM + Neon PostgreSQL, Vercel Blob, Vercel deployment Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- Login page: Branded design with Teressa's logo, brand colors, and photo. Route:
/agent/login. Title: "Agent Portal". Generic error on failure: "Invalid email or password". No forgot-password flow. Password visibility toggle included. Post-login redirect:/agent/dashboard. - Session behavior: 7-day rolling session, refreshes on each visit. Persistent across browser restarts (httpOnly cookie, not sessionStorage). On session expiry: silent redirect to
/agent/login. Logout: immediate redirect to/agent/loginwith "You've been signed out" confirmation message. - Database schema: Auth tables only (users + sessions). ORM: Drizzle ORM. Password storage: bcrypt hashing. Migration files committed to
/drizzledirectory. Initial account: seed script from environment variables (no signup UI). Single-agent design — standard users table rows, no multi-tenant columns. - Deployment: Production only at teressacopelandhomes.com. Secrets via Vercel dashboard only (never committed). Vercel Blob: single store, organized by path (
/documents/signed/,/documents/templates/). Deployment trigger: Vercel native Git integration (push to main → auto-deploy).
Claude's Discretion
- Exact brand color palette and visual design details (Teressa's brand assets to be used, layout specifics are open)
- Loading/submitting state on the login form button
- Exact bcrypt salt rounds
- Session token storage implementation details (Auth.js/NextAuth vs custom JWT)
Deferred Ideas (OUT OF SCOPE)
- Forgot password / email reset flow — backlog for a future phase
- Multi-agent support / role-based access — intentionally deferred, single-agent product for now
- Staging or preview environments — production-only for this build </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| AUTH-01 | Agent (Teressa) can log in to the portal with email and password | Auth.js v5 Credentials provider with bcryptjs password comparison; authorize() callback in auth.ts |
| AUTH-02 | Agent session persists across browser refresh and tab closes | Auth.js v5 JWT strategy with maxAge: 7 * 24 * 60 * 60; httpOnly encrypted cookie is the default; no sessionStorage |
| AUTH-03 | All agent portal routes are protected — unauthenticated users are redirected to login | middleware.ts with auth export and matcher: ["/agent/:path*"]; authorized callback redirects to /agent/login |
| AUTH-04 | Agent can log out from any portal page | Auth.js signOut() server action with redirectTo: "/agent/login" and a ?signed_out=1 query param or a dedicated confirmation route |
| </phase_requirements> |
Summary
Phase 1 establishes the entire infrastructure foundation: a Next.js 15 App Router project, Drizzle ORM schema against Neon PostgreSQL, Vercel Blob bucket provisioning, and single-agent authentication via Auth.js v5. Every subsequent phase builds on top of this layer without touching these concerns again.
The authentication stack is well-defined by the user decisions: Auth.js v5 (beta) with the Credentials provider handles email/password login, JWT session strategy stores an encrypted httpOnly cookie, and Next.js middleware protects all /agent/* routes. The Drizzle adapter for Auth.js exists but is only needed if using database sessions — since the decision defers to Claude's discretion, the JWT strategy (no sessions table required, simpler schema) is recommended and matches the rolling-session requirement without database writes per request.
The Neon + Drizzle + Vercel deployment triplet is a first-class supported combination with official documentation, making this phase a well-trodden path in 2026. The main risk areas are: Auth.js v5 is still in beta (use next-auth@beta, pin a specific beta version), the signOut server action has known quirks in Next.js 15 that must be handled correctly, and the Vercel Blob bucket must be created and linked to the project before environment variables are available.
Primary recommendation: Use Auth.js v5 (beta) with JWT strategy + Credentials provider. Skip the Drizzle adapter (no sessions table needed). Keep the schema minimal: one users table with id, email, password_hash, created_at. Run one migration, seed Teressa's account, deploy to Vercel, wire env vars.
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| next | 15.x (latest) | Full-stack React framework | Locked by client; App Router is the current default |
| next-auth | 5.0.0-beta (pin latest beta) | Authentication — credentials, session, middleware | Official Next.js auth solution; only option with full App Router support |
| drizzle-orm | ^0.40+ (latest stable) | TypeScript-native ORM for Neon | TypeScript-first, excellent Next.js + Neon support, locked by user |
| drizzle-kit | ^0.30+ (latest stable) | Migration generation and running | Required companion to drizzle-orm; locked by user |
| @neondatabase/serverless | ^0.10+ | Neon HTTP driver for serverless | Required for Vercel serverless functions — uses HTTP not persistent TCP |
| bcryptjs | ^2.4.3 | Password hashing | Pure JS (no native binaries), works in all serverless environments |
| @vercel/blob | ^0.27+ | File/blob storage | Locked by user decision; single store for all documents |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| @types/bcryptjs | ^2.4.x | TypeScript types for bcryptjs | Development dependency — required for TypeScript projects |
| tsx | ^4.x | Run TypeScript scripts directly | Used to run the seed script (drizzle:seed) |
| zod | ^3.x | Runtime input validation | Validate login form credentials in authorize() callback |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| Auth.js v5 JWT strategy | Database session strategy | Database strategy requires a sessions table and a write on every visit; JWT is simpler for single-user and satisfies the rolling-session requirement with updateAge |
| bcryptjs | bcrypt (native) | Native bcrypt is faster but requires C++ compiler — fails in some Vercel builds; bcryptjs is pure JS and always works |
| Auth.js v5 | Custom JWT + iron-session | Auth.js handles the hard parts (cookie encryption, session refresh, middleware integration); custom is more control but more bugs |
Installation:
npx create-next-app@latest teressa-copeland-homes --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd teressa-copeland-homes
npm install next-auth@beta
npm install drizzle-orm @neondatabase/serverless
npm install bcryptjs @vercel/blob
npm install -D drizzle-kit tsx @types/bcryptjs
Architecture Patterns
Recommended Project Structure
src/
├── app/
│ ├── agent/
│ │ ├── login/
│ │ │ └── page.tsx # Login page (branded, password toggle, error display)
│ │ └── dashboard/
│ │ └── page.tsx # Post-login landing (blank in Phase 1)
│ ├── api/
│ │ └── auth/
│ │ └── [...nextauth]/
│ │ └── route.ts # Auth.js handler export
│ ├── layout.tsx # Root layout
│ └── page.tsx # Public homepage (placeholder in Phase 1)
├── lib/
│ ├── auth.ts # Auth.js configuration (providers, callbacks, session)
│ └── db/
│ ├── index.ts # Drizzle client initialization
│ └── schema.ts # Database schema (users table only in Phase 1)
├── scripts/
│ └── seed.ts # One-time seed script to create Teressa's account
drizzle/ # Migration files (committed to repo per user decision)
drizzle.config.ts # Drizzle Kit configuration
middleware.ts # Route protection (protects /agent/* routes)
Pattern 1: Auth.js v5 Configuration (JWT Strategy)
What: Central auth configuration that exports handlers, auth, signIn, signOut. JWT strategy with 7-day rolling expiry. httpOnly encrypted cookie by default.
When to use: Phase 1 and every subsequent phase that needs to verify the session.
Example:
// src/lib/auth.ts
// Source: https://authjs.dev/reference/nextjs
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
session: {
strategy: "jwt",
maxAge: 7 * 24 * 60 * 60, // 7-day rolling expiry
updateAge: 24 * 60 * 60, // Refresh cookie once per day (not on every request)
},
pages: {
signIn: "/agent/login",
},
providers: [
Credentials({
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null;
const [user] = await db
.select()
.from(users)
.where(eq(users.email, parsed.data.email))
.limit(1);
if (!user) return null;
const passwordMatch = await bcrypt.compare(
parsed.data.password,
user.passwordHash
);
if (!passwordMatch) return null;
return { id: user.id, email: user.email };
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id;
return token;
},
session({ session, token }) {
session.user.id = token.id as string;
return session;
},
},
});
Pattern 2: Middleware Route Protection
What: Protect all /agent/* routes at the edge. Unauthenticated requests redirect to /agent/login. Does NOT run on static assets or API routes.
When to use: Phase 1 only (this file never needs modification in later phases unless new protected route prefixes are added).
Example:
// middleware.ts (project root, NOT inside src/)
// Source: https://authjs.dev/getting-started/session-management/protecting
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAgentRoute = req.nextUrl.pathname.startsWith("/agent");
const isLoginPage = req.nextUrl.pathname === "/agent/login";
if (isAgentRoute && !isLoginPage && !isLoggedIn) {
return NextResponse.redirect(new URL("/agent/login", req.nextUrl.origin));
}
// Redirect already-logged-in users away from login page
if (isLoginPage && isLoggedIn) {
return NextResponse.redirect(new URL("/agent/dashboard", req.nextUrl.origin));
}
});
export const config = {
matcher: [
// Match /agent/* but exclude static files and Next.js internals
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};
Pattern 3: Drizzle Client + Schema
What: Serverless-safe Drizzle client using Neon HTTP driver. Users table only in Phase 1.
When to use: Import db from @/lib/db in any server component, route handler, or server action.
Example:
// src/lib/db/index.ts
// Source: https://neon.com/docs/guides/drizzle
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle({ client: sql, schema });
// src/lib/db/schema.ts
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { createId } from "@paralleldrive/cuid2"; // or use crypto.randomUUID()
export const users = pgTable("users", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
Pattern 4: Login Form (Server Action)
What: Login form that calls the Auth.js signIn server action. Displays generic error message. Password toggle for UX.
When to use: /agent/login/page.tsx
Example:
// src/app/agent/login/page.tsx (simplified structure)
import { signIn } from "@/lib/auth";
import { AuthError } from "next-auth";
import { redirect } from "next/navigation";
async function loginAction(formData: FormData) {
"use server";
try {
await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirectTo: "/agent/dashboard",
});
} catch (error) {
if (error instanceof AuthError) {
// Return to login with error flag — do NOT re-throw
redirect("/agent/login?error=invalid");
}
throw error; // Re-throw redirects (NEXT_REDIRECT must bubble up)
}
}
Pattern 5: Sign Out Server Action
What: Logout from any portal page. Redirects to /agent/login with a confirmation message.
When to use: Logout button in the portal header/nav.
Example:
// In a portal layout or header component
import { signOut } from "@/lib/auth";
async function logoutAction() {
"use server";
await signOut({ redirectTo: "/agent/login?signed_out=1" });
}
// On /agent/login page, check for ?signed_out=1 and show the message:
// "You've been signed out."
Pattern 6: Drizzle Migration + Seed Script
What: Generate migration files and seed Teressa's account from environment variables. When to use: Once during Phase 1 setup; migrations added in future phases for new tables. Example:
// scripts/seed.ts
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import { users } from "../src/lib/db/schema";
import bcrypt from "bcryptjs";
const sql = neon(process.env.DATABASE_URL!);
const db = drizzle({ client: sql });
async function seed() {
const email = process.env.AGENT_EMAIL;
const password = process.env.AGENT_PASSWORD;
if (!email || !password) {
throw new Error("AGENT_EMAIL and AGENT_PASSWORD env vars required");
}
const passwordHash = await bcrypt.hash(password, 12);
await db.insert(users).values({ email, passwordHash })
.onConflictDoNothing(); // Safe to re-run
console.log(`Seeded agent account: ${email}`);
process.exit(0);
}
seed().catch((err) => {
console.error(err);
process.exit(1);
});
// package.json scripts section additions:
{
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "tsx scripts/seed.ts",
"db:studio": "drizzle-kit studio"
}
Anti-Patterns to Avoid
- Wrapping
signIn/signOutin try/catch without re-throwing: Auth.js usesNEXT_REDIRECTas a control-flow mechanism. Catching it silently swallows the redirect. Only catchAuthError— everything else must be re-thrown. - Putting
middleware.tsinsidesrc/: Next.js middleware must live at the project root (same level aspackage.json), not insidesrc/. - Using
@auth/drizzle-adapterwhen using JWT strategy: The Drizzle adapter is only needed for database session strategy. JWT strategy does not require an adapter or a sessions table. - Exposing
BLOB_READ_WRITE_TOKENto client components: Always call Vercel Blob from server actions or API routes. Never import from client components. - Using
bcrypt(native) instead ofbcryptjs: Native bcrypt requires C++ compilation which can fail on Vercel's build system. Always usebcryptjsfor serverless. - Storing
DATABASE_URLorAUTH_SECRETin.envcommitted to repo: All secrets go through Vercel dashboard only, per user decision.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Session management | Custom JWT encode/decode/cookie logic | Auth.js v5 | Cookie rotation, encryption key management, expiry handling, CSRF protection — all handled; custom implementation has attack vectors |
| Password hashing | Custom hash functions, MD5, SHA-256 alone | bcryptjs | bcrypt is adaptive (salt rounds increase over time), includes automatic salting, designed for passwords specifically |
| Route protection | Manual session checks in every layout/page | Auth.js middleware + auth() in layouts |
Consistent behavior; middleware runs before page renders so no flash of protected content |
| Database migrations | Manual SQL files, ad-hoc ALTER TABLE | Drizzle Kit generate + migrate | Generates SQL from TypeScript schema diff; migration files are version-controlled |
| Blob URL generation | Custom file storage URLs | Vercel Blob put() |
CDN distribution, globally cached, secure access controls built in |
Key insight: The entire auth domain (sessions, cookies, token rotation, CSRF) has hundreds of edge cases. Auth.js handles them correctly by default — do not re-implement.
Common Pitfalls
Pitfall 1: NEXT_REDIRECT Swallowed in Try/Catch
What goes wrong: signIn() throws a NEXT_REDIRECT error to perform the redirect after successful login. If wrapped in a catch-all try/catch, the redirect never fires — the form appears to do nothing after a correct password.
Why it happens: Next.js uses thrown errors (not return values) for redirects and notFound. AuthError is a real error; NEXT_REDIRECT is a control-flow mechanism masquerading as an error.
How to avoid: Only catch instanceof AuthError. Re-throw everything else.
Warning signs: Login with correct credentials submits but nothing happens; no redirect occurs.
Pitfall 2: Auth.js v5 Beta Version Instability
What goes wrong: next-auth@beta is not a stable release. Minor beta updates have broken Credentials provider behavior, session callbacks, and Next.js 16 compatibility.
Why it happens: v5 is actively developed; API surface changes between betas.
How to avoid: Pin to a specific beta version (e.g., next-auth@5.0.0-beta.25). Test after upgrading. Check the releases page for breaking changes before updating.
Warning signs: Login stops working after npm update; TypeScript types change.
Pitfall 3: Middleware.ts Location
What goes wrong: Placing middleware.ts inside src/ causes it to be ignored entirely — no route protection, /agent/dashboard is publicly accessible.
Why it happens: Next.js middleware must be at the root of the project (same level as package.json and next.config.ts), not inside src/.
How to avoid: Always create middleware.ts at the project root, not src/middleware.ts.
Warning signs: Protected routes are accessible without login; no redirects occur.
Pitfall 4: DATABASE_URL Not Loaded in Production
What goes wrong: Neon connection fails in production with "DATABASE_URL is not set" because the env var was only added to .env.local and not to Vercel dashboard.
Why it happens: .env.local is local-only. Vercel requires environment variables set in the dashboard (or via vercel env add).
How to avoid: Add DATABASE_URL, AUTH_SECRET, AGENT_EMAIL, AGENT_PASSWORD, and BLOB_READ_WRITE_TOKEN to the Vercel dashboard before first deploy. Run vercel env pull locally.
Warning signs: Production site works for static pages but fails on any DB-dependent route; Vercel function logs show missing env var.
Pitfall 5: Drizzle Client Re-initialized on Every Serverless Invocation
What goes wrong: Neon connection pool exhaustion in development (not production with HTTP driver, but can affect local PostgreSQL testing).
Why it happens: Each serverless function invocation creates a new connection if the db client is not cached.
How to avoid: Use a module-level singleton in src/lib/db/index.ts — const sql = neon(...) at module scope. The Neon HTTP driver is stateless per-request anyway, but the pattern prevents future issues with database adapters.
Warning signs: "too many connections" errors in Neon console.
Pitfall 6: Logout Redirect Without Signed-Out Message
What goes wrong: signOut({ redirectTo: "/agent/login" }) redirects cleanly but shows no confirmation message. The "You've been signed out" message (user requirement) is lost.
Why it happens: Auth.js v5 signOut does not pass flash messages; there is no built-in toast/flash system.
How to avoid: Pass a query parameter: signOut({ redirectTo: "/agent/login?signed_out=1" }). On the login page, read searchParams.signed_out and render the confirmation message conditionally.
Warning signs: Login page shows no confirmation text after logout even though redirect worked.
Code Examples
Verified patterns from official sources:
Drizzle Config (drizzle.config.ts)
// Source: https://neon.com/docs/guides/drizzle
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/lib/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
API Route Handler for Auth.js
// src/app/api/auth/[...nextauth]/route.ts
// Source: https://authjs.dev/reference/nextjs
export { GET, POST } from "@/lib/auth";
// (auth.ts exports `handlers`, and { handlers: { GET, POST } } is de-structured here)
// More explicitly:
// import { handlers } from "@/lib/auth";
// export const { GET, POST } = handlers;
Vercel Blob Upload (Server Action)
// Source: https://vercel.com/docs/vercel-blob/using-blob-sdk
"use server";
import { put } from "@vercel/blob";
export async function uploadDocument(formData: FormData) {
const file = formData.get("file") as File;
const blob = await put(`documents/templates/${file.name}`, file, {
access: "private", // Documents are never publicly accessible (LEGAL-03)
});
return blob.url;
}
Session Check in Server Component
// Source: https://authjs.dev/reference/nextjs
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session) redirect("/agent/login"); // Defense-in-depth beyond middleware
return <div>Welcome, {session.user?.email}</div>;
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
NextAuth v4 (next-auth@4) |
Auth.js v5 (next-auth@beta) |
2024-2025 | New unified API across frameworks; App Router native; single auth() function replaces getServerSession() |
pages/api/auth/[...nextauth].ts |
app/api/auth/[...nextauth]/route.ts |
Next.js 13+ App Router | File location changes; export pattern changes |
getServerSideProps + getSession |
await auth() in Server Components |
Next.js 13+ | No more getServerSideProps; direct async/await in RSC |
| Prisma ORM | Drizzle ORM (preferred for new projects) | 2023-2024 | Drizzle is TypeScript-first, lighter, and has better Neon serverless support |
next-auth@4 database sessions |
JWT sessions (v5 default) | v5 | Database sessions now require explicit adapter; JWT is simpler for single-user apps |
Deprecated/outdated:
getServerSession(authOptions): replaced byawait auth()in Auth.js v5pages/api/auth/[...nextauth].js: replaced by App Router route handler@auth/prisma-adapterfor this project: not needed — using JWT strategy, not database sessionsprocess.env.NEXTAUTH_SECRET: renamed toAUTH_SECRETin v5; both work but v5 usesAUTH_prefix
Open Questions
-
Auth.js v5 exact beta version to pin
- What we know:
next-auth@betapoints to latest beta; Next.js 16 has a known signIn server action bug with beta.30 - What's unclear: Which beta is most stable with Next.js 15 specifically
- Recommendation: Install
next-auth@betainitially, note the exact version installed (npm ls next-auth), pin it inpackage.json. Test login before declaring Phase 1 complete.
- What we know:
-
Vercel Blob store creation timing
- What we know: Store must be created before
BLOB_READ_WRITE_TOKENis available; Phase 1 only provisions it, not uses it - What's unclear: Whether the planner should treat blob provisioning as a separate task or bundled with deployment setup
- Recommendation: Make blob store creation a distinct task in Wave 1 (infrastructure setup) even though it's not exercised until Phase 5.
- What we know: Store must be created before
-
Teressa's brand assets location
- What we know: Agent photo is at
/Users/ccopeland/Downloads/red.jpg; brand colors and logo not yet confirmed - What's unclear: Whether brand assets will be provided during Phase 1 or if a placeholder design is acceptable
- Recommendation: Build login page with placeholder brand vars (CSS custom properties for colors) so they can be swapped without code changes. Use the photo from the known path.
- What we know: Agent photo is at
Sources
Primary (HIGH confidence)
- authjs.dev/reference/nextjs — Auth.js v5 Next.js setup, session strategies, middleware pattern
- authjs.dev/getting-started/session-management/protecting — Route protection with
authorizedcallback and matcher - authjs.dev/concepts/session-strategies — JWT vs database session strategies,
maxAge,updateAge - neon.com/docs/guides/drizzle — Neon + Drizzle setup, serverless HTTP driver
- neon.com/docs/guides/drizzle-migrations — Migration workflow with drizzle-kit
- vercel.com/docs/vercel-blob — Vercel Blob SDK,
BLOB_READ_WRITE_TOKEN, store creation - npmjs.com/package/bcryptjs — bcryptjs pure JS implementation
Secondary (MEDIUM confidence)
- workos.com/blog/nextjs-app-router-authentication-guide-2026 — Comprehensive 2026 guide; verified against official Auth.js docs
- nextjs.org/docs/app/getting-started/installation — create-next-app scaffolding options
- strapi.io/blog/how-to-use-drizzle-orm-with-postgresql-in-a-nextjs-15-project — Drizzle + Next.js 15 setup tutorial
Tertiary (LOW confidence — verify during implementation)
- Auth.js v5 beta.25 being "most stable" for Next.js 15 — from community discussion, not official docs; pin and test
signOutUI refresh bug requiringwindow.location.reload()— reported in GitHub discussions, not documented officially
Metadata
Confidence breakdown:
- Standard stack: HIGH — all packages verified via official docs and npm; versions are current
- Architecture: HIGH — file locations and export patterns verified against Auth.js v5 and Next.js docs
- Pitfalls: MEDIUM-HIGH — most from official docs or verified GitHub issues; NEXT_REDIRECT behavior confirmed in Auth.js docs
Research date: 2026-03-19 Valid until: 2026-04-18 (30 days — Auth.js beta moves fast; re-verify if planning is delayed more than 2 weeks)