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)
Recommended Stack
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 PDFspdf-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:
- Authenticate with the agent's saved credentials (stored encrypted in the DB)
- Navigate to the forms library
- Download the target PDF as a Buffer
- Store the original PDF in Vercel Blob under a UUID-based path
- 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:
- Load the PDF from Blob storage
- Iterate over each page's annotations
- Find
Widgetannotations (AcroForm fields: text inputs, checkboxes, signature fields) - Record each field's name, type, and bounding box (x, y, width, height, page number)
- Store this field map in the database (
DocumentFieldtable)
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-libserver-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.):
- Send field values to a Server Action
- Server Action loads the PDF from Blob
- Use
@pdfme/pdf-libto:- 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 aUint8Array, embed asPDFImage, draw it at the signature zone coordinates - Optionally flatten the form (make fields non-editable) before final storage
- 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.
E-Signature Legal Requirements
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
- Client opens the email link containing a
nanoidtoken - Server validates the token (not expired, not already used)
- Record
"viewed"audit event (IP, timestamp, UA) - Client sees a consent banner: "By signing, you agree to execute this document electronically under the ESIGN Act."
- Client checks consent checkbox — record
"consent_given"audit event - Client draws signature on canvas
- On submit: POST to API route with canvas PNG data URL
- Server records
"signed"audit event, embeds signature into PDF, stores signed PDF - 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:
- Store the agent's utahrealestate.com credentials encrypted in the database (use
bcryptor AES-256-GCM encryption with a server-side secret key — not bcrypt since you need to recover the plaintext) - Use Playwright to authenticate: navigate to the login page, fill credentials, submit, wait for session cookies
- Navigate to the forms section, find the desired form by name/category, download the PDF
- This is fragile (subject to site redesigns) but unavoidable without official API access
- 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:
- Apply for licensed data access at vendor.utahrealestate.com ($50 IDX enrollment fee)
- Obtain a Bearer Token (OAuth-based)
- Query the
Propertyresource 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>
- Cache results in the database or in-memory (listings don't change by the second) using Next.js
unstable_cacheor a simplerevalidatetag - 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:
- Server Component fetches listings from the RESO API (or from a cached DB table)
- Display property cards with photo, price, address, beds/baths
- Photos: WFRMLS media URLs are served directly; use
next/imagewith the MLS domain whitelisted innext.config.ts - Detail page: dynamic route
/listings/[mlsNumber]withgenerateStaticParamsfor ISR (revalidate every hour) - 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
- Next.js 15.5 Release Notes — HIGH confidence
- Next.js 16 Upgrade Guide — HIGH confidence (confirms 16 is available but breaking)
- pdf-lib npm page — HIGH confidence (v1.17.1, unmaintained 4 years)
- @pdfme/pdf-lib npm page — HIGH confidence (v5.5.8, actively maintained fork)
- pdfjs-dist npm / libraries.io — HIGH confidence (v5.5.207 current)
- JavaScript PDF Libraries Comparison 2025 (Nutrient) — HIGH confidence
- signature_pad npm — HIGH confidence (v5.1.3)
- react-signature-canvas npm — HIGH confidence (v1.1.0-alpha.2, alpha status noted)
- better-auth npm — HIGH confidence (v1.5.5)
- Auth.js joins better-auth discussion — HIGH confidence
- better-auth Next.js integration — HIGH confidence
- resend npm — HIGH confidence (v6.9.4)
- Prisma 6.19.0 announcement — HIGH confidence
- Neon + Vercel integration — HIGH confidence
- playwright npm — HIGH confidence (v1.58.2)
- UtahRealEstate.com Vendor Data Services — HIGH confidence (official RESO OData API confirmed)
- RESO OData endpoints for WFRMLS — HIGH confidence
- ESIGN/UETA audit trail requirements (Anvil Engineering) — HIGH confidence
- E-signature legal requirements (BlueNotary) — HIGH confidence
- Vercel Blob documentation — HIGH confidence
- Playwright vs Puppeteer 2025 (BrowserStack) — HIGH confidence
- NextAuth vs Clerk vs better-auth comparison (supastarter) — MEDIUM confidence (third-party analysis)
Stack research for: Teressa Copeland Homes — real estate agent website + document signing Researched: 2026-03-19