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.pushSource:https://dev.to/viktorhendelmann/how-to-scrape-linktree-with-nodejs-profiles-links-socials-and-a-link-change-tracker-2026-2i82Related posts

Every Song Has a Name
The Card-Update Page Is a Checkout Page. Treat It Like One.

Best Time to See Snow in Kashmir: Month-by-Month Guide for 2026-27

Supercharging the Gemini App with Custom MCP Connectors, Skills, and Live GitHub Access

Set up cex-uk-prices in 2 minutes, with a live check every week
