# 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 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 ```bash # 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. --- ## 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) ```typescript // 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](https://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 ``` 4. 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 5. 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) ```prisma 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](https://nextjs.org/blog/next-15-5) — HIGH confidence - [Next.js 16 Upgrade Guide](https://nextjs.org/docs/app/guides/upgrading/version-16) — HIGH confidence (confirms 16 is available but breaking) - [pdf-lib npm page](https://www.npmjs.com/package/pdf-lib) — HIGH confidence (v1.17.1, unmaintained 4 years) - [@pdfme/pdf-lib npm page](https://www.npmjs.com/package/@pdfme/pdf-lib) — HIGH confidence (v5.5.8, actively maintained fork) - [pdfjs-dist npm / libraries.io](https://libraries.io/npm/pdfjs-dist) — HIGH confidence (v5.5.207 current) - [JavaScript PDF Libraries Comparison 2025 (Nutrient)](https://www.nutrient.io/blog/javascript-pdf-libraries/) — HIGH confidence - [signature_pad npm](https://www.npmjs.com/package/signature_pad) — HIGH confidence (v5.1.3) - [react-signature-canvas npm](https://www.npmjs.com/package/react-signature-canvas) — HIGH confidence (v1.1.0-alpha.2, alpha status noted) - [better-auth npm](https://www.npmjs.com/package/better-auth) — HIGH confidence (v1.5.5) - [Auth.js joins better-auth discussion](https://github.com/nextauthjs/next-auth/discussions/13252) — HIGH confidence - [better-auth Next.js integration](https://better-auth.com/docs/integrations/next) — HIGH confidence - [resend npm](https://www.npmjs.com/package/resend) — HIGH confidence (v6.9.4) - [Prisma 6.19.0 announcement](https://www.prisma.io/blog/announcing-prisma-6-19-0) — HIGH confidence - [Neon + Vercel integration](https://vercel.com/marketplace/neon) — HIGH confidence - [playwright npm](https://www.npmjs.com/package/playwright) — HIGH confidence (v1.58.2) - [UtahRealEstate.com Vendor Data Services](https://vendor.utahrealestate.com/) — HIGH confidence (official RESO OData API confirmed) - [RESO OData endpoints for WFRMLS](https://vendor.utahrealestate.com/webapi/docs/tuts/endpoints) — HIGH confidence - [ESIGN/UETA audit trail requirements (Anvil Engineering)](https://www.useanvil.com/blog/engineering/e-signature-audit-trail-schema-events-json-checklist/) — HIGH confidence - [E-signature legal requirements (BlueNotary)](https://bluenotaryonline.com/electronic-signature-legal-requirements/) — HIGH confidence - [Vercel Blob documentation](https://vercel.com/docs/vercel-blob) — HIGH confidence - [Playwright vs Puppeteer 2025 (BrowserStack)](https://www.browserstack.com/guide/playwright-vs-puppeteer) — HIGH confidence - [NextAuth vs Clerk vs better-auth comparison (supastarter)](https://supastarter.dev/blog/better-auth-vs-nextauth-vs-clerk) — MEDIUM confidence (third-party analysis) --- *Stack research for: Teressa Copeland Homes — real estate agent website + document signing* *Researched: 2026-03-19*