Linktree pages are everywhere. Creators, bands, podcasts, shops, and plenty of brands put one linktr.ee/... URL in their bio and send everything through it. So if you manage a few creators, run an agency, or just want to know when your own page or a client's page changes, it's useful to read those pages as data: who the profile is, which links it shows, in what order, and which social icons it lists.
People usually start by searching for "Linktree API". There is official tooling, but it's built for managing pages you own, not for reading other people's pages. This post covers both: what Linktree officially offers, and how to build a small Linktree reader in Node.js for public profile pages. It's one file, no npm dependencies, and it uses Node's built-in node:sqlite (Node 22.5+). It will:
- Fetch a profile page politely and treat 404s and blocks properly
- Pull the page data out of the Next.js
__NEXT_DATA__script - Turn that into one clean object: profile, ordered links, and social icons
- Store snapshots in SQLite and report which links were added, removed, retitled, or re-pointed
- Check where every link actually goes, so you catch dead links
Everything below was run on November 4, 2026, against a few well-known public profiles. The output is real.
Read this first. Linktree's Terms of Service say that you must not, as a user, a profile visitor, or in any other capacity, use scripts, bots, scraping tools, or other automated means to access, download, extract, aggregate, or otherwise collect content or information from Linktree user profiles or the platform. The robots.txt agrees: it names a list of search and AI crawlers, and everything else falls under
User-agent: */Disallow: /, with the comment "Fallback, disallow scraping". Your script falls under that rule. So running this code against Linktree profiles goes against their terms, and you should review those terms and your legal risk before you run anything. Where an official option covers your need (see the next section), use it instead. If you go ahead anyway, keep it to a handful of pages you have a real reason to read (your own, or a client's with their permission), go slowly, don't republish or resell what you collect, and don't build contact lists from people's pages.
Is there a Linktree API?
Sort of. I checked what Linktree actually publishes, and found three things:
-
The Linktree MCP server (
https://mcp.linktr.ee/mcp, tool reference atmcp.linktr.ee/docs). It's listed in Linktree's own/.well-known/api-catalog. It uses OAuth 2.0, and its tools act on profiles you have access to:get_account,get_links,add_link,update_link,move_link, analytics, and so on. If you want to read or edit your own Linktree (or the ones in your workspace) from code or an AI agent, start here. -
The LinkApp SDK (
docs.linktr.ee,npx @linktr.ee/create-linkapp). This is for building interactive "LinkApps" that appear on Linktree profiles. It's not a data API. -
A developer program waitlist at
linktr.ee/marketplace/developer: "We're expanding access to our APIs and SDKs. Register your interest..."
What I did not find is a public, read-only REST API where you pass any username and get back its links. So:
| You want | Use |
|---|---|
| Read or update your own profile, links, and analytics | Linktree MCP server (OAuth) or the admin dashboard |
| Build something that shows up on Linktree profiles | LinkApp SDK |
| Read someone else's public profile | No official option. That's what the code below does, and it's what the terms forbid. |
What a Linktree profile page looks like to a script
I fetched a few profiles with curl and Node's fetch first:
path status bytes notes
/robots.txt 200 named bots allowed, "*" disallowed
/tonyhawk 200 313,189 full profile in __NEXT_DATA__
/TonyHawk 200 usernames are case-insensitive
/billieeilish 200 322,229 6 links, 6 social icons
/spotify 200 241,608 6 playlist links
/linktree 200 86,219 one "forwarding" link
/thisusernamedoesnotexist12345 404 12,448 page is "/_error"
/_next/data/<buildId>/tonyhawk.json 404 the Next.js JSON route is not exposed
The status codes were the same with an honest User-Agent, a Chrome User-Agent, and curl's default. I saw no rate-limit headers. I also didn't try to find the limit, and you shouldn't either.
Three things shaped the code.
1. Everything is in one JSON script. linktr.ee is a Next.js app. The server renders the page and puts its data in <script id="__NEXT_DATA__" type="application/json">. For a profile, data.page is "/[profile]" and data.props.pageProps holds about 80 keys. The useful ones:
pageProps.account id, uuid, username, pageTitle, description, profilePictureUrl,
profileBadges, verticals, timezone, tier, createdAt, updatedAt, ...
pageProps.links [{ id, type, title, url, position, parent, children, rules, modifiers, metaData, ... }]
pageProps.socialLinks [{ type, url, position }]
pageProps.isProfileVerified, hasSensitiveContent, hasPagePassword, pageLockType, contentGating
So you don't need a headless browser. One request per profile gives you everything shown on the page.
2. Links aren't all URLs. On Billie Eilish's page, four links are normal (CLASSIC and YOUTUBE_VIDEO), but "SHOP MERCH" is a PRODUCT link and the mailing-list signup is an EXTENSION. Both have an empty url. They are app-style blocks that render inside Linktree.
3. position has gaps. Tony Hawk's 12 visible links have positions 0, 3, 10, 11, 12, 13, 38, 42, 44, 61, 77, 83. My guess is that hidden, scheduled, or archived links keep their slots but aren't sent to visitors. Sort by position, but don't treat it as a 1-to-N rank.
Step 1: A polite fetch helper
One profile every four seconds, an honest User-Agent, 404 means "no such profile", and 403 or 429 means stop.
// linktree.mjs - a small Linktree profile reader + link-change tracker in Node.js.
// Node 22.5+ (uses the built-in node:sqlite), no npm dependencies.
// Read Linktree's terms first: they prohibit automated collection from profiles.
import { DatabaseSync } from "node:sqlite";
const BASE = "https://linktr.ee";
const UA = process.env.LT_UA ?? "linktree-research-script/1.0 (+mailto:you@example.com)";
const MIN_DELAY_MS = 4000; // one profile every ~4 s, at most
// ---------- Step 1: a polite fetch helper ----------
let last = 0;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function get(url) {
const wait = last + MIN_DELAY_MS - Date.now();
if (wait > 0) await sleep(wait);
last = Date.now();
const res = await fetch(url, { headers: { "user-agent": UA, accept: "text/html" }, redirect: "follow" });
if (res.status === 404) return null; // no such profile (or it was deleted)
if (res.status === 429 || res.status === 403) {
throw new Error(`${res.status} from ${url}: you are being limited. Stop and try again much later.`);
}
if (!res.ok) throw new Error(`${res.status} from ${url}`);
return res.text();
}
Two small choices here. The User-Agent says what the script is, with a way to contact you. And there's no retry loop on 429. If Linktree starts limiting you, the right move is to stop, not to retry faster.
Step 2: Pull out __NEXT_DATA__
// ---------- Step 2: pull the Next.js page data out of the HTML ----------
function nextData(html) {
const m = html.match(/<script id="__NEXT_DATA__" type="application\/json"[^>]*>([\s\S]*?)<\/script>/);
if (!m) throw new Error("no __NEXT_DATA__ script: the page shape changed");
return JSON.parse(m[1]);
}
const iso = (ms) => (typeof ms === "number" ? new Date(ms).toISOString() : null);
// Which gates are switched on for a link (age, passcode, sensitive-content, ...).
function gates(link) {
const g = link.rules?.gate ?? {};
const on = Object.entries(g)
.filter(([k, v]) => k !== "activeOrder" && k !== "sensitiveContent" && v)
.map(([k]) => k);
if (g.sensitiveContent?.domain) on.push("sensitiveContent");
return on;
}
function normLink(l, parentId = null) {
return {
id: String(l.id),
type: l.type, // CLASSIC, YOUTUBE_VIDEO, PRODUCT, EXTENSION, ...
title: (l.title ?? "").replace(/\s+/g, " ").trim(),
url: l.url || null, // empty for app-style links (PRODUCT, EXTENSION, ...)
position: l.position,
parentId: l.parent?.id ? String(l.parent.id) : parentId,
forwarding: Boolean(l.modifiers?.isForwardingActive),
gated: gates(l),
locked: Boolean(l.locked),
};
}
The regex matches the exact script tag Next.js writes. If Linktree moves to the App Router one day, this tag will disappear and the data will move into React Server Component chunks (self.__next_f.push(...)). That's why the function fails with a clear message instead of returning an empty profile. An empty result that looks valid is worse than an error.
Step 3: One profile, one clean object
The raw link objects have about 20 keys each, most of them null. This keeps the parts you'll use and flattens grouped links (children) so each one has a parentId.
// ---------- Step 3: one profile -> one clean object ----------
export async function profile(username) {
const html = await get(`${BASE}/${encodeURIComponent(username)}`);
if (!html) return null;
const data = nextData(html);
if (data.page !== "/[profile]") return null; // e.g. "/_error"
const p = data.props?.pageProps;
const a = p?.account;
if (!a || !Array.isArray(p.links)) throw new Error("profile JSON without account/links: the shape changed");
const links = [];
for (const l of p.links) {
links.push(normLink(l));
for (const c of l.children ?? []) links.push(normLink(c, String(l.id))); // grouped links
}
return {
username: a.username,
id: a.id,
url: `${BASE}/${a.username}`,
displayName: a.pageTitle ?? null,
bio: a.description ?? null,
verified: Boolean(p.isProfileVerified),
badges: a.profileBadges ?? [],
verticals: a.verticals ?? [],
timezone: a.timezone ?? null,
tier: a.tier ?? null,
createdAt: iso(a.createdAt),
updatedAt: iso(a.updatedAt),
sensitive: Boolean(p.hasSensitiveContent),
pageLocked: Boolean(p.hasPagePassword || p.pageLockType),
socials: (p.socialLinks ?? []).map((s) => ({ type: s.type, url: s.url })),
links,
fetchedAt: new Date().toISOString(),
};
}
A few details from the real data:
-
pageTitleis the display name, and it's whatever the owner typed. Tony Hawk's is@tonyhawk, Billie Eilish's isBillie Eilish. -
account.usernamekeeps the owner's casing. Billie Eilish's comes back asBillieeilish. The tracker lowercases it. -
createdAtandupdatedAtare epoch milliseconds.updatedAtchanges when the owner edits the profile, which makes it a cheap "did anything change?" signal. -
tiercomes back asfree,pro, orpaid2. That's an internal value. Don't build anything on it. - Gates. Each link has
rules.gatewithage,passcode,password,nft, andsensitiveContent. None of my test links had a gate switched on, so I can't show you a gated link. The code only records which gates are on. It doesn't try to get past them, and you shouldn't either. Gated links, page passwords (hasPagePassword), and sensitive-content profiles (hasSensitiveContent) are the owner saying "not for everyone".
Here's part of node linktree.mjs profile tonyhawk:
{
"username": "tonyhawk",
"id": 8900221,
"url": "https://linktr.ee/tonyhawk",
"displayName": "@tonyhawk",
"bio": null,
"verified": true,
"badges": ["VERIFIED_AND_NOTABLE_TICK"],
"verticals": [],
"timezone": "America/Los_Angeles",
"tier": "pro",
"createdAt": "2020-09-22T17:15:38.000Z",
"updatedAt": "2026-09-13T06:54:58.000Z",
"sensitive": false,
"pageLocked": false,
"socials": [],
"links": [
{
"id": "582852123",
"type": "CLASSIC",
"title": "Order “Nearshore” by Steve Hawk here",
"url": "https://stevehawkauthor.com",
"position": 0,
"parentId": null,
"forwarding": false,
"gated": [],
"locked": false
},
{
"id": "449921776",
"type": "YOUTUBE_VIDEO",
"title": "My final video",
"url": "https://youtu.be/KgyW24ONcUI?si=3NPP0immuN4fTTU2",
"position": 3,
"parentId": null,
"forwarding": false,
"gated": [],
"locked": false
}
]
}
(10 more links trimmed.) And the parts of Billie Eilish's page that show the other link types and the social icons:
socials: INSTAGRAM https://instagram.com/billieeilish
TIKTOK https://tiktok.com/@billieeilish
SPOTIFY https://open.spotify.com/artist/6qqNVTkY8uBg9cP3Jd7DAH
YOUTUBE https://www.youtube.com/billieeilish
APPLE_MUSIC https://music.apple.com/us/artist/billie-eilish/1065981054
X https://x.com/billieeilish
links: CLASSIC WATCH HIT ME HARD AND SOFT: THE TOUR AT HOME http://billieeilish.lnk.to/ondemand
YOUTUBE_VIDEO WATCH THE HIT ME HARD AND SOFT: THE TOUR ... https://www.youtube.com/watch?v=WY1zGdMyWZQ
CLASSIC LISTEN TO HIT ME HARD AND SOFT https://billieeilish.lnk.to/HITMEHARDANDSOFT
CLASSIC SHOP EILISH FRAGRANCES https://billieeilish.lnk.to/shopfragrance
PRODUCT SHOP MERCH (no url)
EXTENSION SIGN UP FOR BILLIE'S MAILING LIST (no url)
The raw "SHOP MERCH" title has 36 spaces in the middle. That's why normLink collapses whitespace.
Step 4: A SQLite link-change tracker
Each run saves a snapshot: one row in snapshots, and one row in items per link and per social icon. Then it compares the two newest snapshots for that profile.
// ---------- Step 4: snapshots in SQLite ----------
export function openDb(file = "linktree.db") {
const db = new DatabaseSync(file);
db.exec(`
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY, username TEXT NOT NULL, fetched_at TEXT NOT NULL,
display_name TEXT, bio TEXT, verified INTEGER, n_links INTEGER, n_socials INTEGER);
CREATE TABLE IF NOT EXISTS items (
snapshot_id INTEGER NOT NULL REFERENCES snapshots(id), kind TEXT NOT NULL, -- 'link' | 'social'
key TEXT NOT NULL, type TEXT, title TEXT, url TEXT, position INTEGER);
CREATE INDEX IF NOT EXISTS items_snap ON items(snapshot_id);`);
return db;
}
function save(db, p) {
const { lastInsertRowid: sid } = db
.prepare(`INSERT INTO snapshots (username, fetched_at, display_name, bio, verified, n_links, n_socials)
VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(p.username.toLowerCase(), p.fetchedAt, p.displayName, p.bio, p.verified ? 1 : 0, p.links.length, p.socials.length);
const ins = db.prepare(`INSERT INTO items VALUES (?, ?, ?, ?, ?, ?, ?)`);
for (const l of p.links) ins.run(sid, "link", l.id, l.type, l.title, l.url, l.position);
for (const s of p.socials) ins.run(sid, "social", s.type, s.type, null, s.url, null);
return Number(sid);
}
function items(db, sid) {
const rows = db.prepare(`SELECT kind, key, type, title, url FROM items WHERE snapshot_id = ?`).all(sid);
return new Map(rows.map((r) => [`${r.kind}:${r.key}`, r]));
}
// Compare the two newest snapshots of a profile.
export function diff(db, username) {
const [now, prev] = db
.prepare(`SELECT id, fetched_at FROM snapshots WHERE username = ? ORDER BY id DESC LIMIT 2`)
.all(username.toLowerCase());
if (!prev) return null;
const a = items(db, prev.id), b = items(db, now.id);
const changes = [];
for (const [k, r] of b) {
const o = a.get(k);
if (!o) changes.push({ change: "added", kind: r.kind, title: r.title ?? r.type, url: r.url });
else if (o.url !== r.url) changes.push({ change: "url", kind: r.kind, title: r.title ?? r.type, from: o.url, to: r.url });
else if (o.title !== r.title) changes.push({ change: "title", kind: r.kind, from: o.title, to: r.title });
}
for (const [k, o] of a) if (!b.has(k)) changes.push({ change: "removed", kind: o.kind, title: o.title ?? o.type, url: o.url });
return { since: prev.fetched_at, changes };
}
Links are matched by their Linktree id, and social icons by their type (one Instagram icon per profile). Matching by id means a retitled link shows up as "title changed", not as one removal plus one addition. Matching by URL would get that wrong.
Here are the first two real runs. Each profile is one request, four seconds apart:
$ node linktree.mjs track billieeilish tonyhawk spotify linktree
Billieeilish: first snapshot, 6 links, 6 socials
tonyhawk: first snapshot, 12 links, 0 socials
spotify: first snapshot, 6 links, 0 socials
linktree: first snapshot, 1 links, 0 socials
$ node linktree.mjs track billieeilish tonyhawk
Billieeilish: no changes since 2026-11-04T04:05:54.546Z
tonyhawk: no changes since 2026-11-04T04:05:57.430Z
Nobody edits their Linktree in the 20 seconds between two test runs. So to test the diff, I edited the stored snapshot by hand: I deleted one link, changed one URL, shortened one title, and added a fake "Summer tour dates" link. Then I ran it again against the live page:
$ node linktree.mjs track tonyhawk
tonyhawk: 4 change(s) since 2026-11-04T04:06:13.199Z
{"change":"added","kind":"link","title":"Order “Nearshore” by Steve Hawk here","url":"https://stevehawkauthor.com"}
{"change":"url","kind":"link","title":"New Birdhouse shirts & hoodies","from":"https://www.birdhouseskateboards.com/collections/sale","to":"https://www.birdhouseskateboards.com/collections/apparel"}
{"change":"title","kind":"link","from":"Book me","to":"Book me on Cameo"}
{"change":"removed","kind":"link","title":"Summer tour dates","url":"https://example.com/tour"}
All four kinds of change were detected. Run it once a day from cron for the few profiles you care about, and you'll have a history of what was promoted and when.
Step 5: Check where the links go
This is the part I find most useful for my own pages. Link-in-bio pages collect dead links over time, because a product sells out or a campaign page gets removed, and nobody notices.
// ---------- Step 5: check where the links actually go ----------
export async function checkLinks(p, { delayMs = 1500 } = {}) {
const out = [];
for (const l of p.links.filter((x) => x.url)) {
let status, finalUrl;
try {
// GET, not HEAD: several shops and link shorteners answer HEAD with 403/405.
const r = await fetch(l.url, { headers: { "user-agent": UA }, redirect: "follow", signal: AbortSignal.timeout(15000) });
status = r.status; finalUrl = r.url;
await r.body?.cancel();
} catch (e) {
status = e.name === "TimeoutError" ? "timeout" : "error";
}
out.push({ title: l.title, url: l.url, status, finalHost: finalUrl ? new URL(finalUrl).host : null });
await sleep(delayMs);
}
return out;
}
These requests go to the link targets, not to Linktree. Keep them slow anyway. The real result for Tony Hawk's page:
$ node linktree.mjs check tonyhawk
200 www.stevehawkauthor.com Order “Nearshore” by Steve Hawk here
200 www.youtube.com My final video
200 www.birdhouseskateboards.com New Birdhouse shirts & hoodies
200 www.birdhouseskateboards.com Birdhouse completes
200 www.cameo.com Book me on Cameo
200 www.youtube.com WAR CRY video
404 www.tonyhawk.com Signed Hawk Vs Wolf decks
200 www.youtube.com Thrasher “Masher” Houston
200 www.youtube.com Thrasher “Masher” from Baja
200 www.youtube.com Masher (Hawaii edit) on Thrasher
200 www.youtube.com Birdhouse's “Skate and Create”
200 www.youtube.com Learning a "dream trick" / NBD
One real dead link: the signed "Hawk Vs Wolf" deck product page returns 404. I checked it with a browser User-Agent as well, and it's still a 404. That's exactly the kind of thing this check is for. (If it's your page, the fix takes 10 seconds in the admin.)
finalHost matters for short links. Billie Eilish's links go through lnk.to, a smart-link service that redirects by country. The status you see is that of the final page, so a 200 means "this lands somewhere", not "this lands where it did last month".
The CLI
// ---------- CLI ----------
async function main() {
const [cmd, ...users] = process.argv.slice(2);
if (cmd === "profile") {
for (const u of users) console.log(JSON.stringify((await profile(u)) ?? { username: u, found: false }, null, 2));
} else if (cmd === "track") {
const db = openDb(process.env.LT_DB ?? "linktree.db");
for (const u of users) {
const p = await profile(u);
if (!p) { console.log(`${u}: not found`); continue; }
save(db, p);
const d = diff(db, u);
if (!d) console.log(`${p.username}: first snapshot, ${p.links.length} links, ${p.socials.length} socials`);
else if (!d.changes.length) console.log(`${p.username}: no changes since ${d.since}`);
else { console.log(`${p.username}: ${d.changes.length} change(s) since ${d.since}`); for (const c of d.changes) console.log(" ", JSON.stringify(c)); }
}
} else if (cmd === "check") {
const p = await profile(users[0]);
if (!p) return console.log("not found");
for (const r of await checkLinks(p)) console.log(String(r.status).padEnd(8), r.finalHost?.padEnd(28) ?? "".padEnd(28), r.title);
} else {
console.log("usage: node linktree.mjs profile <user...> | track <user...> | check <user>");
}
}
if (import.meta.url === `file://${process.argv[1]}`) main().catch((e) => { console.error(e.message); process.exit(1); });
node linktree.mjs profile tonyhawk > tonyhawk.json
node linktree.mjs track billieeilish tonyhawk spotify # run daily
node linktree.mjs check tonyhawk
Pitfalls, in one place
-
The terms and robots.txt both say no. Linktree's terms ban automated collection from profiles, and robots.txt disallows
/for every crawler it doesn't name. For your own pages, use the MCP server or the dashboard. -
A brand's name isn't its username.
linktr.ee/nikeis not Nike. It's a personal page with three links to someone's Instagram, SoundCloud, and TikTok. Verify the profile (isProfileVerified,profileBadges, and links back from the brand's own site) before you trust it. -
Forwarding profiles.
linktr.ee/linktreehas one link, "Linktree's Linktree", withisForwardingActive: true, pointing tolinktr.ee/linktr.ee. Visitors get sent straight there. The HTML still returns 200 with the data, so check theforwardingflag. Otherwise you'll record a one-link profile and wonder why. -
__NEXT_DATA__is a private contract. Key names likeisForwardingActive,profileBadges, ortiercan be renamed in any deploy. Validate the shape (the code throws ifaccountorlinksis missing) and keep a saved HTML page so you can compare when something breaks. -
No JSON shortcut. The usual Next.js trick,
/_next/data/<buildId>/<page>.json, returns 404 here. Parse the HTML. -
Empty URLs are normal.
PRODUCT,EXTENSION, and other app blocks have nourl. Don't count them as broken links. -
Positions have gaps. Sort by
position, don't rank by it. -
Usernames are case-insensitive but keep their casing.
TonyHawkandtonyhawkare the same page. Lowercase your keys. - Respect gates and sensitive pages. Age gates, passcodes, page passwords, and sensitive-content flags are there for a reason. Record that they exist and move on.
- No visible rate-limit headers doesn't mean no limit. Linktree sits behind a CDN. At one request every four seconds for a handful of profiles I never saw a block, but I didn't push it and you shouldn't. The code stops on the first 403 or 429.
-
Social icons are not links. They live in
socialLinks, separate fromlinks, and many big profiles (Tony Hawk, Spotify) have none.
When DIY stops being worth it
The single file is fine for a few profiles you check once a day. It stops being fun when you need the shop tab, need to discover profiles by category, or have to keep up with every change to the page JSON.
For that I use ScrapeAtlas*. It has three Linktree endpoints on https://api.scrapeatlas.com, with auth through an x-api-key header:
-
GET /v1/linktree?url=https://linktr.ee/<username>returns a public profile with display name, verification, badges, ordered links, block previews, and social icons. -
GET /v1/linktree/shop?url=...returns the profile's published shop products and collections with prices, images, and vendors. -
GET /v1/linktree/directory?category=tech&page=1browses Linktree's creator directory bycategory(business,creative,education,tech, and so on) orsubcategory, with display names, verification, avatars, and pagination.
Each response has a coverage object that tells you whether the result is complete. A successful request costs 1 credit, and failed requests cost nothing.
For your own page, the official MCP server is still the right tool. For a handful of profiles, the script above works.
Wrapping up
- Linktree's official tools are for your own pages. The MCP server reads and edits profiles you have access to, and the LinkApp SDK builds blocks. There's no public read API for other profiles.
- The terms and robots.txt forbid automated collection. Know that before you run anything.
-
One request gives you the whole page.
__NEXT_DATA__has the account, ordered links, and social icons. - Track by link id. Then retitles and re-pointed URLs show up as what they are.
- The dead-link check pays off quickly. On the first real run, it found a 404 on a verified profile.
Which would you use more: the change tracker, or the dead-link check on your own page? Tell me in the comments.
*I build it.
Top comments (0)