ApoorvKhanna/personalized-landing-pages

Your landing page, rewritten for each kind of visitor. Crawl once, approve every variant, paste one tag. Self-hosting needs your own Vaaya API key

TypeScript

0

2 commits

updated Oct 7, 2026

See the code

See what people are saying

SourceMessageScoreDate

Personalized Landing Pages - different copy for different visitors, without changing the offer. Code on GitHub. Doesnt need LLM so is super duper fast and needs one JS snippet addition and nothing more. (r/SideProject)

Repo: [https://github.com/ApoorvKhanna/personalized-landing-pages](https://github.com/ApoorvKhanna/personalized-landing-pages) I built this for an ordinary problem: different people land on the same page, but the headline only speaks to one of them and its a pain seeing good customers getting all…

1

Oct 7, 2026

README

Personalized Landing Pages

Your landing page, rewritten for each kind of visitor. Give it a URL: it reads the page once, suggests visitor personas, and writes each persona's version of your headline, subhead, button and feature blurbs. You approve every block, then paste one tag in your page head.

Self-hosting needs your own Vaaya API key with balance. Every model call and rendered-crawl fallback is billed to that key at Vaaya's per-call prices. It is not free to run. Get a key at https://vaaya.ai. If you would rather not host it, the hosted version is at https://vaaya.ai/personalized-landing-pages.

What it does

  • Add a URL. The page is fetched once on the server (generation reads it again only if that copy is a day old). The text blocks are pulled out: the hero headline, subhead and primary call to action, plus up to six feature headings and descriptions. Navigation, footers, hidden text and animated headlines are left alone
  • Personas. A cheap model suggests 3 to 5 visitor personas for the page, each with routing hints (utm_source values, referring sites). Edit, remove or add your own before generating
  • Generate. One model call writes every persona's version of every block, plus more routing suggestions. Everything comes back as a draft
  • Review. Approve, edit or reject each block. A facts guard checks every variant against the crawled page: a new number, price or percentage blocks approval, and a new name or claim needs "Approve anyway"
  • Install. One synchronous script tag, first thing in your page head, or a prompt that has Cursor, Lovable or Claude Code add it for you
  • On every visit the tag picks a persona in plain code, in this order: a preview parameter, the persona cookie, utm_content, utm_campaign, utm_source, then the referring site. A bare visit with none of these sees your original page. A model is asked only when your site passes free text (a site search, a chat opener), at most once per visit
  • Swapping. The tag hides only the blocks it will change, swaps them before paint, and shows everything after 400 ms whatever happens. A block whose text changed since the crawl is never touched. Take any block down from the dashboard; changes reach visitors within about a minute

Setup

NameRequiredWhat
VAAYA_API_KEYYesYour Vaaya API key. Every model call and rendered read is billed to it
VAAYA_URLNoVaaya base URL. Default https://vaaya.ai
ADMIN_TOKENYesThe token you type at /login. Long and random. Changing it signs every browser out
SESSION_SECRETYes32 or more random characters. Seals the session cookie and salts the hashed visitor IPs
BLOB_READ_WRITE_TOKENYesFrom the Vercel Blob store connected to the project
APP_URLNoThis app's public address, no trailing slash, used in the install tag. Default: the address the dashboard was opened on
MODELSNoComma-separated model ids, tried in order. Replaces every default model list
CLASSIFY_PER_SITE_DAYNoVisitor classifications per site per UTC day. Default 500. 0 turns classification off
CLASSIFY_PER_IP_DAYNoVisitor classifications per visitor IP per UTC day, across every site. Default 20
pnpm install
cp .env.example .env.local   # then fill it in
pnpm dev                     # http://localhost:3000, sign in with ADMIN_TOKEN
pnpm test                    # unit tests

Local development still needs a Blob store token. vercel env pull .env.local fetches it once the project is linked.

Deploy to Vercel: import the repository, connect a Blob store to the project (Storage, then Blob; this sets BLOB_READ_WRITE_TOKEN), add the other variables and deploy. The routes that crawl and generate ask for up to 300 seconds (maxDuration), so your plan must allow that.

Install the tag on your site

The Install step in the dashboard shows your exact tag. It goes first in the page head, before any other script or stylesheet, and must stay synchronous (no async, defer or type="module"), because an async script cannot swap text before the first paint:

<head>
  <script src="https://your-app.example.com/api/s/pv_yoursitekey.js"></script>
  ...
</head>

The dashboard also gives you a prompt to paste into a coding agent, with the right file for plain HTML, Vite or Lovable, and the Next.js App and Pages routers.

If your site has a search box or a chat opener, pass the visitor's first query or message:

window.vaayaPersona?.classify(text) // resolves to a persona id, or null

Preview any persona on your live site with ?vaaya_persona=<persona id>, and your original with ?vaaya_persona=none. A preview never sets the cookie.

What it costs

Hosted and self-hosted are priced differently. The hosted version has product prices and gives some steps away; a self-hosted copy has no product price, and every call it makes is billed to your own key.

Hosted at vaaya.ai

Persona suggestions are free, and so is UTM and referrer routing on every pageview. Generation is charged once per site when the variants are ready, nothing if it fails. Classification of a visitor's free text is charged per visit, and routing keeps working when the balance runs out. The current prices are on https://vaaya.ai/personalized-landing-pages.

Self-hosted

Your Vaaya key pays per call, at Vaaya's per-call prices, including the steps that are free on the hosted version:

  • Persona suggestions: one cheap model call per site you add. Free hosted, billed here
  • Generation: one model call per site
  • The rendered read, only when a plain fetch of your page finds nothing to read (a page that draws itself with JavaScript), is refused, or is not HTML
  • Visitor classification: one cheap model call per visit that passes free text

Each step tries the next model in its list only when one fails, and a failed generation can be run again.

Free on Vaaya either way: the direct page fetch, UTM and referrer routing, and serving the tag. Self-hosted, Vercel bills hosting and Blob storage under your plan.

The caps bound what visitors can spend. CLASSIFY_PER_SITE_DAY and CLASSIFY_PER_IP_DAY are counted in one blob per UTC day (counts/<YYYY-MM-DD>.json). A classification is counted when it is attempted, before the model call, so a failed call still counts. Visitor IPs are stored only as salted hashes. On top of that, each server instance allows at most 5 classifications per minute from one visitor on one site and 120 per minute per site. After Vaaya refuses the key (401) or the balance (402), classification pauses for a minute on that instance and visitors keep plain UTM and referrer routing.

How it is built

  • Next.js 15 App Router, React 19, TypeScript, plain CSS. No database server: private JSON documents in Vercel Blob, written with ETag checks (ifMatch, a weak W/ prefix stripped) and retried when another write got there first
    • sites/<id>.json: one site with its crawl, personas, routing rules and every variant
    • live/<site_key>.json: what the public endpoints read, approved variants only, rewritten after generation and after every review or routing change
    • counts/<YYYY-MM-DD>.json: the daily classification counters
  • One owner. /login checks ADMIN_TOKEN in constant time and sets one httpOnly cookie, AES-GCM sealed with SESSION_SECRET. Dashboard writes also refuse a request whose Origin is another site
  • Every paid call goes through lib/vaaya.ts with authorization: Bearer <VAAYA_API_KEY> and x-vaaya-agent: personalized-landing-pages:
    • models: POST {VAAYA_URL}/api/llm/v1/chat/completions, OpenAI-compatible, with a forced tool call so every answer is one JSON object
    • rendered read: POST {VAAYA_URL}/api/run/vaaya/onescrape with one URL, format: "html" and a cost cap
  • Models, tried in order until one answers: suggestions and classification use the first two of google/gemini-2.5-flash-lite, openai/gpt-5-nano, anthropic/claude-haiku-4.5; generation uses anthropic/claude-sonnet-5, then openai/gpt-5.2. MODELS replaces both lists (suggestions and classification then use its first two)
  • The crawler fetches over https only, re-checks every redirect hop (DNS lookup, private and internal addresses refused), and reads at most 2 MB
  • A small built-in HTML parser follows the browser rules that move elements, so the CSS selectors it writes resolve to the same elements in a browser. Streamed React pages are replayed into their final shape first
  • The tag's runtime is a plain JavaScript string (lib/snippet-runtime.ts), never compiled, so a bundler cannot change what ships. The tests evaluate those exact bytes
  • The tag is served with max-age=60, s-maxage=30, stale-while-revalidate=30, and each server instance keeps a site's live copy in memory for 10 seconds

Layout

app/page.tsx                        dashboard when signed in, a short intro otherwise
app/dashboard.tsx                   the four steps: personas, generate, review, install
app/login/page.tsx                  sign in with ADMIN_TOKEN
app/api/login, app/api/logout       set and clear the session cookie
app/api/sites/...                   add, list, personas, generate, review, routing, remove
app/api/s/[file]                    public: the tag, /api/s/<site_key>.js
app/api/classify                    public: a visitor's free text to a persona
lib/store.ts                        sites, live copies and daily counters
lib/blob.ts                         JSON documents in Vercel Blob with ETag checks
lib/crawl.ts                        the fetch, the address checks, the rendered fallback
lib/html.ts, lib/extract.ts         the parser and block extraction
lib/suggest.ts                      persona suggestions
lib/generate.ts                     the variants
lib/classify.ts                     visitor classification
lib/facts.ts                        the facts guard
lib/snippet.ts, snippet-runtime.ts  the served tag
lib/install.ts                      the tag and the coding-agent prompt
lib/public.ts                       the two public endpoints
lib/vaaya.ts                        the only file that calls Vaaya
lib/session.ts, lib/env.ts          sign-in and settings
lib/__tests__/                      vitest

License

MIT, see LICENSE.

ApoorvKhanna/personalized-landing-pages

Your landing page, rewritten for each kind of visitor. Crawl once, approve every variant, paste one tag. Self-hosting needs your own Vaaya API key

TypeScript

0

2 commits

updated Oct 7, 2026

See the code

See what people are saying

SourceMessageScoreDate

Personalized Landing Pages - different copy for different visitors, without changing the offer. Code on GitHub. Doesnt need LLM so is super duper fast and needs one JS snippet addition and nothing more. (r/SideProject)

Repo: [https://github.com/ApoorvKhanna/personalized-landing-pages](https://github.com/ApoorvKhanna/personalized-landing-pages) I built this for an ordinary problem: different people land on the same page, but the headline only speaks to one of them and its a pain seeing good customers getting all…

1

Oct 7, 2026

README

Personalized Landing Pages

Your landing page, rewritten for each kind of visitor. Give it a URL: it reads the page once, suggests visitor personas, and writes each persona's version of your headline, subhead, button and feature blurbs. You approve every block, then paste one tag in your page head.

Self-hosting needs your own Vaaya API key with balance. Every model call and rendered-crawl fallback is billed to that key at Vaaya's per-call prices. It is not free to run. Get a key at https://vaaya.ai. If you would rather not host it, the hosted version is at https://vaaya.ai/personalized-landing-pages.

What it does

  • Add a URL. The page is fetched once on the server (generation reads it again only if that copy is a day old). The text blocks are pulled out: the hero headline, subhead and primary call to action, plus up to six feature headings and descriptions. Navigation, footers, hidden text and animated headlines are left alone
  • Personas. A cheap model suggests 3 to 5 visitor personas for the page, each with routing hints (utm_source values, referring sites). Edit, remove or add your own before generating
  • Generate. One model call writes every persona's version of every block, plus more routing suggestions. Everything comes back as a draft
  • Review. Approve, edit or reject each block. A facts guard checks every variant against the crawled page: a new number, price or percentage blocks approval, and a new name or claim needs "Approve anyway"
  • Install. One synchronous script tag, first thing in your page head, or a prompt that has Cursor, Lovable or Claude Code add it for you
  • On every visit the tag picks a persona in plain code, in this order: a preview parameter, the persona cookie, utm_content, utm_campaign, utm_source, then the referring site. A bare visit with none of these sees your original page. A model is asked only when your site passes free text (a site search, a chat opener), at most once per visit
  • Swapping. The tag hides only the blocks it will change, swaps them before paint, and shows everything after 400 ms whatever happens. A block whose text changed since the crawl is never touched. Take any block down from the dashboard; changes reach visitors within about a minute

Setup

NameRequiredWhat
VAAYA_API_KEYYesYour Vaaya API key. Every model call and rendered read is billed to it
VAAYA_URLNoVaaya base URL. Default https://vaaya.ai
ADMIN_TOKENYesThe token you type at /login. Long and random. Changing it signs every browser out
SESSION_SECRETYes32 or more random characters. Seals the session cookie and salts the hashed visitor IPs
BLOB_READ_WRITE_TOKENYesFrom the Vercel Blob store connected to the project
APP_URLNoThis app's public address, no trailing slash, used in the install tag. Default: the address the dashboard was opened on
MODELSNoComma-separated model ids, tried in order. Replaces every default model list
CLASSIFY_PER_SITE_DAYNoVisitor classifications per site per UTC day. Default 500. 0 turns classification off
CLASSIFY_PER_IP_DAYNoVisitor classifications per visitor IP per UTC day, across every site. Default 20
pnpm install
cp .env.example .env.local   # then fill it in
pnpm dev                     # http://localhost:3000, sign in with ADMIN_TOKEN
pnpm test                    # unit tests

Local development still needs a Blob store token. vercel env pull .env.local fetches it once the project is linked.

Deploy to Vercel: import the repository, connect a Blob store to the project (Storage, then Blob; this sets BLOB_READ_WRITE_TOKEN), add the other variables and deploy. The routes that crawl and generate ask for up to 300 seconds (maxDuration), so your plan must allow that.

Install the tag on your site

The Install step in the dashboard shows your exact tag. It goes first in the page head, before any other script or stylesheet, and must stay synchronous (no async, defer or type="module"), because an async script cannot swap text before the first paint:

<head>
  <script src="https://your-app.example.com/api/s/pv_yoursitekey.js"></script>
  ...
</head>

The dashboard also gives you a prompt to paste into a coding agent, with the right file for plain HTML, Vite or Lovable, and the Next.js App and Pages routers.

If your site has a search box or a chat opener, pass the visitor's first query or message:

window.vaayaPersona?.classify(text) // resolves to a persona id, or null

Preview any persona on your live site with ?vaaya_persona=<persona id>, and your original with ?vaaya_persona=none. A preview never sets the cookie.

What it costs

Hosted and self-hosted are priced differently. The hosted version has product prices and gives some steps away; a self-hosted copy has no product price, and every call it makes is billed to your own key.

Hosted at vaaya.ai

Persona suggestions are free, and so is UTM and referrer routing on every pageview. Generation is charged once per site when the variants are ready, nothing if it fails. Classification of a visitor's free text is charged per visit, and routing keeps working when the balance runs out. The current prices are on https://vaaya.ai/personalized-landing-pages.

Self-hosted

Your Vaaya key pays per call, at Vaaya's per-call prices, including the steps that are free on the hosted version:

  • Persona suggestions: one cheap model call per site you add. Free hosted, billed here
  • Generation: one model call per site
  • The rendered read, only when a plain fetch of your page finds nothing to read (a page that draws itself with JavaScript), is refused, or is not HTML
  • Visitor classification: one cheap model call per visit that passes free text

Each step tries the next model in its list only when one fails, and a failed generation can be run again.

Free on Vaaya either way: the direct page fetch, UTM and referrer routing, and serving the tag. Self-hosted, Vercel bills hosting and Blob storage under your plan.

The caps bound what visitors can spend. CLASSIFY_PER_SITE_DAY and CLASSIFY_PER_IP_DAY are counted in one blob per UTC day (counts/<YYYY-MM-DD>.json). A classification is counted when it is attempted, before the model call, so a failed call still counts. Visitor IPs are stored only as salted hashes. On top of that, each server instance allows at most 5 classifications per minute from one visitor on one site and 120 per minute per site. After Vaaya refuses the key (401) or the balance (402), classification pauses for a minute on that instance and visitors keep plain UTM and referrer routing.

How it is built

  • Next.js 15 App Router, React 19, TypeScript, plain CSS. No database server: private JSON documents in Vercel Blob, written with ETag checks (ifMatch, a weak W/ prefix stripped) and retried when another write got there first
    • sites/<id>.json: one site with its crawl, personas, routing rules and every variant
    • live/<site_key>.json: what the public endpoints read, approved variants only, rewritten after generation and after every review or routing change
    • counts/<YYYY-MM-DD>.json: the daily classification counters
  • One owner. /login checks ADMIN_TOKEN in constant time and sets one httpOnly cookie, AES-GCM sealed with SESSION_SECRET. Dashboard writes also refuse a request whose Origin is another site
  • Every paid call goes through lib/vaaya.ts with authorization: Bearer <VAAYA_API_KEY> and x-vaaya-agent: personalized-landing-pages:
    • models: POST {VAAYA_URL}/api/llm/v1/chat/completions, OpenAI-compatible, with a forced tool call so every answer is one JSON object
    • rendered read: POST {VAAYA_URL}/api/run/vaaya/onescrape with one URL, format: "html" and a cost cap
  • Models, tried in order until one answers: suggestions and classification use the first two of google/gemini-2.5-flash-lite, openai/gpt-5-nano, anthropic/claude-haiku-4.5; generation uses anthropic/claude-sonnet-5, then openai/gpt-5.2. MODELS replaces both lists (suggestions and classification then use its first two)
  • The crawler fetches over https only, re-checks every redirect hop (DNS lookup, private and internal addresses refused), and reads at most 2 MB
  • A small built-in HTML parser follows the browser rules that move elements, so the CSS selectors it writes resolve to the same elements in a browser. Streamed React pages are replayed into their final shape first
  • The tag's runtime is a plain JavaScript string (lib/snippet-runtime.ts), never compiled, so a bundler cannot change what ships. The tests evaluate those exact bytes
  • The tag is served with max-age=60, s-maxage=30, stale-while-revalidate=30, and each server instance keeps a site's live copy in memory for 10 seconds

Layout

app/page.tsx                        dashboard when signed in, a short intro otherwise
app/dashboard.tsx                   the four steps: personas, generate, review, install
app/login/page.tsx                  sign in with ADMIN_TOKEN
app/api/login, app/api/logout       set and clear the session cookie
app/api/sites/...                   add, list, personas, generate, review, routing, remove
app/api/s/[file]                    public: the tag, /api/s/<site_key>.js
app/api/classify                    public: a visitor's free text to a persona
lib/store.ts                        sites, live copies and daily counters
lib/blob.ts                         JSON documents in Vercel Blob with ETag checks
lib/crawl.ts                        the fetch, the address checks, the rendered fallback
lib/html.ts, lib/extract.ts         the parser and block extraction
lib/suggest.ts                      persona suggestions
lib/generate.ts                     the variants
lib/classify.ts                     visitor classification
lib/facts.ts                        the facts guard
lib/snippet.ts, snippet-runtime.ts  the served tag
lib/install.ts                      the tag and the coding-agent prompt
lib/public.ts                       the two public endpoints
lib/vaaya.ts                        the only file that calls Vaaya
lib/session.ts, lib/env.ts          sign-in and settings
lib/__tests__/                      vitest

License

MIT, see LICENSE.