Files
red/.planning/research/STACK.md
2026-03-19 11:50:51 -06:00

23 KiB

Stack Research

Domain: Real estate agent website + PDF document signing web app Researched: 2026-03-19 Confidence: HIGH (versions verified via npm/GitHub; integration strategies based on official docs)


Core Technologies

Technology Version Purpose Why Recommended
Next.js 15.5.x (use 15, not 16 — see note) Full-stack framework App Router + Server Components + API routes in one repo; Vercel-native; 15.5 is current LTS-equivalent with stable Node.js middleware
React 19.x UI Ships with Next.js 15; Server Components, use(), form actions all stable
TypeScript 5.x Type safety Required; Prisma, Auth.js, pdf-lib all ship full types
PostgreSQL (Neon) latest (PG 16) Data persistence Serverless-compatible, scales to zero, free tier generous, branches per PR — perfect for solo agent on Vercel
Prisma ORM 6.x (6.19+) Database access Best-in-class DX for TypeScript; schema migrations; works with Neon via @neondatabase/serverless driver

Note on Next.js version: Next.js 16 is now available but introduced several breaking changes (sync APIs fully removed, middleware renamed to proxy, edge runtime dropped from proxy). Use Next.js 15.5.x for this project to avoid churn — it has stable Node.js middleware, stable Turbopack builds in beta, and typed routes. Upgrade to 16 after it stabilizes (6-12 months).


PDF Processing Libraries

Three separate libraries serve three distinct roles. You need all three.

Library Version Purpose When to Use
pdfjs-dist 5.5.x (5.5.207 current) Render PDFs in browser; detect existing AcroForm fields Use on the client side to display the imported PDF and to iterate over existing form field annotations so you can find where signature areas already exist
pdf-lib 1.17.1 (original) OR @pdfme/pdf-lib 5.5.x (actively maintained fork) Modify PDFs server-side: fill text fields, embed signature images, flatten Use on the server (API route / Server Action) to fill form fields, embed the canvas signature PNG, and produce the final signed PDF. Use the @pdfme/pdf-lib fork — the original has been unmaintained for 4 years
react-pdf (wojtekmaj) latest (wraps pdfjs-dist) React component wrapper for PDF display Use in the client-side signing UI to render the document page-by-page with minimal setup; falls back to pdfjs-dist for custom rendering needs

Critical distinction:

  • pdfjs-dist = rendering/viewing only — cannot modify PDFs
  • pdf-lib / @pdfme/pdf-lib = modifying/creating PDFs — cannot render to screen
  • You must use both together for this workflow

Supporting Libraries

Library Version Purpose When to Use
better-auth 1.5.x (1.5.5 current) Agent authentication Single-agent portal; credentials auth (email+password) built in; rate limiting and session management out of the box; no per-MAU cost; first-class Next.js App Router support
resend + @react-email/components 6.9.x / latest Email delivery for signing links Resend is the modern Nodemailer replacement; generous free tier (3k/mo), React-based email templates, dead simple API route integration
signature_pad 5.1.3 Canvas-based signature drawing Core signature capture library; HTML5 canvas, Bezier curve smoothing, works on touch/mouse; use directly (not the React wrapper) so you control the ref and can export PNG
react-signature-canvas 1.1.0-alpha.2 React wrapper around signature_pad Optional convenience wrapper if you prefer JSX integration; note: alpha version — prefer using signature_pad directly with a useRef
@vercel/blob latest PDF file storage Zero-config for Vercel deploys; S3-backed (99.999999999% durability); PDFs are not large enough to hit egress cost issues for a solo agent; avoid vendor lock-in concern by abstracting behind a storage.ts service module
playwright 1.58.x utahrealestate.com forms library scraping; credentials-based login Only option for sites requiring JS execution + session cookies; multi-browser, auto-wait, built-in proxy support
zod 3.x Request/form validation Used with Server Actions and API routes; integrates with Prisma and better-auth
nanoid 5.x Generating secure signing link tokens Cryptographically secure, URL-safe, short IDs for signing request URLs
sharp 0.33.x Image optimization if needed Only needed if resizing/converting signature images before PDF embedding

Development Tools

Tool Purpose Notes
tailwindcss v4 Styling v4 released 2025; CSS-native config, no tailwind.config.js required; significantly faster
shadcn/ui Component library Copies components into your repo (not a dependency); works with Tailwind v4 and React 19; perfect for the agent portal UI
ESLint 9 + @typescript-eslint Linting Next.js 15 ships ESLint 9 support
Prettier Formatting Standard
prisma studio Database GUI Built into Prisma; npx prisma studio
@next/bundle-analyzer Bundle analysis Experimental in Next.js 16.1 but available as standalone package for 15
Turbopack Dev server Enabled by default in Next.js 15 (next dev --turbo); production builds still use Webpack in 15.x

