Lettr — AI Resume Builder (Rezi-style SaaS)
An AI-assisted resume builder: structured resume editor, ATS keyword match
scoring against a job description, AI bullet-point rewriting, AI cover letter
generation, PDF export, and account/auth.
Stack
- Next.js 16 (App Router, TypeScript) — full-stack: pages + API routes in one app
- Postgres (via
pg) for the database — production-ready, works on serverless
hosts like Vercel. Every call site only touches the functions exported from
lib/db.ts, so switching providers (Neon, Supabase, RDS, etc.) is a
connection-string change, not a code change.
- NextAuth (Auth.js v5) — credentials (email/password) auth, bcrypt hashing
- Anthropic API (
@anthropic-ai/sdk) — AI bullet rewriting + cover letters
- @react-pdf/renderer — server-side PDF export
- Tailwind CSS 4 with a custom design system: navy/gold/cream editorial palette, self-hosted Playfair Display + Inter fonts (via
@fontsource, bundled at build time — no runtime dependency on Google's font CDN)
- Vitest — unit tests for the ATS scoring engine and the database layer (run against a real local Postgres instance)
Local setup
# 1. Install Postgres locally, then:
createdb resumeai
# 2. Install deps and configure env
npm install
cp .env.local.example .env.local
# edit .env.local:
# DATABASE_URL=postgres://postgres:postgres@localhost:5432/resumeai
# ANTHROPIC_API_KEY=sk-ant-... (get one at console.anthropic.com)
# NEXTAUTH_SECRET=$(openssl rand -base64 32)
npm run dev
# open http://localhost:3000
Schema (users + resumes tables) is created automatically on first request —
no separate migration step needed for this MVP.
Without a real ANTHROPIC_API_KEY, everything works except the two AI
endpoints (bullet rewriting and cover letter generation) — auth, resume
CRUD, ATS scoring, and PDF export are all pure-code/no-external-API and work
immediately.
Billing setup (Razorpay)
Pro is sold as one-time passes (1, 3 or 12 months, prices in lib/plans.ts). No auto-renewal: buying again while on Pro adds the time after the current end date, a daily job (/api/cron/billing) moves lapsed passes back to Free and emails a reminder 3 days before the end.
- In Razorpay, go to Account & Settings → API Keys and copy the key id and secret into
RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET.
- Add a webhook at Account & Settings → Webhooks pointing at
https://your-domain.com/api/billing/webhook, events order.paid and payment.captured. Copy its secret into RAZORPAY_WEBHOOK_SECRET.
- Prices outside India are charged in USD/GBP/EUR/AUD, which Razorpay only accepts once international payments are approved. Until then only India can buy; set
RAZORPAY_INTERNATIONAL=on after approval.
Without keys everything else works, and the Pro button says "Opens at launch".
Social login setup (Google + LinkedIn)
Both are optional — the app works fine with just email/password login if you
skip this. When configured, "Continue with Google" / "Continue with LinkedIn"
appear on the login and signup pages automatically (no code change needed,
just add the env vars).
Account linking: if someone signs up with a password using
[email protected], then later clicks "Continue with Google" using that same
Google account, they're signed into the same account — not a duplicate.
This is handled in lib/oauth-user.ts (tested against real Postgres in
tests/oauth-user.test.ts).
Google
Reuses the same Google Cloud OAuth app as Google Drive export (GOOGLE_CLIENT_ID
/ GOOGLE_CLIENT_SECRET) — you need two redirect URIs registered on it,
not one:
https://your-domain.com/api/auth/callback/google (for login)
https://your-domain.com/api/google/callback (for Drive export — see below)
Add both under Google Cloud Console → Credentials → your OAuth client →
Authorized redirect URIs.
LinkedIn
- Create an app at linkedin.com/developers
- Under Products, request "Sign In with LinkedIn using OpenID Connect"
- Under Auth, add redirect URL:
https://your-domain.com/api/auth/callback/linkedin
- Copy the Client ID and Client Secret into
LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET
Google Drive setup (Pro feature)
Lets Pro users save a resume PDF straight to their own Google Drive.
- Go to the Google Cloud Console → create a project (or use an existing one)
- APIs & Services → Library → enable the Google Drive API
- APIs & Services → OAuth consent screen → configure it (External is fine for testing; add your own email as a test user while unverified)
- APIs & Services → Credentials → Create Credentials → OAuth client ID → type Web application
- Add an Authorized redirect URI:
https://your-domain.com/api/google/callback (and http://localhost:3000/api/google/callback for local dev)
- Copy the Client ID and Client Secret into
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
The app only requests the drive.file scope — it can only see/write files it creates itself, never a user's existing Drive contents.
Without real Google credentials, the rest of the app still works — the Drive export button will correctly show a "couldn't connect" state instead of crashing.
Monitoring & ops setup
Three separate concerns, set up separately:
Errors/crashes → Sentry
- Create a free account at sentry.io, create a project (choose Next.js)
- Copy the DSN it gives you into both
SENTRY_DSN and NEXT_PUBLIC_SENTRY_DSN
- (Optional, for better stack traces) also set
SENTRY_ORG / SENTRY_PROJECT from your Sentry project settings
- That's it — uncaught errors on both client and server now report to Sentry automatically, and you can configure Sentry's own alert rules (email/Slack) for new issues
Support requests → in-app inbox
- Anyone can submit a message at
/support — no account required
- All messages are stored in the
support_messages table regardless of any other config
- Set
ADMIN_EMAIL to your own account's email — that unlocks /admin/support, a private inbox only you can see (returns a 404, not a login prompt, to anyone else)
- Optional: set
RESEND_API_KEY + RESEND_FROM_EMAIL (free tier at resend.com) to also get an email the moment someone submits a message. Without it, messages still save fine, you just have to check /admin/support yourself.
Failed payments → Stripe dashboard + Sentry
- The webhook handler now captures
invoice.payment_failed to Sentry as a warning (with the customer/amount) so you see it alongside other errors — it does not auto-downgrade the user, since Stripe's own retry schedule handles that
- For truly instant alerts, also turn on Stripe's own Email notifications for failed payments: Stripe Dashboard → Settings → Notifications
Uptime → an external pinger (this can't be code in the app)
An uptime monitor has to run independently of your app — if the app is down, code running inside it can't report that. Use a free service:
- UptimeRobot (or Better Uptime, Pingdom, etc.) → sign up free
- Add a new monitor, type HTTP(s), URL:
https://your-domain.vercel.app/api/health
- Set the check interval (5 min is fine on the free tier) and your alert email/SMS
/api/health returns 200 with {"status":"ok"} when the DB is reachable, 503 otherwise — so this also catches database outages, not just "server is down"
Testing
createdb resumeai_test # one-time, for the test suite
npm test # runs the Vitest suite (14 tests: ATS scoring + real Postgres DB layer)
npx tsc --noEmit # type-check
npm run build # production build
Project structure
app/
page.tsx landing page
login/, signup/ auth pages
dashboard/ resume list
builder/[id]/ resume editor (form + live preview + AI tabs)
api/
auth/register user signup
auth/[...nextauth] NextAuth handlers
resumes/ CRUD for resumes
resumes/pdf PDF export
ai/generate-bullets AI bullet rewriting
ai/ats-score ATS keyword match scoring (no external API)
ai/cover-letter AI cover letter generation
lib/
db.ts database access layer (node:sqlite)
auth.ts NextAuth config
ai.ts Anthropic API calls
ats-score.ts keyword extraction + scoring engine
types.ts shared resume data types
components/
ResumeEditor.tsx the main builder UI
ResumePdfDocument.tsx PDF template
tests/
ats-score.test.ts, db.test.ts
Rate limiting
The 5 endpoints that call Anthropic's API (bullet rewriting, summary writer,
cover letter, resignation letter, AI agent) are rate-limited per user via a
Postgres-backed sliding window (lib/rate-limit.ts) — 15-20 requests per 10
minutes depending on the endpoint. This is backed by the database (not an
in-memory counter) because Vercel runs multiple serverless instances that
don't share memory — an in-memory limiter would be trivially bypassed.
Email verification & password reset
Both require RESEND_API_KEY to actually send emails:
- Without it: new accounts are auto-verified (we can't gate on an email
we're incapable of sending), and password reset requests are accepted but
no email goes out — there's currently no way to actually reset a password
without Resend configured.
- With it: new accounts start unverified with a banner + resend option
on the dashboard;
/forgot-password sends a real reset link.
Unverified users are not blocked from using the app — the banner is a
nudge, not a gate. If you want verification enforced before certain actions
(e.g. before Pro checkout), that's a small addition to make, not built in
by default.
OAuth accounts self-heal a stuck "unverified" state automatically
Real bug, found via the admin panel: accounts created via Google/LinkedIn
sign-in were originally defaulting to email_verified: false, even though
completing OAuth login already proves email ownership - there's no
separate verification step that should even exist for these accounts. If
RESEND_API_KEY was ever unset at the time (or is unset now), an affected
account has no way to self-serve clear the banner - there's no email
service to send a link to.
This was first patched with a manual admin action ("Mark email verified" on
a user's detail page in /admin/users) - functional, but required an admin
to notice and click it per affected account.
findOrCreateOAuthUser() in lib/oauth-user.ts now self-heals this
automatically, on every OAuth login, for every affected account - past or
future, no admin involvement needed. When an existing user record is
found during OAuth sign-in and it's still email_verified: false, the
successful OAuth login happening right now is treated as proof of email
ownership and the record is corrected immediately, before the session is
even created. The manual admin button still exists as a fallback (e.g. for
a credentials-based account stuck unverified for unrelated reasons), but it
should rarely be needed now.
Verified two ways, not just unit tests: a dedicated test
(tests/oauth-user.test.ts) recreates the exact stuck-account scenario end
to end, and separately, the real function was called directly against a
real Postgres database with a before/after check on the raw row -
email_verified confirmed flipping f → t at the database level, with the
pending verification token cleared too.
SEO
Technical/on-page (done):
- Unique
<title> and meta description per public page (landing, pricing, templates, privacy, terms)
/robots.txt and /sitemap.xml generated dynamically (app/robots.ts, app/sitemap.ts)
- Open Graph + Twitter card tags for social link previews
- JSON-LD structured data (
SoftwareApplication) on the landing page — honest fields only, no fabricated ratings/reviews
/templates is now publicly crawlable — it previously redirected to /login before a visitor (or Googlebot) could see it at all, which meant zero SEO value for a page that should realistically rank for "resume templates" searches. Auth is now only required when actually creating a resume, not for browsing.
Requires NEXTAUTH_URL to be set to your real production domain in Vercel — the sitemap/robots/canonical URLs all derive from it. If it's still unset or pointing at localhost, fix that first or none of this resolves to real URLs in production.
Content SEO (not done, and honestly the bigger lever): the "resume examples for nurses," "how to write a resume with no experience" style pages that actually drive most of this category's organic traffic aren't built. That's a sustained content practice, not a one-time task — see the conversation this was built in for a longer discussion of what that would take.
Guest builder (build before signing up)
Anyone can start building a resume at /builder/new with zero account —
manual editing, live preview, Resume Score, and Job Match all work for
anonymous visitors. Their draft is saved in the browser's localStorage
(see lib/guest-draft.ts), not on the server, since there's no account yet
to attach it to.
What requires login: AI features (bullet rewriting, cover letter, AI
agent) and actually downloading a file. Clicking Export shows a modal
prompting signup/login rather than calling the export API directly.
What happens after they sign up: completeGuestExport() in
lib/guest-draft.ts reads the local draft, creates a real saved resume via
the normal /api/resumes endpoint, immediately triggers the export they
originally clicked, and lands them in the real editor — their work is never
lost across the signup step.
The watermark is a CSS overlay on the live preview only ("SIGN IN TO
DOWNLOAD"), not baked into any actual file — since download is gated behind
login entirely, there's never a scenario where an anonymous user holds a
watermarked (or any) real export. Once logged in, the file has no watermark.
Known testing limitation: the localStorage/redirect handoff itself
needs a real browser to fully exercise end-to-end; what's verified here is
that the server-side API sequence it depends on (create resume → export)
works correctly with real data.
Admin visual distinction
The admin portal uses a deliberately different color language from the
user-facing app — dark crimson sidebar instead of navy, a light rose-tinted
content background instead of cream, a permanent "⚠ Real user data — act
carefully" label, and a warning-stripe accent bar. This isn't just styling:
since admin has full read/write access to real user data (including
deleting accounts), making it visually unmistakable which panel you're in
is a real safety measure, not a preference.
Resume import (upload an existing resume)
Authenticated users can upload a PDF/DOCX/TXT resume (/api/resumes/import)
and have AI extract it into a new, editable resume — text extraction via
pdf-parse and mammoth, structuring via extractResumeFromText() in
lib/ai.ts. Subject to the same free-tier resume cap and AI rate limit as
everything else.
A real bug worth knowing about if you touch this code: pdf-parse
depends on pdfjs-dist, which dynamically loads a worker file at runtime.
Next.js's bundler doesn't handle that dynamic import correctly by default —
it works fine in an isolated script but breaks in the actual built server
with a "cannot find module .../pdf.worker.mjs" error. The fix is
serverExternalPackages: ["pdf-parse", "pdfjs-dist"] in next.config.ts,
which tells Next.js to leave this package's module resolution to Node
directly rather than bundling it. If you ever remove or "clean up" that
config line, PDF import will silently break again.
Reviews & feedback loop
Users leave a star rating + written feedback at /dashboard/feedback. AI
analyzes it (analyzeReview() in lib/ai.ts) into sentiment, specific
likes/dislikes pulled from their actual wording (not generic categories),
and a genuine personalized reply - shown immediately in the UI and emailed
if Resend is configured.
Admin gets a dedicated "what don't they like" view at /admin/reviews,
not just a raw list of reviews to read one by one - every extracted dislike
across every review is flattened into one panel, most recent first, each
tagged with who said it and their rating. Real stats (total, average
rating, distribution) computed live, no fabricated numbers.
Public testimonials on the landing page
Real reviews can appear on the landing page - but only through two
deliberate gates, since the feedback form was originally framed as private
("a real person reads every one"), not a public testimonial submission:
- User consent at submission - an opt-in checkbox (unchecked by
default) on the feedback form, only ever offered for feedback they're
giving anyway - not a separate "write us a testimonial" ask.
- Admin curation - even a consented review only goes live if you
explicitly click "Feature on landing page" in
/admin/reviews.
This is enforced server-side, not just hidden in the UI:
setReviewFeatured() in lib/db.ts refuses to feature a review without
consent, and getFeaturedReviews() independently filters on consent too -
tested directly in tests/reviews.test.ts, including a test that bypasses
the first guard at the SQL level to prove the second one still holds.
Caching behavior worth knowing: the landing page uses revalidate = 3600 (Next.js ISR), so a review you feature won't appear on the live site
instantly - it can take up to an hour. This is deliberate: querying the
database on every single landing-page visitor for content that changes
rarely would be wasteful. If you need a change to show up immediately,
redeploy (which forces a fresh build) rather than waiting.
Photo, dividers, and indent customization
Every template (all 9 unique designs, both live preview and PDF export)
supports:
- Profile photo - uploaded via
components/PhotoUpload.tsx, resized
client-side to max 400px JPEG before storage (so a multi-MB phone photo
doesn't bloat the database row). Stored as a base64 data URL directly in
the resume's customization.photoDataUrl - no blob storage needed.
- Section dividers toggle - what "divider" means varies sensibly by
template (a border line, a left accent bar, a solid block heading, a
connecting line between timeline entries) rather than one rigid rule
applied identically everywhere.
- Bullet indent toggle - hanging indent vs. flush-left with an inline
bullet character.
All three are threaded through both the live preview (ResumeEditor.tsx)
and PDF export (ResumePdfDocument.tsx) via layoutFlags() - a shared
helper so the two rendering engines read the same three fields consistently
rather than duplicating the logic per template.
Verified with a real generated test photo, not just a successful HTTP
response: confirmed via pdfimages that the PDF genuinely embeds the
uploaded image (matching pixel color, matching dimensions), that toggling
the photo off produces zero embedded images even when one was uploaded, and
that the dividers toggle produces measurably different PDF output.
Bug fixes: colors not applying, photo not adjustable
Accent color wasn't actually changing anything. Root cause: Tailwind
v4's @theme inline compiles .text-seal directly to color:var(--seal),
not color:var(--color-seal). The color-override code in
ResumePreview (components/ResumeEditor.tsx) was setting
--color-seal on the wrapper div - a variable nothing actually reads.
Confirmed by inspecting the real compiled CSS output, not just reasoning
about it. Fixed by overriding --seal/--seal-soft/--seal-deep
directly. This never affected PDF export, which computes colors as plain
JS values with no CSS variables involved at all - which is exactly why the
earlier PDF-based color testing passed while the live preview was
silently broken the whole time.
Uploaded photos couldn't be repositioned or cropped. There was only
Replace-entirely or Remove-entirely - if a face wasn't centered,
object-cover's automatic crop was final with no way to fix it. Built a
real adjuster (components/PhotoAdjuster.tsx): drag to reposition, slider
to zoom, bakes the result to a canvas on Apply. The original (pre-crop)
image is kept in customization.photoOriginalDataUrl specifically so
"Adjust" can always be reopened later and re-crop from scratch, rather
than progressively degrading an already-cropped image. Also fixed a
smaller real bug found along the way: the file input never reset its
value, so re-selecting the same file silently did nothing.
Bug fixes: bullet AI silent failure, nav dead-ends
Clicking "✦ AI" on an empty bullet did nothing. polish() in
ExperienceCard (components/ResumeEditor.tsx) silently returned early
if the bullet textarea was empty - no error, no feedback, just nothing.
Now shows a real message ("Write something first, then click AI to polish
it.") instead of failing silently. Also replaced the alert() popup on
API errors with the same inline message pattern.
Nav destinations fixed per explicit instruction, in TopNav.tsx and
AppSidebar.tsx:
- "Lettr" now links to the public landing page (
/) everywhere, not /dashboard
- "✦ Build Resume" now actually creates a resume and opens the builder
(reusing
NewResumeButton's logic) instead of just linking to /dashboard
- Removed the home/bell/settings icon row from
TopNav entirely
Regional pricing
Pro is priced in 3 tiers based on World Bank income classification (see
lib/pricing-region.ts): full ($19, US/UK/EU/etc.), mid ($9,
Brazil/Mexico/China/etc.), value ($5, or ₹399 for India specifically,
since that's the planned Razorpay market — Indonesia/Philippines/etc. show
the USD figure).
Detection: Vercel's x-vercel-ip-country header, set at the edge from
real IP geolocation — free, no third-party geo service needed, and not
spoofable by a client-sent header (Vercel overwrites it). Only works when
actually deployed on Vercel; elsewhere (local dev, other hosts) it's null
and everyone gets the default "full" tier.
Captured once, at signup, not re-detected on every page load — a
logged-in user's price stays stable even if they later browse from a
different location, rather than shifting under them.
Billing: STRIPE_PRO_PRICE_ID_FULL / _MID / _VALUE — create 3
separate Prices in Stripe, one per tier. The old single
STRIPE_PRO_PRICE_ID still works as a fallback for the full tier if you
haven't set up regional pricing in Stripe yet. This only affects what
gets displayed and which Stripe Price gets used at checkout — it doesn't
make billing "live." Stripe/Razorpay integration itself is still pending,
per the existing pre-launch checklist.
Admin visibility: /admin/users and /admin/subscriptions show each
user's detected country/tier, and estimated MRR is computed per-subscriber
from their actual tier rather than assuming everyone pays the same amount.
AI cost controls: Agent moved to Pro, lifetime cap on Free AI writing
Two changes to bound free-tier AI cost, which previously applied to every
active user regardless of whether they ever paid:
AI Resume Agent is now Pro-only. It was the most expensive feature by
far (up to 5 Anthropic calls behind a single chat message) and was
available to free users. Gated via canUseAiAgent() in lib/limits.ts.
Bullet rewriting and summary generation are capped at 5 lifetime uses on
Free, not the existing per-10-minute rate limit (which resets forever and
doesn't bound total cost - see lib/rate-limit.ts for that separate
mechanism). This is a genuinely different kind of limit: a lifetime counter
(ai_writing_assist_count on the users table, incremented via
incrementAiWritingAssistCount() in lib/db.ts), checked via
canUseAiWritingAssist() before the AI call is ever attempted - so a
user who's hit the cap costs nothing further, ever.
Verified via an isolated boundary test (not just unit tests): set a
real user's counter to 4, confirmed the next call passes the gate; set it
to 5, confirmed the next call is blocked with 402 before any Anthropic
API call is attempted - proving the block genuinely prevents further cost,
not just returns an error after the fact.
A visible usage indicator ("3 of 5 free uses") shows in the editor for free
users - see EditForm in components/ResumeEditor.tsx.
Free-tier template restriction (2 of 10)
Free plan gets Classic and Modern only; the other 8 templates require
Pro. Configured in lib/templates.ts (FREE_TEMPLATE_IDS), checked via
canUseTemplate() in lib/limits.ts.
This is enforced at every real point a template gets used, not just the
gallery UI - resume creation, resume save, and PDF export all
independently check it server-side. This matters specifically because PDF
export takes the template as a raw request parameter rather than
re-deriving it from what's saved in the database - a save-only check
would've left a direct bypass. DOCX and Google Drive export don't need
their own check since both are already fully Pro-gated regardless of
template.
Guest builder edge case, handled deliberately: a guest can freely
preview any template while building (no restriction - it's just
client-side rendering, costs nothing). If they sign up as Free after
picking a Pro-only template, completeGuestExport() in lib/guest-draft.ts
falls back to Classic rather than silently discarding their written
content - they keep everything they wrote, just not their original visual
choice, and can see why once they're in the real editor.
Also fixed while wiring this in: save() in the resume editor
previously never checked whether the save actually succeeded - it always
displayed "Saved" regardless of the server response. Real bug, not
specific to this feature, but this is what surfaced it. Also replaced the
hardcoded "Upgrade for more PDFs →" / "DOCX is Pro →" link text with the
real server-provided message, since a generic PDF-limit message would've
been actively misleading for a template-restriction rejection.
Verified live: confirmed save/export are both rejected for a Pro
template on Free (before any rendering happens), confirmed the same
requests succeed for the other free template, and confirmed the exact same
user succeeds once upgraded to Pro - not just a mocked assertion.
AI cost optimization: model routing
Not every AI feature needs the same model. lib/ai.ts now routes by task:
- Stays on Sonnet (
claude-sonnet-4-6): cover letter generation (writing
quality matters more here) and the AI Agent in lib/ai-agent.ts
(multi-step tool-use reasoning genuinely benefits from the stronger model)
- Routed to Haiku (
claude-haiku-4-5-20251001): bullet rewriting,
summary generation, resignation letters, resume import extraction, and
review analysis - all well-defined, mostly mechanical tasks (rewrite
this, extract that, classify this) that don't need the expensive model,
and are also the highest-volume calls, which is where routing saves the
most in aggregate.
Real impact is bigger on Pro usage than Free: the free-tier lifetime cap
(5 calls) already bounded free-tier cost to a small, fixed amount, so
routing barely moves that number further. On an active Pro user's total
AI cost, routing saves roughly 30% - the Agent and cover letter (the two
priciest, least-routable features) intentionally stay on the expensive
model, which is why the aggregate saving is more moderate than the ~90%
saving on the routed features specifically.
Honest verification note: confirmed the routed endpoints execute
correctly end-to-end with the new model (no code-level errors), but this
sandbox's placeholder Anthropic API key means the actual per-call cost
reduction couldn't be measured directly - Anthropic's real usage dashboard
(console.anthropic.com) will show the real before/after once this is live.
Going to production
- Database: already Postgres — for production, point
DATABASE_URL at a
managed instance (Neon, Supabase, Railway, RDS all work) instead of local
Postgres. No code changes needed.
- Auth secret: generate a real
NEXTAUTH_SECRET (openssl rand -base64 32)
and set it as an environment variable in your host, never commit it.
- Anthropic API key: set
ANTHROPIC_API_KEY as a server-side env var in
your host's dashboard.
- Deploy: this is a standard Next.js app, so Vercel is the path of least
resistance (
vercel deploy after vercel login, or connect the GitHub
repo in the Vercel dashboard). Railway/Render/Fly.io also work with a
Dockerfile if you'd rather not use Vercel.
- Payments: there's no billing wired up yet. If you want a free/pro plan
split, Stripe Checkout + a webhook that flips
users.plan in the DB is the
standard pattern — say the word and I'll wire it in.
- Rate limiting: the AI endpoints call a paid API per request — add rate
limiting (e.g. per-user daily cap) before opening this to the public, or
costs can run away.
Known limitations (MVP scope)
- No AI Interview practice, Job Search tool, LinkedIn optimizer, or resume
upload/parsing yet — all real Rezi features, but each is a substantial
standalone feature, not a quick addition
- No email verification on signup
- The "Job Match" tab measures JD-keyword overlap; the separate "Score" tab
covers Rezi's resume-quality scoring (formatting/content strength) —
together they cover what Rezi Score + Keyword Targeting do
- The AI Agent (
lib/ai-agent.ts) supports 9 tools covering contact,
summary, experience, education, and skills edits. It does not yet call
the ATS scorer or cover-letter generator as tools within the same
conversation - those stay on their own tabs.
- No Privacy Policy or Terms of Service pages yet — needed before real launch
Design system
The visual design (navy/gold/cream palette, Playfair Display headings, sidebar
app layout) was rebuilt to match a set of reference mockups. Two things were
deliberately not copied from those mockups, on principle rather than
preference:
- The reference landing page included a customer-logo bar (real companies)
and named customer testimonials. Lettr has no actual customers yet, so
reproducing those would be misleading marketing — the current landing page
uses the same structure with honest placeholder content instead.
- The reference admin dashboard showed specific numbers (total users, revenue,
AI usage volume). Rather than copy those numbers,
/admin queries and
displays Lettr's actual database — real user counts, real plan breakdown,
real signups-per-week chart. It'll show real (small) numbers until there
are real users, which is correct behavior, not a bug.