Vibecode Linktree Pro
track this build5 phases, 15 steps, beginner friendly0%A static page. The most obviously one-shottable thing on this list.
You are building a lean indie version of Linktree Pro.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# Linktree Pro · indie build
A link-in-bio page on your own domain: your links live in one JSON file, every button routes through a redirect that counts the click without a third party, and a private stats page shows what people actually press. Fast, dark-mode aware, and yours.
Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | the page is server-rendered HTML and the redirect is one INSERT and a 302 |
| Content | links.json | editing a file and redeploying is the CMS; git is the history |
| Database | SQLite for clicks only | content is a file; only the counters need a database |
| Hosting | A small VPS behind Caddy | the redirect needs a process; the rest is static |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **Your links, title and bio, written down** · free
- Why: The page renders links.json exactly. Decide the order and titles first so Phase 1 is about rendering, not editing.
- Get it: A list of up to ten links with a short title each, a one-line bio, and a square avatar image (at least 400x400).
- [ ] **A random salt for hashing IPs** · free
- Why: Click rows store a daily-salted hash, never the raw address.
- Get it: openssl rand -hex 32 into .env as IP_SALT.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: The entire upgrade over Linktree is that the page lives on your domain.
- Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase.
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir links && cd links && git init && npm init -y && npm pkg set type=module
mkdir data public && cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- The drag-and-drop editor. Editing links.json is the trade for owning it.
- Their analytics beyond clicks per link. At this scale the only question is which link people press.
- Payments, link scheduling, and the integrations you were not using.
- the drag-and-drop editor
- their analytics dashboard
- hosted-for-you convenience
- integrations you probably weren't using
If one of those is essential to you, that is the reason to keep paying for Linktree Pro, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Linktree Pro
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a link-in-bio page to replace Linktree. Build it in phases, in the order
below. Do not write the whole thing in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- One Node process using `node:http` and `node:sqlite`. No Express, no framework,
no bundler.
- The page is server-rendered HTML with inlined CSS. No client JS is required for
a link to work · that is the whole product, and it must survive a broken script.
- SQLite file at a path from `.env` for click data only. Content lives in JSON.
### Data model (create this before Phase 1)
`links.json` is the CMS:
- `profile`: { name, bio, avatar, accent }
- `links`: array of { slug, title, url, emoji (optional), enabled (bool) }
- `slug` is the stable click-tracking key. Changing a title must never change a
slug, or the stats history silently resets.
`clicks` table: id, slug, clicked_at, referer, user_agent_class, ip_hash.
Classify the user agent into a coarse bucket (mobile/desktop/bot) at write time
and store the bucket, not the string. Hash the IP with a rotating daily salt.
### Phase 1 · The page
Build: render the profile and the enabled links as a vertical stack of buttons,
mobile-first, from links.json. Disabled links do not render at all. Validate
links.json at startup and refuse to boot with a readable error rather than
serving a broken page.
Done when: the page renders correctly at 375px with JS disabled, every link
navigates, and a malformed links.json stops the server with a message naming the
bad entry.
Do not build yet: tracking, stats, OG.
### Phase 2 · Design
Build: the visual layer · a clear typographic hierarchy, comfortable tap targets
(44px minimum), the accent color as a CSS custom property, dark mode via
prefers-color-scheme, subtle hover and active states, and a visible
focus-visible ring. Self-hosted font or a system stack, no external requests.
Done when: the page makes zero third-party network requests, passes an
accessibility audit with no contrast failures in both color schemes, and every
button is reachable and operable by keyboard.
### Phase 3 · Click tracking
Build: route every button through `GET /go/:slug`, which records one row and
issues a `302` to the target. Redirect unknown slugs to `/` rather than erroring.
Add `rel="noopener noreferrer"` on outbound links. Send the redirect before
writing if the write is slow · a click must never wait on the database.
Done when: clicking a link lands on the target with one new row, an unknown slug
lands on the homepage, and stopping SQLite mid-test still redirects correctly
while dropping the row.
### Phase 4 · Bot filtering and stats
Build: `/stats` behind basic auth from `.env` · clicks per link for today, 7 days
and 30 days, a clicks-per-day bar chart as inline SVG (no chart library), and
top referrers. Filter obvious bots at write time from a user-agent list and count
them separately rather than deleting them, so the numbers can be explained.
Done when: the totals reconcile with a `GROUP BY slug` query, a curl request with
a bot user agent is bucketed as bot and excluded from the headline number, and
the page renders with zero clicks recorded.
### Phase 5 · Share cards and deploy
Build: complete OG and Twitter meta plus a generated 1200x630 OG image, a
`/healthz` endpoint, a nightly SQLite backup command, a systemd unit, and deploy
notes for a VPS behind Caddy or nginx including the TLS and custom-domain step.
Done when: the link previews correctly in a card validator, and a reader goes
from clone to a live page on their own domain using only the README.
### Out of scope (and why)
- The drag-and-drop editor. Editing links.json is the tradeoff for owning it.
- Their analytics dashboard beyond clicks per link · you have the raw rows, and
the interesting question at this scale is only which link people press.
- Payments, link scheduling, and the integrations catalogue you were not using.
### README must contain
- The links.json reference, and the warning that slugs are permanent.
- How to add a link without breaking existing stats.
- A note that a link-in-bio page on your own domain is the actual upgrade here ·
the analytics are a bonus, the ownership is the point.
===== AGENTS.md =====
# Agent instructions · Linktree Pro indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, links.json, SQLite for clicks only, A small VPS behind Caddy. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
## Known traps
- No web fonts from a CDN. A fonts request is a third-party call and a performance deduction.
- Send the redirect before writing. A click must never wait on the database.
===== BUILD_PLAN.md =====
# Build plan · Linktree Pro
A link-in-bio page on your own domain: your links live in one JSON file, every button routes through a redirect that counts the click without a third party, and a private stats page shows what people actually press. Fast, dark-mode aware, and yours.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · The page
Render the profile and links from links.json, mobile-first, working with JavaScript disabled, and refuse to boot on a broken file.
### Steps
1. Create the project and links.json
profile (name, bio, avatar, accent) and links: array of {slug, title, url, emoji, enabled}. The slug is the permanent click-tracking key; changing a title must never change a slug.
Files: `links.json`, `server.mjs`
```sh
mkdir links && cd links && git init && npm init -y && npm pkg set type=module
mkdir data public && cp .env.example .env
```
2. Validate links.json at startup
Every url starts with http, every slug is unique and lowercase, avatar exists in public/. On failure exit with the bad entry named. A broken page is worse than a refused start.
3. Render the page server-side
Avatar, name, bio, then enabled links as a vertical stack of buttons. Disabled links do not render at all.
### Done when
- [ ] The page renders at 375px with JavaScript disabled and every link navigates
- [ ] A malformed links.json stops the server with a message naming the bad entry
- [ ] A link with enabled false is absent from the HTML
## Phase 2 · Design
Comfortable, accessible, no external requests.
### Steps
1. Set the typographic hierarchy and 44px tap targets
System font stack; buttons at least 44px tall with generous horizontal padding.
2. Read the accent into a CSS custom property and add dark mode
prefers-color-scheme with the same custom properties overridden. Check contrast in both schemes.
3. Add hover, active and focus-visible states
Keyboard visitors must see where they are.
### Done when
- [ ] Zero third-party network requests on load
- [ ] No contrast failures in either color scheme in an accessibility audit
- [ ] Every button is reachable and operable by keyboard
### Watch out
- No web fonts from a CDN. A fonts request is a third-party call and a performance deduction.
## Phase 3 · Click tracking
Every button goes through /go/:slug, which records a row and redirects, and the redirect never waits on the database.
### Steps
1. Create the clicks table
clicks (id, slug, clicked_at, referer_host, user_agent_class, ip_hash). Classify the user agent into mobile, desktop or bot at write time and store the bucket, not the string.
2. Implement GET /go/:slug
Look up the slug in links.json, send the 302 first, then insert the row. Unknown slugs redirect to / rather than erroring.
3. Hash the IP with a daily salt
sha256(IP_SALT + today's date + ip). The same visitor is one hash today and a different one tomorrow.
4. Add rel="noopener noreferrer" to outbound links
### Done when
- [ ] Clicking a link lands on the target and adds one row
- [ ] An unknown slug lands on the homepage
- [ ] Stopping SQLite mid-test still redirects correctly and drops the row
### Watch out
- Send the redirect before writing. A click must never wait on the database.
## Phase 4 · Bot filtering and stats
A private stats page whose numbers you can explain.
### Steps
1. Bucket bot user agents at write time
A short list (bot, crawler, spider, preview, slackbot, twitterbot, facebookexternalhit). Count them separately rather than deleting them.
2. Build /stats behind basic auth
Clicks per link over today, 7 and 30 days excluding bots, a clicks-per-day bar chart as inline SVG, top referrers, and the bot count shown separately.
### Done when
- [ ] Totals reconcile with a GROUP BY slug query
- [ ] A curl with a bot user agent is bucketed as bot and excluded from the headline number
- [ ] The page renders with zero clicks recorded
## Phase 5 · Share cards and deploy
Previews correctly when shared, live on your domain, documented.
### Steps
1. Add OG and Twitter meta plus a generated 1200x630 OG image
satori and @resvg/resvg-js at startup or build time, rendering your name on your accent.
```sh
npm install satori@0.29.0 @resvg/resvg-js@2.6.2
```
2. Add /healthz, a nightly backup command, and the systemd unit
Files: `deploy/links.service`, `Caddyfile`
```sh
sqlite3 data/clicks.db ".backup '/tmp/clicks-$(date +%F).db'"
```
3. Point the domain, deploy, write the README
README: the links.json reference, the warning that slugs are permanent, how to add a link without breaking stats, and a note that owning the domain is the actual upgrade.
Files: `README.md`
### Done when
- [ ] The link previews correctly in a card validator
- [ ] A reader goes from clone to a live page on their own domain using only the README
## Not in this build
- The drag-and-drop editor. Editing links.json is the trade for owning it.
- Their analytics beyond clicks per link. At this scale the only question is which link people press.
- Payments, link scheduling, and the integrations you were not using.
## After v1, if you want it
- A tiny basic-auth editor that writes links.json and commits it
- Per-link QR codes for print
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port; Caddy proxies to it.
PORT=3000
# Required. SQLite file for click rows.
DATABASE_PATH=./data/clicks.db
# Required. Public base URL for OG tags.
SITE_URL=https://links.yourname.com
# Required · secret. openssl rand -hex 32, once.
IP_SALT=hex-from-openssl-rand
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
You are building a lean indie version of Linktree Pro.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# Linktree Pro · indie build
A link-in-bio page on your own domain: your links live in one JSON file, every button routes through a redirect that counts the click without a third party, and a private stats page shows what people actually press. Fast, dark-mode aware, and yours.
Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | the page is server-rendered HTML and the redirect is one INSERT and a 302 |
| Content | links.json | editing a file and redeploying is the CMS; git is the history |
| Database | SQLite for clicks only | content is a file; only the counters need a database |
| Hosting | A small VPS behind Caddy | the redirect needs a process; the rest is static |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **Your links, title and bio, written down** · free
- Why: The page renders links.json exactly. Decide the order and titles first so Phase 1 is about rendering, not editing.
- Get it: A list of up to ten links with a short title each, a one-line bio, and a square avatar image (at least 400x400).
- [ ] **A random salt for hashing IPs** · free
- Why: Click rows store a daily-salted hash, never the raw address.
- Get it: openssl rand -hex 32 into .env as IP_SALT.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: The entire upgrade over Linktree is that the page lives on your domain.
- Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase.
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir links && cd links && git init && npm init -y && npm pkg set type=module
mkdir data public && cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- The drag-and-drop editor. Editing links.json is the trade for owning it.
- Their analytics beyond clicks per link. At this scale the only question is which link people press.
- Payments, link scheduling, and the integrations you were not using.
- the drag-and-drop editor
- their analytics dashboard
- hosted-for-you convenience
- integrations you probably weren't using
If one of those is essential to you, that is the reason to keep paying for Linktree Pro, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Linktree Pro
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a link-in-bio page to replace Linktree. Build it in phases, in the order
below. Do not write the whole thing in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- One Node process using `node:http` and `node:sqlite`. No Express, no framework,
no bundler.
- The page is server-rendered HTML with inlined CSS. No client JS is required for
a link to work · that is the whole product, and it must survive a broken script.
- SQLite file at a path from `.env` for click data only. Content lives in JSON.
### Data model (create this before Phase 1)
`links.json` is the CMS:
- `profile`: { name, bio, avatar, accent }
- `links`: array of { slug, title, url, emoji (optional), enabled (bool) }
- `slug` is the stable click-tracking key. Changing a title must never change a
slug, or the stats history silently resets.
`clicks` table: id, slug, clicked_at, referer, user_agent_class, ip_hash.
Classify the user agent into a coarse bucket (mobile/desktop/bot) at write time
and store the bucket, not the string. Hash the IP with a rotating daily salt.
### Phase 1 · The page
Build: render the profile and the enabled links as a vertical stack of buttons,
mobile-first, from links.json. Disabled links do not render at all. Validate
links.json at startup and refuse to boot with a readable error rather than
serving a broken page.
Done when: the page renders correctly at 375px with JS disabled, every link
navigates, and a malformed links.json stops the server with a message naming the
bad entry.
Do not build yet: tracking, stats, OG.
### Phase 2 · Design
Build: the visual layer · a clear typographic hierarchy, comfortable tap targets
(44px minimum), the accent color as a CSS custom property, dark mode via
prefers-color-scheme, subtle hover and active states, and a visible
focus-visible ring. Self-hosted font or a system stack, no external requests.
Done when: the page makes zero third-party network requests, passes an
accessibility audit with no contrast failures in both color schemes, and every
button is reachable and operable by keyboard.
### Phase 3 · Click tracking
Build: route every button through `GET /go/:slug`, which records one row and
issues a `302` to the target. Redirect unknown slugs to `/` rather than erroring.
Add `rel="noopener noreferrer"` on outbound links. Send the redirect before
writing if the write is slow · a click must never wait on the database.
Done when: clicking a link lands on the target with one new row, an unknown slug
lands on the homepage, and stopping SQLite mid-test still redirects correctly
while dropping the row.
### Phase 4 · Bot filtering and stats
Build: `/stats` behind basic auth from `.env` · clicks per link for today, 7 days
and 30 days, a clicks-per-day bar chart as inline SVG (no chart library), and
top referrers. Filter obvious bots at write time from a user-agent list and count
them separately rather than deleting them, so the numbers can be explained.
Done when: the totals reconcile with a `GROUP BY slug` query, a curl request with
a bot user agent is bucketed as bot and excluded from the headline number, and
the page renders with zero clicks recorded.
### Phase 5 · Share cards and deploy
Build: complete OG and Twitter meta plus a generated 1200x630 OG image, a
`/healthz` endpoint, a nightly SQLite backup command, a systemd unit, and deploy
notes for a VPS behind Caddy or nginx including the TLS and custom-domain step.
Done when: the link previews correctly in a card validator, and a reader goes
from clone to a live page on their own domain using only the README.
### Out of scope (and why)
- The drag-and-drop editor. Editing links.json is the tradeoff for owning it.
- Their analytics dashboard beyond clicks per link · you have the raw rows, and
the interesting question at this scale is only which link people press.
- Payments, link scheduling, and the integrations catalogue you were not using.
### README must contain
- The links.json reference, and the warning that slugs are permanent.
- How to add a link without breaking existing stats.
- A note that a link-in-bio page on your own domain is the actual upgrade here ·
the analytics are a bonus, the ownership is the point.
===== AGENTS.md =====
# Agent instructions · Linktree Pro indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, links.json, SQLite for clicks only, A small VPS behind Caddy. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
## Known traps
- No web fonts from a CDN. A fonts request is a third-party call and a performance deduction.
- Send the redirect before writing. A click must never wait on the database.
===== BUILD_PLAN.md =====
# Build plan · Linktree Pro
A link-in-bio page on your own domain: your links live in one JSON file, every button routes through a redirect that counts the click without a third party, and a private stats page shows what people actually press. Fast, dark-mode aware, and yours.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · The page
Render the profile and links from links.json, mobile-first, working with JavaScript disabled, and refuse to boot on a broken file.
### Steps
1. Create the project and links.json
profile (name, bio, avatar, accent) and links: array of {slug, title, url, emoji, enabled}. The slug is the permanent click-tracking key; changing a title must never change a slug.
Files: `links.json`, `server.mjs`
```sh
mkdir links && cd links && git init && npm init -y && npm pkg set type=module
mkdir data public && cp .env.example .env
```
2. Validate links.json at startup
Every url starts with http, every slug is unique and lowercase, avatar exists in public/. On failure exit with the bad entry named. A broken page is worse than a refused start.
3. Render the page server-side
Avatar, name, bio, then enabled links as a vertical stack of buttons. Disabled links do not render at all.
### Done when
- [ ] The page renders at 375px with JavaScript disabled and every link navigates
- [ ] A malformed links.json stops the server with a message naming the bad entry
- [ ] A link with enabled false is absent from the HTML
## Phase 2 · Design
Comfortable, accessible, no external requests.
### Steps
1. Set the typographic hierarchy and 44px tap targets
System font stack; buttons at least 44px tall with generous horizontal padding.
2. Read the accent into a CSS custom property and add dark mode
prefers-color-scheme with the same custom properties overridden. Check contrast in both schemes.
3. Add hover, active and focus-visible states
Keyboard visitors must see where they are.
### Done when
- [ ] Zero third-party network requests on load
- [ ] No contrast failures in either color scheme in an accessibility audit
- [ ] Every button is reachable and operable by keyboard
### Watch out
- No web fonts from a CDN. A fonts request is a third-party call and a performance deduction.
## Phase 3 · Click tracking
Every button goes through /go/:slug, which records a row and redirects, and the redirect never waits on the database.
### Steps
1. Create the clicks table
clicks (id, slug, clicked_at, referer_host, user_agent_class, ip_hash). Classify the user agent into mobile, desktop or bot at write time and store the bucket, not the string.
2. Implement GET /go/:slug
Look up the slug in links.json, send the 302 first, then insert the row. Unknown slugs redirect to / rather than erroring.
3. Hash the IP with a daily salt
sha256(IP_SALT + today's date + ip). The same visitor is one hash today and a different one tomorrow.
4. Add rel="noopener noreferrer" to outbound links
### Done when
- [ ] Clicking a link lands on the target and adds one row
- [ ] An unknown slug lands on the homepage
- [ ] Stopping SQLite mid-test still redirects correctly and drops the row
### Watch out
- Send the redirect before writing. A click must never wait on the database.
## Phase 4 · Bot filtering and stats
A private stats page whose numbers you can explain.
### Steps
1. Bucket bot user agents at write time
A short list (bot, crawler, spider, preview, slackbot, twitterbot, facebookexternalhit). Count them separately rather than deleting them.
2. Build /stats behind basic auth
Clicks per link over today, 7 and 30 days excluding bots, a clicks-per-day bar chart as inline SVG, top referrers, and the bot count shown separately.
### Done when
- [ ] Totals reconcile with a GROUP BY slug query
- [ ] A curl with a bot user agent is bucketed as bot and excluded from the headline number
- [ ] The page renders with zero clicks recorded
## Phase 5 · Share cards and deploy
Previews correctly when shared, live on your domain, documented.
### Steps
1. Add OG and Twitter meta plus a generated 1200x630 OG image
satori and @resvg/resvg-js at startup or build time, rendering your name on your accent.
```sh
npm install satori@0.29.0 @resvg/resvg-js@2.6.2
```
2. Add /healthz, a nightly backup command, and the systemd unit
Files: `deploy/links.service`, `Caddyfile`
```sh
sqlite3 data/clicks.db ".backup '/tmp/clicks-$(date +%F).db'"
```
3. Point the domain, deploy, write the README
README: the links.json reference, the warning that slugs are permanent, how to add a link without breaking stats, and a note that owning the domain is the actual upgrade.
Files: `README.md`
### Done when
- [ ] The link previews correctly in a card validator
- [ ] A reader goes from clone to a live page on their own domain using only the README
## Not in this build
- The drag-and-drop editor. Editing links.json is the trade for owning it.
- Their analytics beyond clicks per link. At this scale the only question is which link people press.
- Payments, link scheduling, and the integrations you were not using.
## After v1, if you want it
- A tiny basic-auth editor that writes links.json and commits it
- Per-link QR codes for print
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port; Caddy proxies to it.
PORT=3000
# Required. SQLite file for click rows.
DATABASE_PATH=./data/clicks.db
# Required. Public base URL for OG tags.
SITE_URL=https://links.yourname.com
# Required · secret. openssl rand -hex 32, once.
IP_SALT=hex-from-openssl-rand
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
You are building a production product version of Linktree Pro.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== PRODUCT.md =====
# Linktree Pro · product brief
## Problem
A static page. The most obviously one-shottable thing on this list.
## Product outcome
A link page you could run for a small roster of people or brands: content as files, click data you own, a domain that never changes hands.
## Target user
A builder who needs a maintainable product foundation, not a one-off demo.
## Required capabilities
- Implement the core workflow described in ARCHITECTURE.md
## Explicit non-goals for v1
- The drag-and-drop editor. Editing links.json is the trade for owning it.
- Their analytics beyond clicks per link. At this scale the only question is which link people press.
- Payments, link scheduling, and the integrations you were not using.
- the drag-and-drop editor
- their analytics dashboard
- hosted-for-you convenience
- integrations you probably weren't using
## Success criteria
- A clean clone reaches a live page using only the README
- Bot traffic verified excluded from headline numbers with a seeded fixture
- One restore drill performed and dated
- Lighthouse 100 on Performance and Accessibility
===== BRIEF.md =====
# Build brief · Linktree Pro
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a link-in-bio page to replace Linktree. Build it in phases, in the order
below. Do not write the whole thing in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- One Node process using `node:http` and `node:sqlite`. No Express, no framework,
no bundler.
- The page is server-rendered HTML with inlined CSS. No client JS is required for
a link to work · that is the whole product, and it must survive a broken script.
- SQLite file at a path from `.env` for click data only. Content lives in JSON.
### Data model (create this before Phase 1)
`links.json` is the CMS:
- `profile`: { name, bio, avatar, accent }
- `links`: array of { slug, title, url, emoji (optional), enabled (bool) }
- `slug` is the stable click-tracking key. Changing a title must never change a
slug, or the stats history silently resets.
`clicks` table: id, slug, clicked_at, referer, user_agent_class, ip_hash.
Classify the user agent into a coarse bucket (mobile/desktop/bot) at write time
and store the bucket, not the string. Hash the IP with a rotating daily salt.
### Phase 1 · The page
Build: render the profile and the enabled links as a vertical stack of buttons,
mobile-first, from links.json. Disabled links do not render at all. Validate
links.json at startup and refuse to boot with a readable error rather than
serving a broken page.
Done when: the page renders correctly at 375px with JS disabled, every link
navigates, and a malformed links.json stops the server with a message naming the
bad entry.
Do not build yet: tracking, stats, OG.
### Phase 2 · Design
Build: the visual layer · a clear typographic hierarchy, comfortable tap targets
(44px minimum), the accent color as a CSS custom property, dark mode via
prefers-color-scheme, subtle hover and active states, and a visible
focus-visible ring. Self-hosted font or a system stack, no external requests.
Done when: the page makes zero third-party network requests, passes an
accessibility audit with no contrast failures in both color schemes, and every
button is reachable and operable by keyboard.
### Phase 3 · Click tracking
Build: route every button through `GET /go/:slug`, which records one row and
issues a `302` to the target. Redirect unknown slugs to `/` rather than erroring.
Add `rel="noopener noreferrer"` on outbound links. Send the redirect before
writing if the write is slow · a click must never wait on the database.
Done when: clicking a link lands on the target with one new row, an unknown slug
lands on the homepage, and stopping SQLite mid-test still redirects correctly
while dropping the row.
### Phase 4 · Bot filtering and stats
Build: `/stats` behind basic auth from `.env` · clicks per link for today, 7 days
and 30 days, a clicks-per-day bar chart as inline SVG (no chart library), and
top referrers. Filter obvious bots at write time from a user-agent list and count
them separately rather than deleting them, so the numbers can be explained.
Done when: the totals reconcile with a `GROUP BY slug` query, a curl request with
a bot user agent is bucketed as bot and excluded from the headline number, and
the page renders with zero clicks recorded.
### Phase 5 · Share cards and deploy
Build: complete OG and Twitter meta plus a generated 1200x630 OG image, a
`/healthz` endpoint, a nightly SQLite backup command, a systemd unit, and deploy
notes for a VPS behind Caddy or nginx including the TLS and custom-domain step.
Done when: the link previews correctly in a card validator, and a reader goes
from clone to a live page on their own domain using only the README.
### Out of scope (and why)
- The drag-and-drop editor. Editing links.json is the tradeoff for owning it.
- Their analytics dashboard beyond clicks per link · you have the raw rows, and
the interesting question at this scale is only which link people press.
- Payments, link scheduling, and the integrations catalogue you were not using.
### README must contain
- The links.json reference, and the warning that slugs are permanent.
- How to add a link without breaking existing stats.
- A note that a link-in-bio page on your own domain is the actual upgrade here ·
the analytics are a bonus, the ownership is the point.
===== ARCHITECTURE.md =====
# Architecture · Linktree Pro
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | the page is server-rendered HTML and the redirect is one INSERT and a 302 |
| Content | links.json | editing a file and redeploying is the CMS; git is the history |
| Database | SQLite for clicks only | content is a file; only the counters need a database |
| Hosting | A small VPS behind Caddy | the redirect needs a process; the rest is static |
## Modules
Each module has one owner concern and a documented way to replace it.
| Module | Owns | How to replace it |
| --- | --- | --- |
| Content | links.json and startup validation | Any source yielding the same object; a small admin UI could write the file |
| Renderer | the server-rendered page and OG image | Could be a static build; the /go routes are the only dynamic part |
| Redirector | /go/:slug, bucketing, hashing | A serverless function writing the same rows |
| Stats | basic-auth reporting | Any UI over the clicks table |
## Configuration
Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists.
- `PORT` · required · Any free port; Caddy proxies to it.
- `DATABASE_PATH` · required · SQLite file for click rows.
- `SITE_URL` · required · Public base URL for OG tags.
- `IP_SALT` · required, secret · openssl rand -hex 32, once.
- `ADMIN_USER` · required · Any username for the basic-auth admin pages.
- `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password.
## Production baseline
- Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives.
- Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised.
- Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts.
- Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists.
- Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path.
## Decision records
For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
===== AGENTS.md =====
# Agent instructions · Linktree Pro product build
- Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, node:http and node:sqlite, links.json, SQLite for clicks only, A small VPS behind Caddy.
- Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit.
- Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present.
- Never invent cryptography or silently weaken a requirement to make a check pass.
- Put every external service behind an interface with a deterministic fake for tests.
- Add migrations and rollback or recovery notes for every persistent data change.
- Log useful operational context without credentials, tokens, passwords or personal data.
- Update documentation and run every check before completing a milestone.
## Known traps
- No web fonts from a CDN. A fonts request is a third-party call and a performance deduction.
- Send the redirect before writing. A click must never wait on the database.
===== MILESTONES.md =====
# Delivery milestones · Linktree Pro
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · The page
Render the profile and links from links.json, mobile-first, working with JavaScript disabled, and refuse to boot on a broken file.
### Steps
1. Create the project and links.json
profile (name, bio, avatar, accent) and links: array of {slug, title, url, emoji, enabled}. The slug is the permanent click-tracking key; changing a title must never change a slug.
Files: `links.json`, `server.mjs`
```sh
mkdir links && cd links && git init && npm init -y && npm pkg set type=module
mkdir data public && cp .env.example .env
```
2. Validate links.json at startup
Every url starts with http, every slug is unique and lowercase, avatar exists in public/. On failure exit with the bad entry named. A broken page is worse than a refused start.
3. Render the page server-side
Avatar, name, bio, then enabled links as a vertical stack of buttons. Disabled links do not render at all.
### Done when
- [ ] The page renders at 375px with JavaScript disabled and every link navigates
- [ ] A malformed links.json stops the server with a message naming the bad entry
- [ ] A link with enabled false is absent from the HTML
## M2 · Design
Comfortable, accessible, no external requests.
### Steps
1. Set the typographic hierarchy and 44px tap targets
System font stack; buttons at least 44px tall with generous horizontal padding.
2. Read the accent into a CSS custom property and add dark mode
prefers-color-scheme with the same custom properties overridden. Check contrast in both schemes.
3. Add hover, active and focus-visible states
Keyboard visitors must see where they are.
### Done when
- [ ] Zero third-party network requests on load
- [ ] No contrast failures in either color scheme in an accessibility audit
- [ ] Every button is reachable and operable by keyboard
### Watch out
- No web fonts from a CDN. A fonts request is a third-party call and a performance deduction.
## M3 · Click tracking
Every button goes through /go/:slug, which records a row and redirects, and the redirect never waits on the database.
### Steps
1. Create the clicks table
clicks (id, slug, clicked_at, referer_host, user_agent_class, ip_hash). Classify the user agent into mobile, desktop or bot at write time and store the bucket, not the string.
2. Implement GET /go/:slug
Look up the slug in links.json, send the 302 first, then insert the row. Unknown slugs redirect to / rather than erroring.
3. Hash the IP with a daily salt
sha256(IP_SALT + today's date + ip). The same visitor is one hash today and a different one tomorrow.
4. Add rel="noopener noreferrer" to outbound links
### Done when
- [ ] Clicking a link lands on the target and adds one row
- [ ] An unknown slug lands on the homepage
- [ ] Stopping SQLite mid-test still redirects correctly and drops the row
### Watch out
- Send the redirect before writing. A click must never wait on the database.
## M4 · Bot filtering and stats
A private stats page whose numbers you can explain.
### Steps
1. Bucket bot user agents at write time
A short list (bot, crawler, spider, preview, slackbot, twitterbot, facebookexternalhit). Count them separately rather than deleting them.
2. Build /stats behind basic auth
Clicks per link over today, 7 and 30 days excluding bots, a clicks-per-day bar chart as inline SVG, top referrers, and the bot count shown separately.
### Done when
- [ ] Totals reconcile with a GROUP BY slug query
- [ ] A curl with a bot user agent is bucketed as bot and excluded from the headline number
- [ ] The page renders with zero clicks recorded
## M5 · Share cards and deploy
Previews correctly when shared, live on your domain, documented.
### Steps
1. Add OG and Twitter meta plus a generated 1200x630 OG image
satori and @resvg/resvg-js at startup or build time, rendering your name on your accent.
```sh
npm install satori@0.29.0 @resvg/resvg-js@2.6.2
```
2. Add /healthz, a nightly backup command, and the systemd unit
Files: `deploy/links.service`, `Caddyfile`
```sh
sqlite3 data/clicks.db ".backup '/tmp/clicks-$(date +%F).db'"
```
3. Point the domain, deploy, write the README
README: the links.json reference, the warning that slugs are permanent, how to add a link without breaking stats, and a note that owning the domain is the actual upgrade.
Files: `README.md`
### Done when
- [ ] The link previews correctly in a card validator
- [ ] A reader goes from clone to a live page on their own domain using only the README
## M6 · Operate it like a product (production only)
Only for the product-builder path: know when the redirect is down, never lose the database, and keep the server patched.
### Steps
1. Add a /healthz endpoint and an external uptime check against it
Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices.
2. Write structured request logs and rotate them
One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight.
3. Back the SQLite file up off the machine nightly and test a restore
SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it.
```sh
sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'"
rclone copy /tmp/app-$(date +%F).db remote:backups/
```
4. Lock the box down
Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure.
### Done when
- [ ] Stopping the service triggers an uptime alert within a few minutes
- [ ] A restore from last night's backup contains yesterday's data
- [ ] A port scan from another machine shows only 22, 80 and 443
===== OPERATIONS.md =====
# Operations · Linktree Pro
## Backup
links.json is in git. SQLite .backup nightly for clicks, thirty days kept.
## Restore
Deploy from git; copy the clicks backup into place. Nothing else exists.
Do a restore drill before the first real user, and write the date here when it passes.
## Monitoring
Uptime check on /healthz. A sudden zero in clicks per day during normal traffic means the redirect is broken.
## Incident checklist
A hijacked link in links.json is fixed by a commit and redeploy; the history shows when it changed.
1. Contain the issue without destroying evidence or user data.
2. Record the timeline and affected scope.
3. Rotate exposed secrets and revoke compromised sessions or credentials.
4. Restore from a verified backup when needed.
5. Document the root cause, the remediation and the regression test.
## Release gate
- [ ] A clean clone reaches a live page using only the README
- [ ] Bot traffic verified excluded from headline numbers with a seeded fixture
- [ ] One restore drill performed and dated
- [ ] Lighthouse 100 on Performance and Accessibility
## Launch constraint
Do not market omitted Linktree Pro capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port; Caddy proxies to it.
PORT=3000
# Required. SQLite file for click rows.
DATABASE_PATH=./data/clicks.db
# Required. Public base URL for OG tags.
SITE_URL=https://links.yourname.com
# Required · secret. openssl rand -hex 32, once.
IP_SALT=hex-from-openssl-rand
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
# Linktree Pro · indie build A link-in-bio page on your own domain: your links live in one JSON file, every button routes through a redirect that counts the click without a third party, and a private stats page shows what people actually press. Fast, dark-mode aware, and yours. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | the page is server-rendered HTML and the redirect is one INSERT and a 302 | | Content | links.json | editing a file and redeploying is the CMS; git is the history | | Database | SQLite for clicks only | content is a file; only the counters need a database | | Hosting | A small VPS behind Caddy | the redirect needs a process; the rest is static | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **Your links, title and bio, written down** · free - Why: The page renders links.json exactly. Decide the order and titles first so Phase 1 is about rendering, not editing. - Get it: A list of up to ten links with a short title each, a one-line bio, and a square avatar image (at least 400x400). - [ ] **A random salt for hashing IPs** · free - Why: Click rows store a daily-salted hash, never the raw address. - Get it: openssl rand -hex 32 into .env as IP_SALT. - [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain - Why: The entire upgrade over Linktree is that the page lives on your domain. - Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase. - [ ] **A small always-on server (VPS)** (optional) · about $5 a month - Why: This needs one process running all the time with a public address. - Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first. - [ ] **Caddy on the server** (optional) · free - Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work. - Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config. - Verify: caddy version prints a version on the server ## Quick start ```sh mkdir links && cd links && git init && npm init -y && npm pkg set type=module mkdir data public && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The drag-and-drop editor. Editing links.json is the trade for owning it. - Their analytics beyond clicks per link. At this scale the only question is which link people press. - Payments, link scheduling, and the integrations you were not using. - the drag-and-drop editor - their analytics dashboard - hosted-for-you convenience - integrations you probably weren't using If one of those is essential to you, that is the reason to keep paying for Linktree Pro, and the README should say so rather than pretend.
# Build brief · Linktree Pro
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a link-in-bio page to replace Linktree. Build it in phases, in the order
below. Do not write the whole thing in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- One Node process using `node:http` and `node:sqlite`. No Express, no framework,
no bundler.
- The page is server-rendered HTML with inlined CSS. No client JS is required for
a link to work · that is the whole product, and it must survive a broken script.
- SQLite file at a path from `.env` for click data only. Content lives in JSON.
### Data model (create this before Phase 1)
`links.json` is the CMS:
- `profile`: { name, bio, avatar, accent }
- `links`: array of { slug, title, url, emoji (optional), enabled (bool) }
- `slug` is the stable click-tracking key. Changing a title must never change a
slug, or the stats history silently resets.
`clicks` table: id, slug, clicked_at, referer, user_agent_class, ip_hash.
Classify the user agent into a coarse bucket (mobile/desktop/bot) at write time
and store the bucket, not the string. Hash the IP with a rotating daily salt.
### Phase 1 · The page
Build: render the profile and the enabled links as a vertical stack of buttons,
mobile-first, from links.json. Disabled links do not render at all. Validate
links.json at startup and refuse to boot with a readable error rather than
serving a broken page.
Done when: the page renders correctly at 375px with JS disabled, every link
navigates, and a malformed links.json stops the server with a message naming the
bad entry.
Do not build yet: tracking, stats, OG.
### Phase 2 · Design
Build: the visual layer · a clear typographic hierarchy, comfortable tap targets
(44px minimum), the accent color as a CSS custom property, dark mode via
prefers-color-scheme, subtle hover and active states, and a visible
focus-visible ring. Self-hosted font or a system stack, no external requests.
Done when: the page makes zero third-party network requests, passes an
accessibility audit with no contrast failures in both color schemes, and every
button is reachable and operable by keyboard.
### Phase 3 · Click tracking
Build: route every button through `GET /go/:slug`, which records one row and
issues a `302` to the target. Redirect unknown slugs to `/` rather than erroring.
Add `rel="noopener noreferrer"` on outbound links. Send the redirect before
writing if the write is slow · a click must never wait on the database.
Done when: clicking a link lands on the target with one new row, an unknown slug
lands on the homepage, and stopping SQLite mid-test still redirects correctly
while dropping the row.
### Phase 4 · Bot filtering and stats
Build: `/stats` behind basic auth from `.env` · clicks per link for today, 7 days
and 30 days, a clicks-per-day bar chart as inline SVG (no chart library), and
top referrers. Filter obvious bots at write time from a user-agent list and count
them separately rather than deleting them, so the numbers can be explained.
Done when: the totals reconcile with a `GROUP BY slug` query, a curl request with
a bot user agent is bucketed as bot and excluded from the headline number, and
the page renders with zero clicks recorded.
### Phase 5 · Share cards and deploy
Build: complete OG and Twitter meta plus a generated 1200x630 OG image, a
`/healthz` endpoint, a nightly SQLite backup command, a systemd unit, and deploy
notes for a VPS behind Caddy or nginx including the TLS and custom-domain step.
Done when: the link previews correctly in a card validator, and a reader goes
from clone to a live page on their own domain using only the README.
### Out of scope (and why)
- The drag-and-drop editor. Editing links.json is the tradeoff for owning it.
- Their analytics dashboard beyond clicks per link · you have the raw rows, and
the interesting question at this scale is only which link people press.
- Payments, link scheduling, and the integrations catalogue you were not using.
### README must contain
- The links.json reference, and the warning that slugs are permanent.
- How to add a link without breaking existing stats.
- A note that a link-in-bio page on your own domain is the actual upgrade here ·
the analytics are a bonus, the ownership is the point.# Agent instructions · Linktree Pro indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, links.json, SQLite for clicks only, A small VPS behind Caddy. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - No web fonts from a CDN. A fonts request is a third-party call and a performance deduction. - Send the redirect before writing. A click must never wait on the database.
# Build plan · Linktree Pro
A link-in-bio page on your own domain: your links live in one JSON file, every button routes through a redirect that counts the click without a third party, and a private stats page shows what people actually press. Fast, dark-mode aware, and yours.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · The page
Render the profile and links from links.json, mobile-first, working with JavaScript disabled, and refuse to boot on a broken file.
### Steps
1. Create the project and links.json
profile (name, bio, avatar, accent) and links: array of {slug, title, url, emoji, enabled}. The slug is the permanent click-tracking key; changing a title must never change a slug.
Files: `links.json`, `server.mjs`
```sh
mkdir links && cd links && git init && npm init -y && npm pkg set type=module
mkdir data public && cp .env.example .env
```
2. Validate links.json at startup
Every url starts with http, every slug is unique and lowercase, avatar exists in public/. On failure exit with the bad entry named. A broken page is worse than a refused start.
3. Render the page server-side
Avatar, name, bio, then enabled links as a vertical stack of buttons. Disabled links do not render at all.
### Done when
- [ ] The page renders at 375px with JavaScript disabled and every link navigates
- [ ] A malformed links.json stops the server with a message naming the bad entry
- [ ] A link with enabled false is absent from the HTML
## Phase 2 · Design
Comfortable, accessible, no external requests.
### Steps
1. Set the typographic hierarchy and 44px tap targets
System font stack; buttons at least 44px tall with generous horizontal padding.
2. Read the accent into a CSS custom property and add dark mode
prefers-color-scheme with the same custom properties overridden. Check contrast in both schemes.
3. Add hover, active and focus-visible states
Keyboard visitors must see where they are.
### Done when
- [ ] Zero third-party network requests on load
- [ ] No contrast failures in either color scheme in an accessibility audit
- [ ] Every button is reachable and operable by keyboard
### Watch out
- No web fonts from a CDN. A fonts request is a third-party call and a performance deduction.
## Phase 3 · Click tracking
Every button goes through /go/:slug, which records a row and redirects, and the redirect never waits on the database.
### Steps
1. Create the clicks table
clicks (id, slug, clicked_at, referer_host, user_agent_class, ip_hash). Classify the user agent into mobile, desktop or bot at write time and store the bucket, not the string.
2. Implement GET /go/:slug
Look up the slug in links.json, send the 302 first, then insert the row. Unknown slugs redirect to / rather than erroring.
3. Hash the IP with a daily salt
sha256(IP_SALT + today's date + ip). The same visitor is one hash today and a different one tomorrow.
4. Add rel="noopener noreferrer" to outbound links
### Done when
- [ ] Clicking a link lands on the target and adds one row
- [ ] An unknown slug lands on the homepage
- [ ] Stopping SQLite mid-test still redirects correctly and drops the row
### Watch out
- Send the redirect before writing. A click must never wait on the database.
## Phase 4 · Bot filtering and stats
A private stats page whose numbers you can explain.
### Steps
1. Bucket bot user agents at write time
A short list (bot, crawler, spider, preview, slackbot, twitterbot, facebookexternalhit). Count them separately rather than deleting them.
2. Build /stats behind basic auth
Clicks per link over today, 7 and 30 days excluding bots, a clicks-per-day bar chart as inline SVG, top referrers, and the bot count shown separately.
### Done when
- [ ] Totals reconcile with a GROUP BY slug query
- [ ] A curl with a bot user agent is bucketed as bot and excluded from the headline number
- [ ] The page renders with zero clicks recorded
## Phase 5 · Share cards and deploy
Previews correctly when shared, live on your domain, documented.
### Steps
1. Add OG and Twitter meta plus a generated 1200x630 OG image
satori and @resvg/resvg-js at startup or build time, rendering your name on your accent.
```sh
npm install satori@0.29.0 @resvg/resvg-js@2.6.2
```
2. Add /healthz, a nightly backup command, and the systemd unit
Files: `deploy/links.service`, `Caddyfile`
```sh
sqlite3 data/clicks.db ".backup '/tmp/clicks-$(date +%F).db'"
```
3. Point the domain, deploy, write the README
README: the links.json reference, the warning that slugs are permanent, how to add a link without breaking stats, and a note that owning the domain is the actual upgrade.
Files: `README.md`
### Done when
- [ ] The link previews correctly in a card validator
- [ ] A reader goes from clone to a live page on their own domain using only the README
## Not in this build
- The drag-and-drop editor. Editing links.json is the trade for owning it.
- Their analytics beyond clicks per link. At this scale the only question is which link people press.
- Payments, link scheduling, and the integrations you were not using.
## After v1, if you want it
- A tiny basic-auth editor that writes links.json and commits it
- Per-link QR codes for print# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file for click rows. DATABASE_PATH=./data/clicks.db # Required. Public base URL for OG tags. SITE_URL=https://links.yourname.com # Required · secret. openssl rand -hex 32, once. IP_SALT=hex-from-openssl-rand # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
# Linktree Pro · product brief ## Problem A static page. The most obviously one-shottable thing on this list. ## Product outcome A link page you could run for a small roster of people or brands: content as files, click data you own, a domain that never changes hands. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Implement the core workflow described in ARCHITECTURE.md ## Explicit non-goals for v1 - The drag-and-drop editor. Editing links.json is the trade for owning it. - Their analytics beyond clicks per link. At this scale the only question is which link people press. - Payments, link scheduling, and the integrations you were not using. - the drag-and-drop editor - their analytics dashboard - hosted-for-you convenience - integrations you probably weren't using ## Success criteria - A clean clone reaches a live page using only the README - Bot traffic verified excluded from headline numbers with a seeded fixture - One restore drill performed and dated - Lighthouse 100 on Performance and Accessibility
# Build brief · Linktree Pro
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a link-in-bio page to replace Linktree. Build it in phases, in the order
below. Do not write the whole thing in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- One Node process using `node:http` and `node:sqlite`. No Express, no framework,
no bundler.
- The page is server-rendered HTML with inlined CSS. No client JS is required for
a link to work · that is the whole product, and it must survive a broken script.
- SQLite file at a path from `.env` for click data only. Content lives in JSON.
### Data model (create this before Phase 1)
`links.json` is the CMS:
- `profile`: { name, bio, avatar, accent }
- `links`: array of { slug, title, url, emoji (optional), enabled (bool) }
- `slug` is the stable click-tracking key. Changing a title must never change a
slug, or the stats history silently resets.
`clicks` table: id, slug, clicked_at, referer, user_agent_class, ip_hash.
Classify the user agent into a coarse bucket (mobile/desktop/bot) at write time
and store the bucket, not the string. Hash the IP with a rotating daily salt.
### Phase 1 · The page
Build: render the profile and the enabled links as a vertical stack of buttons,
mobile-first, from links.json. Disabled links do not render at all. Validate
links.json at startup and refuse to boot with a readable error rather than
serving a broken page.
Done when: the page renders correctly at 375px with JS disabled, every link
navigates, and a malformed links.json stops the server with a message naming the
bad entry.
Do not build yet: tracking, stats, OG.
### Phase 2 · Design
Build: the visual layer · a clear typographic hierarchy, comfortable tap targets
(44px minimum), the accent color as a CSS custom property, dark mode via
prefers-color-scheme, subtle hover and active states, and a visible
focus-visible ring. Self-hosted font or a system stack, no external requests.
Done when: the page makes zero third-party network requests, passes an
accessibility audit with no contrast failures in both color schemes, and every
button is reachable and operable by keyboard.
### Phase 3 · Click tracking
Build: route every button through `GET /go/:slug`, which records one row and
issues a `302` to the target. Redirect unknown slugs to `/` rather than erroring.
Add `rel="noopener noreferrer"` on outbound links. Send the redirect before
writing if the write is slow · a click must never wait on the database.
Done when: clicking a link lands on the target with one new row, an unknown slug
lands on the homepage, and stopping SQLite mid-test still redirects correctly
while dropping the row.
### Phase 4 · Bot filtering and stats
Build: `/stats` behind basic auth from `.env` · clicks per link for today, 7 days
and 30 days, a clicks-per-day bar chart as inline SVG (no chart library), and
top referrers. Filter obvious bots at write time from a user-agent list and count
them separately rather than deleting them, so the numbers can be explained.
Done when: the totals reconcile with a `GROUP BY slug` query, a curl request with
a bot user agent is bucketed as bot and excluded from the headline number, and
the page renders with zero clicks recorded.
### Phase 5 · Share cards and deploy
Build: complete OG and Twitter meta plus a generated 1200x630 OG image, a
`/healthz` endpoint, a nightly SQLite backup command, a systemd unit, and deploy
notes for a VPS behind Caddy or nginx including the TLS and custom-domain step.
Done when: the link previews correctly in a card validator, and a reader goes
from clone to a live page on their own domain using only the README.
### Out of scope (and why)
- The drag-and-drop editor. Editing links.json is the tradeoff for owning it.
- Their analytics dashboard beyond clicks per link · you have the raw rows, and
the interesting question at this scale is only which link people press.
- Payments, link scheduling, and the integrations catalogue you were not using.
### README must contain
- The links.json reference, and the warning that slugs are permanent.
- How to add a link without breaking existing stats.
- A note that a link-in-bio page on your own domain is the actual upgrade here ·
the analytics are a bonus, the ownership is the point.# Architecture · Linktree Pro ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | the page is server-rendered HTML and the redirect is one INSERT and a 302 | | Content | links.json | editing a file and redeploying is the CMS; git is the history | | Database | SQLite for clicks only | content is a file; only the counters need a database | | Hosting | A small VPS behind Caddy | the redirect needs a process; the rest is static | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Content | links.json and startup validation | Any source yielding the same object; a small admin UI could write the file | | Renderer | the server-rendered page and OG image | Could be a static build; the /go routes are the only dynamic part | | Redirector | /go/:slug, bucketing, hashing | A serverless function writing the same rows | | Stats | basic-auth reporting | Any UI over the clicks table | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Any free port; Caddy proxies to it. - `DATABASE_PATH` · required · SQLite file for click rows. - `SITE_URL` · required · Public base URL for OG tags. - `IP_SALT` · required, secret · openssl rand -hex 32, once. - `ADMIN_USER` · required · Any username for the basic-auth admin pages. - `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Linktree Pro product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, node:http and node:sqlite, links.json, SQLite for clicks only, A small VPS behind Caddy. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - No web fonts from a CDN. A fonts request is a third-party call and a performance deduction. - Send the redirect before writing. A click must never wait on the database.
# Delivery milestones · Linktree Pro
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · The page
Render the profile and links from links.json, mobile-first, working with JavaScript disabled, and refuse to boot on a broken file.
### Steps
1. Create the project and links.json
profile (name, bio, avatar, accent) and links: array of {slug, title, url, emoji, enabled}. The slug is the permanent click-tracking key; changing a title must never change a slug.
Files: `links.json`, `server.mjs`
```sh
mkdir links && cd links && git init && npm init -y && npm pkg set type=module
mkdir data public && cp .env.example .env
```
2. Validate links.json at startup
Every url starts with http, every slug is unique and lowercase, avatar exists in public/. On failure exit with the bad entry named. A broken page is worse than a refused start.
3. Render the page server-side
Avatar, name, bio, then enabled links as a vertical stack of buttons. Disabled links do not render at all.
### Done when
- [ ] The page renders at 375px with JavaScript disabled and every link navigates
- [ ] A malformed links.json stops the server with a message naming the bad entry
- [ ] A link with enabled false is absent from the HTML
## M2 · Design
Comfortable, accessible, no external requests.
### Steps
1. Set the typographic hierarchy and 44px tap targets
System font stack; buttons at least 44px tall with generous horizontal padding.
2. Read the accent into a CSS custom property and add dark mode
prefers-color-scheme with the same custom properties overridden. Check contrast in both schemes.
3. Add hover, active and focus-visible states
Keyboard visitors must see where they are.
### Done when
- [ ] Zero third-party network requests on load
- [ ] No contrast failures in either color scheme in an accessibility audit
- [ ] Every button is reachable and operable by keyboard
### Watch out
- No web fonts from a CDN. A fonts request is a third-party call and a performance deduction.
## M3 · Click tracking
Every button goes through /go/:slug, which records a row and redirects, and the redirect never waits on the database.
### Steps
1. Create the clicks table
clicks (id, slug, clicked_at, referer_host, user_agent_class, ip_hash). Classify the user agent into mobile, desktop or bot at write time and store the bucket, not the string.
2. Implement GET /go/:slug
Look up the slug in links.json, send the 302 first, then insert the row. Unknown slugs redirect to / rather than erroring.
3. Hash the IP with a daily salt
sha256(IP_SALT + today's date + ip). The same visitor is one hash today and a different one tomorrow.
4. Add rel="noopener noreferrer" to outbound links
### Done when
- [ ] Clicking a link lands on the target and adds one row
- [ ] An unknown slug lands on the homepage
- [ ] Stopping SQLite mid-test still redirects correctly and drops the row
### Watch out
- Send the redirect before writing. A click must never wait on the database.
## M4 · Bot filtering and stats
A private stats page whose numbers you can explain.
### Steps
1. Bucket bot user agents at write time
A short list (bot, crawler, spider, preview, slackbot, twitterbot, facebookexternalhit). Count them separately rather than deleting them.
2. Build /stats behind basic auth
Clicks per link over today, 7 and 30 days excluding bots, a clicks-per-day bar chart as inline SVG, top referrers, and the bot count shown separately.
### Done when
- [ ] Totals reconcile with a GROUP BY slug query
- [ ] A curl with a bot user agent is bucketed as bot and excluded from the headline number
- [ ] The page renders with zero clicks recorded
## M5 · Share cards and deploy
Previews correctly when shared, live on your domain, documented.
### Steps
1. Add OG and Twitter meta plus a generated 1200x630 OG image
satori and @resvg/resvg-js at startup or build time, rendering your name on your accent.
```sh
npm install satori@0.29.0 @resvg/resvg-js@2.6.2
```
2. Add /healthz, a nightly backup command, and the systemd unit
Files: `deploy/links.service`, `Caddyfile`
```sh
sqlite3 data/clicks.db ".backup '/tmp/clicks-$(date +%F).db'"
```
3. Point the domain, deploy, write the README
README: the links.json reference, the warning that slugs are permanent, how to add a link without breaking stats, and a note that owning the domain is the actual upgrade.
Files: `README.md`
### Done when
- [ ] The link previews correctly in a card validator
- [ ] A reader goes from clone to a live page on their own domain using only the README
## M6 · Operate it like a product (production only)
Only for the product-builder path: know when the redirect is down, never lose the database, and keep the server patched.
### Steps
1. Add a /healthz endpoint and an external uptime check against it
Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices.
2. Write structured request logs and rotate them
One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight.
3. Back the SQLite file up off the machine nightly and test a restore
SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it.
```sh
sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'"
rclone copy /tmp/app-$(date +%F).db remote:backups/
```
4. Lock the box down
Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure.
### Done when
- [ ] Stopping the service triggers an uptime alert within a few minutes
- [ ] A restore from last night's backup contains yesterday's data
- [ ] A port scan from another machine shows only 22, 80 and 443# Operations · Linktree Pro ## Backup links.json is in git. SQLite .backup nightly for clicks, thirty days kept. ## Restore Deploy from git; copy the clicks backup into place. Nothing else exists. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Uptime check on /healthz. A sudden zero in clicks per day during normal traffic means the redirect is broken. ## Incident checklist A hijacked link in links.json is fixed by a commit and redeploy; the history shows when it changed. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A clean clone reaches a live page using only the README - [ ] Bot traffic verified excluded from headline numbers with a seeded fixture - [ ] One restore drill performed and dated - [ ] Lighthouse 100 on Performance and Accessibility ## Launch constraint Do not market omitted Linktree Pro capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file for click rows. DATABASE_PATH=./data/clicks.db # Required. Public base URL for OG tags. SITE_URL=https://links.yourname.com # Required · secret. openssl rand -hex 32, once. IP_SALT=hex-from-openssl-rand # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
$ choose a build depth, inspect the files, then open the complete pack in your agent
xthe drag-and-drop editor
xtheir analytics dashboard
xhosted-for-you convenience
xintegrations you probably weren't using
Don't feel like building it? These folks already made it free.
all 3 free alternatives to Linktree Pro →· no votes, no pay-to-list · just what's real
Linktree Pro pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0 | $0 | Unlimited basic links; current public page does not state a numeric analytics-history limit. |
| starter | $8 | $6 | 1 Linktree profile; unlimited basic links; basic analytics; 9% seller fee on digital products. |
| pro | $15 | $12 | 1 Linktree profile; advanced analytics and integrations; 7-day trial. |
| premium | $35 | $30 | 1 Linktree profile; advanced analytics/export, social scheduling and unlimited Instagram auto-replies; 7-day trial. |
| agency / enterprise | custom | — | Custom team seats and contract; numeric limits are not public. |
free tierUnlimited basic links; numeric analytics-retention and audience limits are not published on the live plan card.
billingmonthly + annual; Pro and Premium include 7-day trials; cancel anytime
hidden costsStarter takes a 9% seller fee; Premium lists 0%, while transaction/payment-processing fees can still apply. Other plan-level seller fees were not exposed in the live comparison.
verified 2026-08-11 · source ↗
Is Linktree Pro free?
The free plan allows unlimited links with basic analytics. Paid is the paid plan at $15/mo (checked 2026-08-07).
Vibecode Linktree Pro
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Linktree Pro replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Linktree Pro cost?
Linktree Pro costs about $15/month (paid plan, checked 2026-08-07), which is $180 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Linktree Pro?
Honestly: the drag-and-drop editor; their analytics dashboard; hosted-for-you convenience; integrations you probably weren't using. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Linktree Pro?
Yes: LinkStack (Linktree without the landlord: editor, themes and click stats, all in one Docker stack.) Carrd (Three free one-page sites; your custom domain and the last scrap of vanity still cost money.) Linktree Free (The same static page with unlimited basic links for zero dollars; branding and serious analytics remain the rent.) All 3 curated free alternatives are at vibecodeit.com/linktree/alternatives. The prompt is for when you want it exactly your way.