Installation

# Core framework
npm install next@^15.5 react@^19 react-dom@^19

# Database
npm install prisma @prisma/client @neondatabase/serverless

# Authentication
npm install better-auth

# PDF processing
npm install @pdfme/pdf-lib pdfjs-dist react-pdf

# E-signature (canvas)
npm install signature_pad

# Email
npm install resend @react-email/components react-email

# File storage
npm install @vercel/blob

# Scraping (for utahrealestate.com forms + listings)
npm install playwright

# Utilities
npm install zod nanoid

# Dev dependencies
npm install -D typescript @types/node @types/react @types/react-dom
npm install -D tailwindcss @tailwindcss/postcss postcss
npm install -D eslint eslint-config-next @typescript-eslint/eslint-plugin prettier
npx prisma init
npx playwright install chromium

Alternatives Considered

Recommended Alternative Why Not
better-auth Clerk Clerk costs per MAU; overkill for one agent; hosted user data is unnecessary complexity for a single known user
better-auth Auth.js v5 (NextAuth) Auth.js v5 remains in beta; better-auth has the same open-source no-lock-in benefit but with built-in rate limiting and MFA. Notably, Auth.js is merging with better-auth team
@pdfme/pdf-lib original pdf-lib Original pdf-lib (v1.17.1) last published 4 years ago; @pdfme/pdf-lib fork is actively maintained with bug fixes
resend Nodemailer + SMTP Nodemailer requires managing an SMTP server or credentials; Resend has better deliverability, a dashboard, and React template support
Neon PostgreSQL PlanetScale / Supabase PlanetScale killed its free tier; Supabase is excellent but heavier than needed; Neon's Vercel integration and branching per PR is the best solo developer experience
Neon PostgreSQL SQLite (local) SQLite doesn't work on Vercel serverless; fine for local dev but you'd need to swap before deploying — adds friction. Neon's free tier makes this unnecessary
playwright Puppeteer Playwright has better auto-wait, multi-browser, and built-in proxy support; more actively maintained for 2025 use cases
playwright Cheerio utahrealestate.com requires authentication (login session) and renders content with JavaScript; Cheerio only parses static HTML
@vercel/blob AWS S3 S3 requires IAM setup, bucket policies, and CORS config; Vercel Blob is zero-config for Vercel deploys and S3-backed under the hood. Abstract it behind a service module so you can swap later
signature_pad react-signature-canvas The React wrapper is at 1.1.0-alpha.2 — alpha status; better to use the underlying signature_pad@5.1.3 directly with a useRef canvas hook

What NOT to Use

Avoid Why Use Instead
DocuSign / HelloSign Monthly subscription cost for a solo agent; defeats the purpose of a custom tool Custom signature_pad canvas capture embedded via @pdfme/pdf-lib
Apryse WebViewer / Nutrient SDK Enterprise pricing ($$$); overkill; hides the PDF internals you need to control pdfjs-dist (render) + @pdfme/pdf-lib (modify)
jspdf Creates PDFs from scratch via HTML canvas; cannot modify existing PDFs @pdfme/pdf-lib for modification
pdfmake Same limitation as jspdf; generates new PDFs, can't edit existing form fields @pdfme/pdf-lib
Firebase / Supabase Storage Additional vendor; Vercel Blob is already in the stack @vercel/blob
Clerk Per-MAU pricing; vendor-hosted user data; one agent user doesn't need this complexity better-auth
Next.js 16 (right now) Too many breaking changes (async APIs fully enforced, middleware renamed); ecosystem compatibility issues Next.js 15.5.x
WebSockets for signing Overkill; the signing flow is a one-shot action, not a live collaboration session Server Actions + polling or simple page refresh

PDF Processing Strategy

Overview

The workflow has five distinct stages, each using different tools:

[Import PDF] → [Detect fields] → [Add signature areas] → [Fill + sign] → [Store signed PDF]
   Playwright     pdfjs-dist        @pdfme/pdf-lib          @pdfme/pdf-lib    @vercel/blob

Stage 1: Importing PDFs from utahrealestate.com

The forms library on utahrealestate.com requires agent login. Use Playwright on the server (a Next.js API route or background job) to:

  1. Authenticate with the agent's saved credentials (stored encrypted in the DB)
  2. Navigate to the forms library
  3. Download the target PDF as a Buffer
  4. Store the original PDF in Vercel Blob under a UUID-based path
  5. Record the document in the database with its Blob URL, filename, and source metadata

Playwright should run in a separate process or as a scheduled job (Vercel Cron), not inline with a user request, because browser startup is slow.

Stage 2: Detecting Existing Form Fields

