Build Linktree Pro

YESreplaces $15/mosaves $180/yrback to the verdict

0%0 of 23 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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 sittingthe files for this build are in the project pack

RuntimeNode 22, node:http and node:sqliteContentlinks.jsonDatabaseSQLite for clicks onlyHostingA small VPS behind Caddy

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    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. open ↗

    Verify node --version prints v22 or higher

  2. installfree

    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. open ↗

    Verify You can open a folder and run a command in its terminal

  3. installfree

    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. open ↗

    Verify git --version prints a version

  4. have readyfree

    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).

  5. decidefree

    Why Click rows store a daily-salted hash, never the raw address.

    Get it openssl rand -hex 32 into .env as IP_SALT.

  6. 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. open ↗

  7. 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. open ↗

  8. installfree

    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. open ↗

    Verify caddy version prints a version on the server

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

`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.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
PORTrequired3000Any free port; Caddy proxies to it.
DATABASE_PATHrequired./data/clicks.dbSQLite file for click rows.
SITE_URLrequiredhttps://links.yourname.comPublic base URL for OG tags.
IP_SALTsecretrequiredhex-from-openssl-randopenssl rand -hex 32, once.
ADMIN_USERrequiredadminAny username for the basic-auth admin pages.
ADMIN_PASSsecretrequiredchange-me-to-a-long-random-stringGenerate one: openssl rand -base64 24. Never reuse a real password.

The build, in order

  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.

    1. 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.jsonserver.mjs

      terminal
      mkdir links && cd links && git init && npm init -y && npm pkg set type=module
      mkdir data public && cp .env.example .env
    2. 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. Avatar, name, bio, then enabled links as a vertical stack of buttons. Disabled links do not render at all.

    done when · tick each as it passes
  2. Design

    Comfortable, accessible, no external requests.

    1. System font stack; buttons at least 44px tall with generous horizontal padding.

    2. prefers-color-scheme with the same custom properties overridden. Check contrast in both schemes.

    3. Keyboard visitors must see where they are.

    done when · tick each as it passes
    watch out
    • No web fonts from a CDN. A fonts request is a third-party call and a performance deduction.
  3. Click tracking

    Every button goes through /go/:slug, which records a row and redirects, and the redirect never waits on the database.

    1. 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. Look up the slug in links.json, send the 302 first, then insert the row. Unknown slugs redirect to / rather than erroring.

    3. sha256(IP_SALT + today's date + ip). The same visitor is one hash today and a different one tomorrow.

    done when · tick each as it passes
    watch out
    • Send the redirect before writing. A click must never wait on the database.
  4. Bot filtering and stats

    A private stats page whose numbers you can explain.

    1. A short list (bot, crawler, spider, preview, slackbot, twitterbot, facebookexternalhit). Count them separately rather than deleting them.

    2. 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 · tick each as it passes
  5. Share cards and deploy

    Previews correctly when shared, live on your domain, documented.

    1. satori and @resvg/resvg-js at startup or build time, rendering your name on your accent.

      terminal
      npm install satori@0.29.0 @resvg/resvg-js@2.6.2
    2. Files deploy/links.serviceCaddyfile

      terminal
      sqlite3 data/clicks.db ".backup '/tmp/clicks-$(date +%F).db'"
    3. 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 · tick each as it passes
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more link in bio.