Skip to content

Building a Privacy-First Resume Builder With Next.js 14 and AI (Technical Case Study)

Updated November 5, 2026
5 min read
Share:XLinkedInWhatsApp
Contents

Most resume builders follow the same pattern: you type, the app sends everything to a server, and your resume lives in someone else's database. We started from a different constraint: user resumes must never be stored on our servers. This article explains the technical decisions that follow from that rule. And if you just want a resume, open the builder.

The Problem With the Classic Model

Client (React)
    ↓
API
    ↓
Database
    ↓
User resumes stored on the server
  1. Privacy risk: names, phone numbers, emails and work histories in one place make an attractive target.
  2. Compliance burden: storing personal data means retention policies, deletion requests and documentation under laws like the GDPR.
  3. Trust: "we store it securely" is a promise. "We don't store it" can be checked.

Architecture: Local-First

The resume lives on the user's device. The server only does work that can't happen in the browser, and it doesn't keep the input.

Browser
  Zustand store (working copy)
    ↓ debounced, merging writes
  localStorage (all of the user's resumes)
    ↓ optional, user-initiated
  User's own Google Drive (drive.file scope)

Server (stateless for resume data)
  /api/pdf    → renders a PDF in memory, returns it
  /api/ai/*   → forwards the selected text to the AI provider

Trade-offs and how we handle them:

  • Clearing browser data deletes resumes → optional backup to the user's own Drive, plus a reminder before download.
  • localStorage is limited to a few megabytes → profile photos are compressed in the browser before they're stored.
  • No automatic cross-device sync → Drive backup again, entirely under the user's control.

Stack

Next.js 14 (App Router)

Content pages (templates, guides, blog) are server-rendered for search engines. The builder itself is a client-side application. Route handlers host the few server functions we need.

State: Zustand with persist

Redux would have been overkill. A single Zustand store holds all resumes plus the working copy of the active one; the persist middleware writes to localStorage.

// simplified
const useResumeStore = create(
  persist(
    (set, get) => ({
      resumes: {},          // id → stored resume
      activeResumeId: null,
      resumeData: emptyResume, // working copy of the active resume
      updatePersonalInfo: (patch) => sync(set, get, patch),
    }),
    { name: 'resume-storage', version: CURRENT_STORE_VERSION, migrate },
  ),
)

Two details turned out to matter more than expected:

  • Versioned migrations. Whenever the resume shape changes, the store version is bumped and a migration step converts old data. Returning users would otherwise lose their resumes on the next visit. Every migration has a unit test.
  • A merging storage adapter. With two tabs open, naive persistence lets the last write win and silently drops the other tab's edits. Our adapter debounces writes, merges with what's already stored, records deletions as tombstones with an expiry, and refuses to overwrite data written by a newer app version. Write errors (for example a full quota) surface in a save indicator instead of failing silently.

PDF Generation

We wanted PDFs with real, selectable text (so applicant tracking systems can parse them) and pixel-accurate layout. Client-side canvas approaches produce images of text, which fails the first requirement.

The solution: the browser posts the preview HTML to a route handler, which renders it with headless Chromium via Playwright and streams the PDF back. Nothing is written to disk.

// simplified
const page = await browser.newPage()
await page.setContent(html, { waitUntil: 'networkidle' })
const pdf = await page.pdf({ format: 'A4', printBackground: true })
await page.close()
return new Response(pdf, { headers: { 'Content-Type': 'application/pdf' } })

The hard part was multi-page layout. Page margins and break rules exist twice: in the print CSS of the PDF route and in the live preview, which simulates page breaks so users see where pages will end. A unit test keeps both in sync, and a script checks page size and page count for every template against a running server.

AI Integration

AI features (rewriting bullets, suggesting skills, drafting a summary or cover letter, matching keywords against a job posting) run on Groq through the Vercel AI SDK. A few rules apply to every AI route:

  • One place for the model name. Providers retire models; a model name hardcoded in several routes can break every AI feature at once. The model is configured in a single module.
  • No fabrication. Every prompt includes a shared rule that forbids inventing numbers, dates, employers or achievements.
  • Bounded input and output. Request bodies have a size limit, user text is clipped before prompting, and output tokens are capped.
  • Safe logging. SDK errors can contain the request body, which here means the user's resume text. Errors are logged only as a sanitized summary.
  • Honest streaming. Streaming responses forward provider errors to the client, so a failure doesn't look like an empty "successful" answer.
  • Rate limiting in middleware, since the AI routes don't require an account.

These routes process the text they receive and keep nothing.

Security Basics

  • Sanitized rich text: CMS content (blog posts, pages) is sanitized with DOMPurify on save, with an explicit allowlist of tags and attributes.
  • Per-route Content Security Policy: only content pages that may show ads allow ad hosts; the builder and everything else block them.
  • Admin sessions are signed JWTs in httpOnly cookies, verified in middleware, with optional TOTP two-factor authentication.

Lessons Learned

1. Local-first works for this use case

A resume is a small, personal document that one person edits. That's a natural fit for local-first. Many apps that store everything centrally don't really need to.

2. Persistence is the hard part

Multiple tabs, schema changes, storage quotas: the real engineering effort went into making local storage reliable, not into the UI.

3. Privacy has to be verifiable

Anyone can open the Network tab and watch what the builder sends. That's a stronger argument than any marketing claim.

4. AI needs guardrails, not just prompts

A no-fabrication rule, input limits, safe logging and a single model configuration prevent the most painful failure modes.

5. Ship, then iterate

Features like the Drive backup and multi-page PDFs came after real usage showed they were needed.

Conclusion

"We keep your data safe" is a promise. "We don't have your data" is an architecture. If you're building a privacy-first product, local-first state, stateless server functions and verifiable behavior go a long way. Try the resume builder, now that you know what's behind it.

Build your resume now

Free, no sign-up required; your data stays in your browser.

Build your resume now

Leave a comment

* Your comment will be published after it has been approved.

    Essential cookies are always on. With your permission, we use anonymous statistics (Google Analytics); your resume data is never sent.

    Cookie Policy