Once the PDF is stored, use pdfjs-dist in a server-side script (Node.js API route) to:

  1. Load the PDF from Blob storage
  2. Iterate over each page's annotations
  3. Find Widget annotations (AcroForm fields: text inputs, checkboxes, signature fields)
  4. Record each field's name, type, and bounding box (x, y, width, height, page number)
  5. Store this field map in the database (DocumentField table)

Utah real estate forms from WFRMLS are typically standard AcroForm PDFs with pre-defined fields. Most will have existing form fields you can fill directly.

Stage 3: Adding Signature Areas

For pages that lack signature fields (or when the agent wants to add a new signature area):

  • Use @pdfme/pdf-lib server-side to add a new AcroForm signature annotation at a specified bounding box
  • Alternatively, track "signature zones" as metadata in your database (coordinates + page) and overlay them on the rendering side — this avoids PDF modification until signing time

The simpler approach: store signature zones as coordinate records in the DB, render them as highlighted overlay boxes in the browser using react-pdf, and embed the actual signature image at those coordinates only at signing time.

Stage 4: Filling Text Fields + Embedding Signature

When the agent fills out a document form (pre-filling client info, property address, etc.):

  1. Send field values to a Server Action
  2. Server Action loads the PDF from Blob
  3. Use @pdfme/pdf-lib to:
    • Get the AcroForm from the PDF
    • Set text field values: form.getTextField('BuyerName').setText(value)
    • Embed the signature PNG: convert the canvas toDataURL() PNG to a Uint8Array, embed as PDFImage, draw it at the signature zone coordinates
    • Optionally flatten the form (make fields non-editable) before final storage
  4. Save the modified PDF bytes back to Blob as a new file (preserve the unsigned original)

Stage 5: Storing Signed PDFs

Store three versions in Vercel Blob:

  • /documents/{id}/original.pdf — the untouched import
  • /documents/{id}/prepared.pdf — fields filled, ready to sign
  • /documents/{id}/signed.pdf — final document with embedded signature

All three paths recorded in the Document table. Serve signed PDFs via signed Blob URLs with short expiry (1 hour) to prevent unauthorized access.


Under the ESIGN Act (federal) and UETA (47 states), an electronic signature is legally valid when it demonstrates: intent to sign, consent to transact electronically, association of the signature with the record, and reliable record retention.

What to Capture at Signing Time

Store all of the following in an AuditEvent table linked to the SigningRequest:

Data Point How to Capture Legal Purpose
Signer's IP address request.headers.get('x-forwarded-for') in API route Attribution — links signature to a network location
Timestamp (UTC) new Date().toISOString() server-side Proves the signature occurred at a specific time
User-Agent string request.headers.get('user-agent') Device/browser fingerprint
Consent acknowledgment Require checkbox click: "I agree to sign electronically" Explicit ESIGN/UETA consent requirement
Document hash (pre-sign) crypto.subtle.digest('SHA-256', pdfBytes) Proves document was not altered before signing
Document hash (post-sign) Same, after embedding signature Proves final document integrity
Signing link token The nanoid-generated token used to access the signing page Ties the signer to the specific invitation
Email used for invitation From the SigningRequest record Identity association

Signing Audit Trail Schema (minimal)

// Prisma model
model AuditEvent {
  id              String   @id @default(cuid())
  signingRequestId String
  signingRequest  SigningRequest @relation(fields: [signingRequestId], references: [id])
  eventType       String   // "viewed" | "consent_given" | "signed" | "downloaded"
  ipAddress       String
  userAgent       String
  timestamp       DateTime @default(now())
  metadata        Json?    // document hashes, page count, etc.
}

Signing Page Flow

  1. Client opens the email link containing a nanoid token
  2. Server validates the token (not expired, not already used)
  3. Record "viewed" audit event (IP, timestamp, UA)
  4. Client sees a consent banner: "By signing, you agree to execute this document electronically under the ESIGN Act."
  5. Client checks consent checkbox — record "consent_given" audit event
  6. Client draws signature on canvas
  7. On submit: POST to API route with canvas PNG data URL
  8. Server records "signed" audit event, embeds signature into PDF, stores signed PDF
  9. Mark signing request as complete; email confirmation to both agent and client

What This Does NOT Cover

This custom implementation is sufficient for standard real estate transactions in Utah under UETA. However:

  • It does NOT provide notarization (RON — Remote Online Notarization is a separate regulated process)
  • It does NOT provide RFC 3161 trusted timestamps (requires a TSA — unnecessary for most residential RE transactions)
  • For purchase agreements and disclosures (standard Utah REPC forms), this level of e-signature is accepted by WFRMLS and Utah law

utahrealestate.com Integration Strategy

Two Separate Use Cases

