Contract Lifecycle Management frontend — Next.js 16 App Router, React 19, Tiptap rich-text editing, pdf.js PDF viewer, AI-powered semantic search, and Recharts analytics dashboard.
| Service | URL | Status |
|---|---|---|
| Frontend (Production) | https://verdant-douhua-1148be.netlify.app |
|
| Backend API | https://api.YOUR_DOMAIN.com |
|
| Swagger UI | https://api.YOUR_DOMAIN.com/api/docs/ |
| Repo | Description | Stack |
|---|---|---|
| Frontend (this repo) | Contract editor, PDF viewer, approval UI | Next.js 16 / React 19 |
| Backend | REST API, AI, async tasks, multi-tenancy | Python / Django 5 |
📹 Live Demo Video (Loom) · 📖 Architecture · 🔬 Feature Index · 🐳 Docker Setup
- Overview
- System architecture
- Tech stack
- Key features
- Quick start
- Docker local development
- Environment variables
- CI/CD pipeline
- Testing
- Route structure
- Component examples
- Design decisions
- Production deployment
- Repository structure
- Documentation index
- Contributing
A production-ready Next.js 16 App Router SPA for the full contract lifecycle — contract creation with rich-text editing, PDF viewing and annotation, multi-stage approval workflows, AI-powered semantic search, and a Recharts analytics dashboard — all secured with in-memory JWT auth and connected to a Django REST backend.
What makes this frontend unusual:
| Signal | Detail |
|---|---|
| Next.js 16 + React 19 | Bleeding-edge App Router with React Server Components — most CLM UIs are still on Pages Router |
| Tiptap (ProseMirror) | The only contract editor with first-class Yjs collaborative editing support — built-in upgrade path to multi-party redlining |
| pdf.js + pdf-lib | Client-side PDF rendering and manipulation — annotations, form filling, and redaction without a server round-trip |
| In-memory JWT storage | Tokens never touch localStorage or sessionStorage — cleared on page close, invisible to XSS payloads |
| Static export + SSR parity | One codebase deploys to Cloudflare Pages (air-gapped enterprise) or a Node.js server (standard SaaS) |
╔══════════════════════════════════════════════════════════════════════╗
║ USER / BROWSER ║
╚══════════════════════╤══════════════════════════════════════════════╝
│ HTTPS
▼
╔══════════════════════════════════════════════════════════════════════╗
║ NETLIFY / CDN EDGE ║
║ Static assets (JS/CSS) served from Cloudflare edge ║
║ Dynamic routes proxied to Next.js server (if SSR) ║
╚══════════════════════╤══════════════════════════════════════════════╝
│
▼
╔══════════════════════════════════════════════════════════════════════╗
║ NEXT.JS 16 APP ROUTER (React 19) ║
║ ║
║ ┌──────────────────────────────────────────────────────────────┐ ║
║ │ app/layout.tsx (Root shell) │ ║
║ │ • AuthProvider (in-memory JWT, token manager) │ ║
║ │ • ThemeProvider (dark/light, system preference) │ ║
║ │ • ToastProvider (global notifications) │ ║
║ └──────────────────────────────────────────────────────────────┘ ║
║ ║
║ ┌─────────────────────┐ ┌──────────────────────────────────┐ ║
║ │ Public Routes │ │ Protected Routes (AuthGuard) │ ║
║ │ /login │ │ /dashboard │ ║
║ │ /register │ │ /contracts/[id] │ ║
║ │ /verify-otp │ │ /search │ ║
║ │ /forgot-password │ │ /approvals │ ║
║ │ │ │ /analytics │ ║
║ └─────────────────────┘ │ /templates │ ║
║ │ /calendar │ ║
║ │ /settings │ ║
║ └──────────────────────────────────┘ ║
║ ║
║ ┌──────────────────────────────────────────────────────────────┐ ║
║ │ Shared Component Library (app/components/) │ ║
║ │ Button · Modal · Table · PDFViewer · Tiptap · Recharts │ ║
║ │ SearchBar · ApprovalTimeline · ContractCard · Skeleton │ ║
║ └──────────────────────────────────────────────────────────────┘ ║
║ ║
║ ┌──────────────────────────────────────────────────────────────┐ ║
║ │ Core Library (app/lib/) │ ║
║ │ api-client.ts → fetch wrapper + auto token refresh │ ║
║ │ auth-context.tsx → React Context + in-memory state │ ║
║ │ token-manager.ts → module-level token store (XSS-safe) │ ║
║ │ env.ts → NEXT_PUBLIC_* config normalization │ ║
║ └──────────────────────────────────────────────────────────────┘ ║
╚══════════════════════╤══════════════════════════════════════════════╝
│ HTTPS / REST
▼
╔══════════════════════════════════════════════════════════════════════╗
║ DJANGO REST BACKEND ║
║ JWT Auth · Contracts · AI · Search · Approvals · Audit ║
╚══════════════════════════════════════════════════════════════════════╝
app/layout.tsx (AuthProvider, ThemeProvider, ToastProvider)
│
├── app/(public)/
│ ├── login/page.tsx
│ │ ├── LoginForm
│ │ │ ├── EmailInput
│ │ │ ├── PasswordInput
│ │ │ └── GoogleOAuthButton
│ │ └── OTPVerifyModal
│ │
│ └── register/page.tsx
│ └── RegistrationWizard
│ ├── Step 1: EmailPasswordForm
│ ├── Step 2: OTPVerify
│ └── Step 3: ProfileSetup
│
└── app/(protected)/ ← all wrapped in AuthGuard
│
├── dashboard/page.tsx
│ ├── KPICardGrid (Recharts AreaChart — contract volume)
│ ├── ApprovalPipelineBar (Recharts BarChart — status breakdown)
│ ├── RecentContractsList (Table + ContractStatusBadge)
│ ├── ExpiryCalendarWidget (upcoming renewals)
│ └── ActivityTimeline
│
├── contracts/
│ ├── page.tsx (ContractListView)
│ │ ├── SearchBar + FilterPanel (status, type, date)
│ │ ├── ContractTable
│ │ │ └── ContractRow × n (status badge, version, actions)
│ │ └── Pagination
│ │
│ └── [id]/page.tsx (ContractDetailView)
│ ├── ContractHeader (name, status, parties, versions)
│ ├── TiptapEditor (ProseMirror — rich text contract body)
│ ├── PDFViewer (pdf.js — inline rendering + annotations)
│ ├── AIInsightsPanel (metadata, clauses, risk flags)
│ ├── VersionHistory (diffs, snapshots, restore)
│ └── ApprovalTimeline (status, approvers, comments)
│
├── search/page.tsx
│ ├── SearchInput (debounced, 300ms)
│ ├── SearchModeToggle (semantic / full-text)
│ ├── FacetSidebar (status, type, date, parties)
│ ├── ClauseResultCard × n (similarity score, contract link)
│ └── SavedSearches
│
├── templates/page.tsx
│ ├── TemplateGrid
│ └── TemplateEditor (Tiptap + variable interpolation)
│
├── approvals/page.tsx
│ ├── ApprovalQueue (pending items)
│ └── ApprovalDetailModal (approve / reject + comment)
│
├── analytics/page.tsx
│ ├── ContractVolumeChart (Recharts LineChart — by month)
│ ├── StatusBreakdown (Recharts PieChart)
│ ├── ApprovalTimeMetric (avg days to approval)
│ └── ExpiryForecast (upcoming renewals heatmap)
│
├── calendar/page.tsx (contract milestone calendar)
├── settings/page.tsx (profile, notifications, API keys)
└── admin/page.tsx (admin-only: users, tenants)
User Action (click, type, submit)
│
▼
React Component
│
├── Local state? → useState / useReducer
│ (UI: modal open, form input, tab selection)
│
├── Global auth? → useAuth() → AuthContext
│ (user, isAuthenticated, logout)
│
└── Server data? → apiClient.get() / .post()
│
▼
┌──────────────────────────────┐
│ app/lib/api-client.ts │
│ │
│ 1. Read access token from │
│ tokenManager (memory) │
│ 2. Attach Authorization: │
│ Bearer {token} header │
│ 3. fetch(NEXT_PUBLIC_API_ │
│ BASE_URL + path) │
│ 4. If 401: │
│ a. POST /auth/refresh/ │
│ b. Retry original request│
│ c. If refresh fails: │
│ → logout() │
│ 5. Return typed response │
└──────────────────────────────┘
│
▼
Django REST Backend
(tenant-scoped response)
│
▼
Component re-renders
with fresh server data
┌─────────────────┐
app load │ IDLE │
──────────────► │ (checking...) │
└────────┬────────┘
│
GET /api/auth/me/
│
┌──────────────┴──────────────┐
│ cookie valid │ no cookie / 401
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ AUTHENTICATED │ │ UNAUTHENTICATED │
│ │ │ │
│ user, tenant_id, │ │ redirect → /login │
│ roles in memory │ │ (preserve destination │
│ │ │ in location.state) │
└──────────┬────────────┘ └────────────────────────┘
│
[user submits JWT form]
│
▼
┌───────────────────────┐
│ LOADING │
│ (POST /auth/login/) │
└──────────┬────────────┘
│
┌──────┴──────┐
success failure
│ │
▼ ▼
AUTHENTICATED show error
(redirect toast
to /dashboard)
│
[access token expires]
│
▼
┌───────────────────────┐
│ TOKEN_REFRESH │
│ (POST /auth/refresh/) │
└──────────┬────────────┘
│
┌──────┴──────┐
success failure
│ │
▼ ▼
AUTHENTICATED UNAUTHENTICATED
(transparent (logout +
to user) redirect)
Login success
│
▼
tokenManager.setTokens(access, refresh)
┌────────────────────────────────────┐
│ Module-level memory store │
│ (NOT localStorage, NOT cookie) │
│ │
│ let accessToken: string | null │
│ let refreshToken: string | null │
│ │
│ Cleared on: │
│ • Page close / refresh │
│ • Explicit logout() │
│ • Refresh token expiry (7d) │
└────────────────────────────────────┘
│
│ Every API request
▼
Authorization: Bearer {accessToken}
│
│ access expires (15 min)
▼
apiClient intercepts 401
│
▼
POST /api/auth/refresh/
{ refresh: refreshToken }
│
├── 200 → new access + refresh → retry original request
└── 401 → logout() + redirect /login
Multi-tab sync:
window.addEventListener('auth:tokens', handler)
← dispatched by tokenManager.setTokens()
← all open tabs update their in-memory state simultaneously
User opens contract PDF
│
▼
PDFViewer component mounts
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 1. Fetch presigned R2 URL │
│ GET /api/v1/contracts/{id}/download/ │
│ → { url: "https://pub-HASH.r2.dev/contracts/abc.pdf" } │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 2. pdf.js worker loads PDF │
│ pdfjsLib.getDocument(url) │
│ Worker runs in separate thread (public/pdf.worker.js) │
│ → Does NOT block main thread │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 3. Render pages to Canvas │
│ page.render({ canvasContext, viewport }) │
│ IntersectionObserver → lazy render only visible pages │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 4. Annotation layer (pdf-lib) │
│ • Highlight text selection │
│ • Add comments (stored in backend) │
│ • Sign fields → generate signed PDF bytes │
│ • Export modified PDF → upload to R2 │
└─────────────────────────────────────────────────────────────────────┘
scripts/copy-pdfjs-assets.mjs runs at:
postinstall / predev / prebuild
→ copies pdf.worker.js from node_modules to public/
→ ensures worker URL is served correctly in all environments
| Category | Technology | Version | Why chosen |
|---|---|---|---|
| Framework | Next.js App Router | 16.1 | RSC for read-heavy pages, SSR/static parity |
| UI Library | React | 19 | Concurrent features, Suspense boundaries |
| Language | TypeScript | 5 (strict) | Zero implicit any, exhaustive deps |
| Styling | Tailwind CSS | 3.4 | Utility-first, responsive, dark mode |
| Rich text editor | Tiptap (ProseMirror) | latest | ProseMirror base, Yjs collab-ready, active maintenance |
| PDF rendering | pdf.js | — | Battle-tested, worker-threaded, no server needed |
| PDF manipulation | pdf-lib | — | Client-side annotation, form filling, signing |
| Charts | Recharts | 3.7 | React-native, composable, responsive containers |
| Icons | Lucide React | 0.383 | Tree-shakeable SVG, consistent style |
| HTTP client | Fetch API (custom wrapper) | — | Native, typed, with auto-refresh interceptor |
| Auth state | React Context + module memory | — | No Redux needed; XSS-safe token store |
| Build tool | Vite / webpack (Next default) | — | Fast HMR in dev, SWC in prod |
- Email/password + Google OAuth 2.0 login
- OTP verification for high-security operations
- In-memory token storage — XSS-safe, never in
localStorage - Transparent token refresh — intercepted at the API client layer
- Multi-tab session sync via
auth:tokenscustom event - Protected route middleware with post-login redirect
- Create & edit contracts with full Tiptap rich-text editor
- Template library with variable interpolation and drag-and-drop clause ordering
- PDF viewer — inline rendering, text selection, annotations, signatures
- Version history — immutable snapshot list, side-by-side diff view
- Clause library — searchable, draggable, category-tagged
- Digital signatures — signature field placement + OTP-gated signing flow
- Metadata extraction panel (parties, dates, values, jurisdiction)
- Clause classification chips (payment, liability, IP, NDA)
- Risk analysis dashboard with flag severity levels
- Document summarization — expandable executive summary card
- Similar clause suggestions linked to source contracts
- Semantic search input with live debouncing (300ms)
- Mode toggle: semantic (pgvector cosine) vs full-text (pg_trgm)
- Facet sidebar: status, contract type, date range, parties
- Clause result cards with similarity score bars
- Saved searches with one-click replay
- Contract volume over time (Recharts LineChart)
- Status breakdown (Recharts PieChart)
- Approval cycle time metric (avg days draft → signed)
- Expiry forecast heatmap (upcoming renewals by month)
- Dark mode (system preference + manual toggle)
- Skeleton loading screens (no layout shift)
- Error boundaries with retry — failed sections don't break the page
- Toast notification system with auto-dismiss
- Full keyboard navigation + ARIA labels
- Responsive: mobile → tablet → desktop layouts
git clone https://github.com/vk93102/Contracts-Life-Cycle-Management-Frontend.git
cd Contracts-Life-Cycle-Management-Frontend
# Install dependencies (runs copy-pdfjs-assets.mjs automatically)
npm install
cp .env.local.example .env.local
# Set NEXT_PUBLIC_API_BASE_URL to your backend URL
npm run devOpen http://localhost:3000
See Docker local development for the full stack with backend.
| Command | What it does |
|---|---|
npm run dev |
Start dev server (webpack, HMR) |
npm run build |
Production build |
npm run start |
Run production server |
npm run lint |
ESLint check |
npm test |
Run Vitest test suite |
npm run test:coverage |
Tests + coverage report |
npx tsc --noEmit |
TypeScript type check |
STATIC_EXPORT=1 npm run build |
Build for Cloudflare Pages / CDN |
Run the full stack (Next.js frontend + Django backend + Postgres + Redis) together:
version: '3.9'
services:
frontend:
build:
context: .
dockerfile: Dockerfile
target: development
command: npm run dev
volumes:
- .:/app
- /app/node_modules
- /app/.next
ports:
- "3000:3000"
env_file:
- .env.local
environment:
NEXT_PUBLIC_API_BASE_URL: http://localhost:11000
depends_on:
- backend
backend:
image: clm-backend:latest # build from backend repo first
ports:
- "11000:11000"
env_file:
- .env.backend.local
environment:
SUPABASE_ONLY: "False"
DB_HOST: db
REDIS_URL: redis://redis:6379/0
db:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: clm_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:# ── Base ───────────────────────────────────────────────────────────────
FROM node:20-slim AS base
WORKDIR /app
ENV NODE_ENV=production
# ── Dependencies ───────────────────────────────────────────────────────
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
# ── Development ────────────────────────────────────────────────────────
FROM base AS development
ENV NODE_ENV=development
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
# ── Builder ────────────────────────────────────────────────────────────
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ── Production ─────────────────────────────────────────────────────────
FROM base AS production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Non-root user
RUN addgroup --system nextjs && adduser --system --group nextjs
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]# Start full stack
docker compose up -d
# View frontend logs
docker compose logs -f frontend
# Rebuild after dependency change
docker compose build frontend && docker compose up -d frontend
# Full reset
docker compose down -vCopy .env.local.example to .env.local. All client-side variables must be prefixed NEXT_PUBLIC_.
# ── Backend connection ────────────────────────────────────────────────
# Required — Django/DRF backend base URL
NEXT_PUBLIC_API_BASE_URL=http://localhost:11000
# Legacy alias (still supported)
# NEXT_PUBLIC_API_URL=http://localhost:11000
# ── Google OAuth ──────────────────────────────────────────────────────
# Optional — required only if using Google login
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
# ── Supabase (frontend read-only) ────────────────────────────────────
# Optional — only needed if frontend queries Supabase directly
NEXT_PUBLIC_SUPABASE_URL=https://YOUR_PROJECT.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
# ── Build flags ───────────────────────────────────────────────────────
# Set to 1 to produce a static export for Cloudflare Pages / CDN
# STATIC_EXPORT=1Production environment:
NEXT_PUBLIC_API_BASE_URL=https://api.yourdomain.com
NEXT_PUBLIC_GOOGLE_CLIENT_ID=...
NEXT_PUBLIC_SUPABASE_URL=https://...supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=...TRIGGER: push to master/main/develop OR pull_request to master/main
│
┌───────────┼──────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌──────────────┐
│ LINT │ │ TYPES │ │ BUILD │
│ │ │ │ │ CHECK │
│ eslint │ │ tsc │ │ npm run build│
│ │ │ --noEmit│ │ │
└────┬────┘ └────┬────┘ └──────┬───────┘
└───────────┴──────────────┘
│ all 3 pass
▼
┌───────────────────────────────────────┐
│ TEST JOB │
│ 1. npm ci │
│ 2. node scripts/copy-pdfjs-assets │
│ 3. npm test -- --coverage │
│ 4. upload coverage artifact │
└──────────────────┬────────────────────┘
│ tests pass
▼
┌───────────────────────────────────────┐
│ LIGHTHOUSE CI (main only) │
│ 1. npm run build │
│ 2. lhci autorun │
│ 3. Assert: perf > 85 │
│ a11y > 90, best-practices > 90 │
│ 4. Upload report as artifact │
└──────────────────┬────────────────────┘
│ passes
▼
┌───────────────────────────────────────┐
│ DEPLOY (main only) │
│ Netlify auto-deploys on push │
│ Preview deploys on every PR │
└───────────────────────────────────────┘
name: CI Quality Gate
on:
push:
branches: [master, main, develop]
pull_request:
branches: [master, main]
env:
NODE_VERSION: '20.x'
jobs:
lint:
name: ESLint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: node scripts/copy-pdfjs-assets.mjs
- run: npm run lint
typecheck:
name: TypeScript
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: npx tsc --noEmit
build:
name: Build Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: node scripts/copy-pdfjs-assets.mjs
- name: Production build
run: npm run build
env:
NEXT_PUBLIC_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_API_BASE_URL || 'https://api.example.com' }}
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${{ secrets.NEXT_PUBLIC_GOOGLE_CLIENT_ID || 'placeholder' }}
test:
name: Tests & Coverage
runs-on: ubuntu-latest
needs: [lint, typecheck, build]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: node scripts/copy-pdfjs-assets.mjs
- name: Run tests with coverage
run: npm test -- --coverage --passWithNoTests
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 14
lighthouse:
name: Lighthouse CI
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: node scripts/copy-pdfjs-assets.mjs
- run: npm install -g @lhci/cli
- name: Build for Lighthouse
run: npm run build
env:
NEXT_PUBLIC_API_BASE_URL: https://api.example.com
- name: Run Lighthouse CI
run: lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: lighthouse-report
path: .lighthouseci/
retention-days: 30module.exports = {
ci: {
collect: {
startServerCommand: 'npm run start',
url: ['http://localhost:3000/login'],
numberOfRuns: 3,
},
assert: {
assertions: {
'categories:performance': ['warn', { minScore: 0.85 }],
'categories:accessibility': ['error', { minScore: 0.90 }],
'categories:best-practices': ['error', { minScore: 0.90 }],
'categories:seo': ['warn', { minScore: 0.80 }],
},
},
upload: {
target: 'temporary-public-storage',
},
},
};npm install --save-dev \
vitest \
@vitejs/plugin-react \
@testing-library/react \
@testing-library/user-event \
@testing-library/jest-dom \
jsdomimport { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', '.next/', 'out/', '**/*.config.*', 'scripts/'],
},
},
resolve: {
alias: { '@': path.resolve(__dirname, './') },
},
})import '@testing-library/jest-dom'app/
├── lib/
│ └── __tests__/
│ ├── api-client.test.ts # fetch wrapper, 401 refresh, error handling
│ ├── auth-context.test.tsx # AuthProvider default state, login/logout
│ └── token-manager.test.ts # in-memory store, multi-tab sync
│
└── components/
└── __tests__/
├── Button.test.tsx # render, click, disabled state
├── Modal.test.tsx # open/close, portal, keyboard trap
├── Table.test.tsx # rows, sorting, empty state
└── ContractCard.test.tsx # status badge, version display
// app/lib/__tests__/auth-context.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { AuthProvider, useAuth } from '../auth-context'
function TestComponent() {
const { isAuthenticated, user } = useAuth()
return (
<div>
<span data-testid="auth">{isAuthenticated ? 'in' : 'out'}</span>
<span data-testid="email">{user?.email ?? 'none'}</span>
</div>
)
}
describe('AuthProvider', () => {
it('starts unauthenticated', () => {
render(<AuthProvider><TestComponent /></AuthProvider>)
expect(screen.getByTestId('auth').textContent).toBe('out')
expect(screen.getByTestId('email').textContent).toBe('none')
})
})// app/lib/__tests__/token-manager.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { tokenManager } from '../token-manager'
describe('tokenManager', () => {
beforeEach(() => tokenManager.clearTokens())
it('starts with no tokens', () => {
expect(tokenManager.getAccessToken()).toBeNull()
expect(tokenManager.isAuthenticated()).toBe(false)
})
it('stores and retrieves tokens', () => {
tokenManager.setTokens('access-abc', 'refresh-xyz')
expect(tokenManager.getAccessToken()).toBe('access-abc')
expect(tokenManager.isAuthenticated()).toBe(true)
})
it('clearTokens removes all tokens', () => {
tokenManager.setTokens('a', 'b')
tokenManager.clearTokens()
expect(tokenManager.getAccessToken()).toBeNull()
})
})// app/lib/__tests__/api-client.test.ts
import { describe, it, expect, vi, afterEach } from 'vitest'
import { tokenManager } from '../token-manager'
describe('apiClient', () => {
afterEach(() => vi.restoreAllMocks())
it('attaches Authorization header when token present', async () => {
tokenManager.setTokens('test-access-token', 'refresh')
const spy = vi.spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ results: [] }), { status: 200 })
)
const { apiClient } = await import('../api-client')
await apiClient.get('/api/v1/contracts/')
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/contracts/'),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-access-token',
}),
})
)
})
it('retries with refreshed token on 401', async () => {
tokenManager.setTokens('expired-token', 'valid-refresh')
const fetchSpy = vi.spyOn(global, 'fetch')
.mockResolvedValueOnce(new Response('', { status: 401 }))
.mockResolvedValueOnce(
new Response(JSON.stringify({ access: 'new-token', refresh: 'new-refresh' }), { status: 200 })
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ results: [] }), { status: 200 })
)
const { apiClient } = await import('../api-client')
const result = await apiClient.get('/api/v1/contracts/')
expect(result).toEqual({ results: [] })
expect(fetchSpy).toHaveBeenCalledTimes(3)
})
})# Full suite
npm test
# Watch mode (dev)
npm run test:watch
# With coverage
npm run test:coverage
# Open HTML coverage report
npm run test:coverage && open coverage/index.html/ Landing page
/login Email/password + Google OAuth login
/register Registration wizard (email → OTP → profile)
/verify-otp Standalone OTP verification
/forgot-password Password reset flow
/terms Terms of service
/privacy Privacy policy
/dashboard KPI cards, approval pipeline, recent contracts
/contracts Contract list (paginated, filterable, sortable)
/contracts/[id] Contract detail — editor, PDF viewer, AI insights
/create-contract New contract wizard (template select → editor)
/templates Template library (browse, edit, create)
/search Semantic + full-text search with facets
/approvals Pending approval queue
/calendar Contract milestone calendar
/analytics Charts and metrics dashboard
/settings User profile, notifications, API access
/admin Admin panel (admin role required)
import { apiClient } from '@/lib/api-client'
// GET — automatic auth header + token refresh on 401
const contracts = await apiClient.get<ContractListResponse>('/api/v1/contracts/')
// POST with typed body
const newContract = await apiClient.post<Contract>('/api/v1/contracts/', {
title: 'Service Agreement',
parties: ['Company A', 'Company B'],
status: 'draft',
})
// PATCH
const updated = await apiClient.patch<Contract>(`/api/v1/contracts/${id}/`, {
status: 'submitted',
})
// File upload (multipart/form-data)
const formData = new FormData()
formData.append('file', file)
const uploaded = await apiClient.upload('/api/v1/upload-document/', formData)import { useAuth } from '@/lib/auth-context'
function ContractActions() {
const { user, isAuthenticated, logout } = useAuth()
if (!isAuthenticated) return null
return (
<div>
<span>Signed in as {user.email}</span>
{user.roles.includes('approver') && (
<button>Approve contract</button>
)}
<button onClick={logout}>Sign out</button>
</div>
)
}// app/(protected)/contracts/page.tsx
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth-server'
export default async function ContractsPage() {
const session = await getServerSession()
if (!session) redirect('/login?next=/contracts')
// Fetch on server — zero JS shipped for the data
const contracts = await fetch(`${process.env.API_BASE_URL}/api/v1/contracts/`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store', // always fresh for contract data
}).then(r => r.json())
return <ContractTable initialData={contracts} />
}Contract list and dashboard pages are read-heavy — the data comes from the server and doesn't change on interaction. RSC lets those pages fetch data server-side and ship zero hydration JS for the table rows, significantly improving Time to Interactive on the most-visited routes.
| Decision | Next.js 16 App Router with RSC for data-fetching routes |
| ✅ Zero JS for data | Server-fetched tables ship as static HTML |
| ✅ Improved TtI | No client-side waterfall for initial data load |
| ✅ SSR/static parity | Same codebase works on Node.js server or CDN static export |
| ❌ Complexity | RSC boundary rules require careful 'use client' placement |
| Rejected | Pages Router (legacy, no RSC), Vite SPA (no SSR for data-heavy pages) |
Contract editing requires features that most editors don't support well: collaborative multi-party redlining, complex clause nesting, and extensible marks for clause classification highlights.
| Decision | Tiptap (ProseMirror-based) |
| ✅ Yjs-ready | First-class collaborative editing support — natural upgrade path to multi-party redlining |
| ✅ Actively maintained | Regular releases, TypeScript-native, well-documented extensions |
| ✅ Extensible | Custom marks for clause classification, risk highlights, version diff |
| ❌ Bundle size | Larger than Quill (~100KB gzipped for full feature set) |
| Rejected | Quill (not actively maintained), Draft.js (Meta-abandoned, no ProseMirror schema) |
localStorage is readable by any JavaScript injected via XSS. For a legal document platform where contract content is sensitive, the risk is unacceptable.
| Decision | Module-level JS variables for access + refresh tokens |
| ✅ XSS-safe | Tokens invisible to injected scripts |
| ✅ Cleared on close | Page close naturally expires the session |
| ❌ No persistence | Users re-login after tab close (trade-off accepted for security) |
| Rejected | localStorage (XSS-readable), sessionStorage (still accessible to scripts), httpOnly cookies (requires backend CORS config changes) |
Enterprise CLM customers often require air-gapped or SOC2-audited hosting — a full Node.js server may not be permissible. Static export lets the same codebase deploy to any CDN with no server-side runtime.
| Decision | STATIC_EXPORT=1 flag toggles output: 'export' in next.config.ts |
| CDN-deployable | Works on Cloudflare Pages, Netlify, S3 + CloudFront |
| No server required | Zero Node.js runtime in air-gapped environments |
| No SSR | Dynamic data fetching becomes client-side only |
| Rejected | SSR-only (blocks enterprise air-gapped deployments) |
Netlify auto-deploys from the master branch. Configure in Netlify dashboard:
Build command: npm run build
Publish dir: .next
Node version: 20.x
Environment variables to set in Netlify dashboard:
NEXT_PUBLIC_API_BASE_URLNEXT_PUBLIC_GOOGLE_CLIENT_IDNEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY
npm install -g vercel
vercel login
vercel --prodSTATIC_EXPORT=1 npm run build
# Output: out/ directory
# Deploy to Cloudflare Pages
npx wrangler pages deploy out --project-name clm-frontendSee docs/STATIC_EXPORT.md for full static export documentation.
docker build --target production -t clm-frontend:latest .
docker run -p 3000:3000 \
-e NEXT_PUBLIC_API_BASE_URL=https://api.yourdomain.com \
clm-frontend:latestcurl https://verdant-douhua-1148be.netlify.app/login # → 200
# Verify: login page loads
# Verify: Google OAuth redirect works
# Verify: JWT auth flow end-to-end
# Verify: PDF viewer loads a contract
# Verify: dark mode toggles correctly.
├── app/ # Next.js App Router
│ ├── layout.tsx # Root layout: AuthProvider, ThemeProvider, Toasts
│ ├── page.tsx # Landing page (redirects to /dashboard or /login)
│ │
│ ├── lib/ # Core libraries
│ │ ├── api-client.ts # fetch wrapper: auth headers + 401 retry
│ │ ├── auth-context.tsx # React Context: user, isAuthenticated, logout
│ │ ├── token-manager.ts # In-memory JWT store (XSS-safe)
│ │ └── env.ts # NEXT_PUBLIC_* normalization + validation
│ │
│ ├── components/ # 40+ shared UI components
│ │ ├── Button.tsx
│ │ ├── Modal.tsx
│ │ ├── Table.tsx
│ │ ├── PDFViewer.tsx # pdf.js + pdf-lib integration
│ │ ├── TiptapEditor.tsx # ProseMirror rich-text editor
│ │ ├── ContractCard.tsx
│ │ ├── StatusBadge.tsx
│ │ ├── ApprovalTimeline.tsx
│ │ ├── Skeleton.tsx # Loading state placeholders
│ │ └── ...
│ │
│ ├── (public)/ # Unauthenticated routes
│ │ ├── login/
│ │ ├── register/
│ │ ├── verify-otp/
│ │ └── forgot-password/
│ │
│ └── (protected)/ # AuthGuard-wrapped routes
│ ├── dashboard/
│ ├── contracts/
│ │ └── [id]/
│ ├── create-contract/
│ ├── templates/
│ ├── search/
│ ├── approvals/
│ ├── calendar/
│ ├── analytics/
│ ├── settings/
│ └── admin/
│
├── public/ # Static assets
│ ├── favicon.ico
│ └── pdf.worker.js # pdf.js worker (copied by scripts/)
│
├── docs/ # Documentation
│ ├── ARCHITECTURE.md
│ ├── FEATURES_INDEX.md
│ ├── SETUP.md
│ └── STATIC_EXPORT.md
│
├── scripts/
│ └── copy-pdfjs-assets.mjs # Copies pdf.worker.js to public/ at install time
│
├── .github/
│ ├── workflows/ci.yml # Lint + types + build + test + Lighthouse
│ └── pull_request_template.md
│
├── .env.local.example # Environment variable template
├── next.config.ts # Next.js config (static export toggle)
├── tailwind.config.ts # Tailwind design tokens
├── tsconfig.json # TypeScript strict mode
├── vitest.config.ts # Vitest + jsdom test config
├── vitest.setup.ts # @testing-library/jest-dom setup
├── .lighthouserc.js # Lighthouse CI thresholds
├── eslint.config.mjs # ESLint flat config
└── package.json
| Document | Contents |
|---|---|
docs/ARCHITECTURE.md |
Detailed frontend architecture, data flow, component patterns |
docs/FEATURES_INDEX.md |
Complete implemented feature list + status |
docs/SETUP.md |
Extended local setup guide, troubleshooting |
docs/STATIC_EXPORT.md |
Cloudflare Pages / CDN static export guide |
git checkout -b feat/your-feature # or fix/, chore/, docs/
npm install
# Make changes + add tests
npm run lint
npx tsc --noEmit
npm test
git commit -m "feat: add clause similarity score bar to search results"Commit prefixes: feat: · fix: · chore: · refactor: · test: · docs: · ci: · perf:
PRs require: CI Quality Gate passing (lint + types + build + tests + Lighthouse) + one reviewer approval.
| Problem | Fix |
|---|---|
Module not found errors |
rm -rf node_modules && npm install |
| PDF viewer not working | node scripts/copy-pdfjs-assets.mjs |
| Env vars not loading | Restart dev server after changing .env.local |
| CORS errors | Add frontend URL to backend CORS_ALLOWED_ORIGINS |
| Stale auth state | Clear sessionStorage in DevTools + hard refresh |
| Hot reload not working | rm -rf .next && npm run dev |
| TypeScript errors | npx tsc --noEmit for full report |
CLM Frontend · Next.js 16 · React 19 · TypeScript 5 · Tiptap · pdf.js · Tailwind CSS · Recharts
📹 Demo · 🖥 Live App · 🔧 Backend · 🐛 Issues