Use Case 1: Forms Library (PDF import) The forms library requires agent login. There is no documented public API for downloading forms. Strategy:

  1. Store the agent's utahrealestate.com credentials encrypted in the database (use bcrypt or AES-256-GCM encryption with a server-side secret key — not bcrypt since you need to recover the plaintext)
  2. Use Playwright to authenticate: navigate to the login page, fill credentials, submit, wait for session cookies
  3. Navigate to the forms section, find the desired form by name/category, download the PDF
  4. This is fragile (subject to site redesigns) but unavoidable without official API access
  5. Implement with a health check: if Playwright fails to find expected elements, send the agent an alert email via Resend

Use Case 2: Listings Display (MLS data) WFRMLS provides an official RESO OData API at https://resoapi.utahrealestate.com/reso/odata/. Access requires:

  1. Apply for licensed data access at vendor.utahrealestate.com ($50 IDX enrollment fee)
  2. Obtain a Bearer Token (OAuth-based)
  3. Query the Property resource using OData syntax

Example query (fetch recent active listings):

GET https://resoapi.utahrealestate.com/reso/odata/Property
  ?$filter=StandardStatus eq 'Active'
  &$orderby=ModificationTimestamp desc
  &$top=20
Authorization: Bearer <token>
  1. Cache results in the database or in-memory (listings don't change by the second) using Next.js unstable_cache or a simple revalidate tag
  2. Display on the public marketing site — no authentication required to view

Do not scrape the listing pages directly. WFRMLS terms of service prohibit unauthorized data extraction, and the official API path is straightforward for a licensed agent.

Playwright Implementation Notes

Run Playwright scraping jobs via:

  • Vercel Cron for scheduled form sync (daily refresh of available forms list)
  • On-demand API route when the agent requests a specific form download
  • Use playwright-core + a managed browser service (Browserless.io free tier, or self-hosted Chromium on a small VPS) for Vercel compatibility — Vercel serverless functions cannot run a full Playwright browser due to size limits

Alternatively, if the Vercel function size is a concern, extract the Playwright logic into a separate lightweight service (a small Express app on a $5/month VPS, or a Railway.app container) and call it from your Next.js API routes.


MLS/WFRMLS Listings Display

Once you have the RESO OData token, the listings page on the public marketing site is straightforward:

  1. Server Component fetches listings from the RESO API (or from a cached DB table)
  2. Display property cards with photo, price, address, beds/baths
  3. Photos: WFRMLS media URLs are served directly; use next/image with the MLS domain whitelisted in next.config.ts
  4. Detail page: dynamic route /listings/[mlsNumber] with generateStaticParams for ISR (revalidate every hour)
  5. No client-side JavaScript needed for browsing — pure Server Components + Suspense

Database Schema (Key Tables)

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  // better-auth manages password hashing
  createdAt DateTime @default(now())
  clients   Client[]
  documents Document[]
}

model Client {
  id        String   @id @default(cuid())
  agentId   String
  agent     User     @relation(fields: [agentId], references: [id])
  name      String
  email     String
  phone     String?
  createdAt DateTime @default(now())
  signingRequests SigningRequest[]
}

model Document {
  id             String   @id @default(cuid())
  agentId        String
  agent          User     @relation(fields: [agentId], references: [id])
  title          String
  originalBlobUrl  String
  preparedBlobUrl  String?
  signedBlobUrl    String?
  status         String   // "draft" | "sent" | "signed"
  fields         DocumentField[]
  signingRequests SigningRequest[]
  createdAt      DateTime @default(now())
}

model DocumentField {
  id         String   @id @default(cuid())
  documentId String
  document   Document @relation(fields: [documentId], references: [id])
  fieldName  String
  fieldType  String   // "text" | "checkbox" | "signature"
  page       Int
  x          Float
  y          Float
  width      Float
  height     Float
  value      String?
}

model SigningRequest {
  id         String   @id @default(cuid())
  documentId String
  document   Document @relation(fields: [documentId], references: [id])
  clientId   String
  client     Client   @relation(fields: [clientId], references: [id])
  token      String   @unique  // nanoid for the signing URL
  expiresAt  DateTime
  signedAt   DateTime?
  status     String   // "pending" | "signed" | "expired"
  auditEvents AuditEvent[]
  createdAt  DateTime @default(now())
}

model AuditEvent {
  id               String        @id @default(cuid())
  signingRequestId String
  signingRequest   SigningRequest @relation(fields: [signingRequestId], references: [id])
  eventType        String        // "viewed" | "consent_given" | "signed"
  ipAddress        String
  userAgent        String
  timestamp        DateTime      @default(now())
  metadata         Json?
}

Sources


Stack research for: Teressa Copeland Homes — real estate agent website + document signing Researched: 2026-03-19