Skip to content
Build Blueprint · free

Build your own link-in-bio page (a Linktree replacement)

One fast page of links on your own domain, with privacy-friendly click counts and an optional tiny admin. A static page built with Lovable or Claude Code, plus Supabase only if you want the admin.

Build time
3–5 hours
Running cost
$0 a month
Stack
Lovable or Claude Code, Netlify or Vercel, Supabase (optional admin)
Verdict
BUILD See the math

What you’re building

A version scoped to what most people actually use in Linktree:

  • One fast page on your own domain, such as links.yourdomain.com, with your photo, name, a one-line bio and your links as big buttons.
  • Social icons in a row and an embedded video from YouTube or Vimeo.
  • Click counts per link, kept privacy-friendly: one number per link per day, with no cookies, IP addresses or visitor IDs.
  • Optional email capture, using the signup form from the newsletter tool you already use. That tool handles consent and unsubscribes, which you don’t want to rebuild.
  • An optional tiny admin at /admin to add, reorder and hide links from your phone’s browser.

Not in version 1: link scheduling, priority links, a storefront or paid links, and analytics dashboards with referrers and locations. Add them later only if you miss them.

Stack

  • Lovable or Claude Code to write the page (React + Tailwind is fine, and so is a single HTML file).
  • Netlify, Vercel or Cloudflare Pages to host it free on your own domain. If you don’t own a domain yet, expect about $10–15 a year.
  • Supabase, only for the optional admin and click counts. The free plan is plenty. Free projects pause after about a week of low activity, so the page always ships with a built-in copy of your links and falls back to it if Supabase can’t be reached.

Data model

You may not need one. If you’re happy to edit a file to change a link, keep your links in src/links.json and skip Supabase. You edit the file (or ask Lovable to), and the site redeploys in about a minute. For click counts without a database, use a cookieless analytics tool that tracks outbound clicks, such as Plausible or Umami.

If you want the admin, run this in the Supabase SQL editor. It creates two small tables, one for your links and one for daily click counts that hold no visitor data, and locks everything down with row-level security (RLS) so only you can change anything.

-- The page talks to Supabase over REST, not GraphQL. Turning GraphQL off closes
-- an endpoint you don't use and clears two Security Advisor warnings.
drop extension if exists pg_graphql;

-- Links: one row per button on the page
create table public.links (
  id uuid primary key default gen_random_uuid(),
  position integer not null default 0,
  kind text not null default 'link' check (kind in ('link', 'social', 'video')),
  title text not null check (char_length(title) between 1 and 80),
  url text not null check (url ~* '^(https://|mailto:)' and char_length(url) <= 2000),
  is_active boolean not null default true,
  created_at timestamptz not null default now()
);
create index links_position on public.links (position);

-- Clicks: one counter per link per day. No IP addresses, cookies or visitor IDs.
create table public.link_clicks (
  link_id uuid not null references public.links on delete cascade,
  day date not null default current_date,
  clicks integer not null default 0,
  primary key (link_id, day)
);

-- Privileged helpers live in a schema the API can't reach.
create schema if not exists private;

-- You're the admin if your user's app_metadata says so. Users can't change app_metadata themselves.
create function private.is_admin() returns boolean
language sql stable security definer set search_path = public as $$
  select coalesce((auth.jwt() -> 'app_metadata' ->> 'is_admin')::boolean, false)
$$;

-- Adds one click to an active link and ignores any other id.
create function private.count_click(p_link_id uuid) returns void
language sql security definer set search_path = public as $$
  insert into public.link_clicks as c (link_id, day, clicks)
  select id, current_date, 1 from public.links where id = p_link_id and is_active
  on conflict (link_id, day) do update set clicks = c.clicks + 1;
$$;

-- The page calls this one. It runs with the visitor's own rights and only passes the id on.
create function public.track_click(p_link_id uuid) returns void
language sql set search_path = public as $$
  select private.count_click(p_link_id);
$$;

revoke execute on all functions in schema private from public;
grant usage on schema private to anon, authenticated;
grant execute on function private.is_admin(), private.count_click(uuid) to anon, authenticated;
revoke execute on function public.track_click(uuid) from public;
grant execute on function public.track_click(uuid) to anon, authenticated;

-- Row-level security
alter table public.links enable row level security;
alter table public.link_clicks enable row level security;

create policy "Active links are public, admins see all" on public.links for select to anon, authenticated
  using (is_active or (select private.is_admin()));
create policy "Admins add links" on public.links for insert to authenticated
  with check ((select private.is_admin()));
create policy "Admins edit links" on public.links for update to authenticated
  using ((select private.is_admin())) with check ((select private.is_admin()));
create policy "Admins delete links" on public.links for delete to authenticated
  using ((select private.is_admin()));
create policy "Admins read click counts" on public.link_clicks for select to authenticated
  using ((select private.is_admin()));

-- Table access. New Supabase projects don't grant this automatically,
-- and RLS still decides which rows each role can see.
revoke all on public.links, public.link_clicks from anon, authenticated;
grant select on public.links to anon, authenticated;
grant insert, update, delete on public.links to authenticated;
grant select on public.link_clicks to authenticated;
grant select, insert, update, delete on public.links, public.link_clicks to service_role;

Then make yourself the admin. Sign in once at /admin (after prompt 6), run this, and sign out and back in so your session picks it up. Then turn off Allow new users to sign up in Supabase Auth, since nobody else needs an account.

update auth.users
set raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || '{"is_admin": true}'::jsonb
where email = 'you@yourdomain.com';

Build it in 8 prompts

Paste these one at a time into Lovable or Claude Code. Check each step works before moving on; it saves credits. Prompts 1–3 build the page. Prompts 4–8 add the optional admin, so skip them if you keep your links in a file.

1. Build a one-page link-in-bio site with React + Tailwind. Read everything from src/links.json: name, bio, photo URL, and a list of links with title, url and kind (link, social or video). Show the photo, name and bio at the top, each "link" as a full-width button, "social" links as a row of icons, and the first "video" as an embedded player (use youtube-nocookie.com for YouTube). Skip any link whose url doesn't start with https:// or mailto:. Mobile first: max width 480px, buttons at least 48px tall, no cookies and no tracking scripts.
2. Add the details that make the page look right when it's shared: a page title and meta description, Open Graph and Twitter card tags that use my photo, a favicon, and a canonical URL of https://links.yourdomain.com. Add light and dark themes that follow the visitor's system setting, using CSS variables, with text contrast that passes WCAG AA. Add a simple 404 page that links back home.
3. Add an optional email signup block below the links. It shows my newsletter tool's signup form when src/config.ts has a newsletterFormHtml value (I'll paste the tool's plain HTML form there, not a script) and hides itself when the value is empty. Then make the page fast: resize and compress the photo, lazy-load the video, and keep the page under 200 KB before the video loads.
4. Optional admin, step 1. Connect the page to my Supabase project (project URL and publishable key from Settings → API Keys; never put a secret or service role key in the browser). The links table already exists with RLS: id, position, kind, title, url, is_active. On load, render the bundled src/links.json straight away, then fetch the active links ordered by position and swap them in. If the request fails or takes more than 2 seconds, keep the bundled list.
5. Count clicks. When a visitor clicks a link from the live list, call supabase.rpc('track_click', { p_link_id: link.id }) without waiting for it, and let the browser follow the link as normal. Create the Supabase client with a fetch that sets keepalive: true, so the request survives the page change. Links from the bundled fallback have no id, so don't track those. Store nothing else: no cookies, no local storage, no visitor IDs.
6. Add /admin with magic-link sign-in through Supabase Auth. Treat the user as an admin only if their session's app_metadata.is_admin is true; everyone else sees "No access" and a sign-out button. Admins see every link, including hidden ones, and can add, edit, hide, delete and drag to reorder them (reordering updates position). Check that a url starts with https:// or mailto: before saving, and explain any failed save in plain words. Make it work well on a phone.
7. On /admin, add click counts: for each link, the totals for the last 7 and 30 days from link_clicks (link_id, day, clicks), sorted by the 30-day total, with a small bar per row. Add a "Download links.json" button that exports src/links.json with its links replaced by the current active links, in the same shape, so I can commit it as the new fallback.
8. Final pass. Test the page on a slow 3G profile and fix anything that delays the first paint. Make sure every button has a visible focus style and an accessible name. Block the Supabase URL in dev tools and confirm the page still shows my links. List anything you couldn't fix.

Security checklist

  • Supabase Security Advisor shows no warnings, and RLS is on for both tables. (Skip the Supabase items if you keep your links in a file.)
  • Only the publishable (anon) key is in the browser. No secret or service role key is in the code or the repo.
  • A signed-out visitor can read active links and call track_click, but can’t add, edit or delete links or read click counts (try it from the browser console with the Supabase client).
  • Allow new users to sign up is off in Supabase Auth, and only your user has is_admin in its app metadata.
  • Every link starts with https:// or mailto:. The table rejects anything else, including javascript: links.
  • The page sets no cookies and loads no tracking scripts (check in the browser’s dev tools), and YouTube embeds use youtube-nocookie.com.
  • With Supabase blocked in dev tools, the page still shows your links.

Move off Linktree

  1. Copy your links, titles and their order from your Linktree admin into links.json or the links table. Most pages have fewer than 30 links, so copying by hand is quicker than any export.
  2. If you collected email sign-ups on Linktree, export them as CSV from the Audience section of your Linktree admin and import them into your newsletter tool.
  3. Point your domain at the new page, then update the link in every bio (Instagram, TikTok, YouTube, X, LinkedIn) and in your email signature.
  4. Don’t delete your Linktree account. Switch to the free plan after the last renewal you’ve paid for and leave one link that points to your new page, so old posts and printed QR codes with your linktr.ee address keep working.

After launch

  • About 5 minutes a month: click every link once, hide the ones nobody uses, and download a fresh links.json if you use the admin.
  • If you start selling through the page, that’s the moment to compare Linktree’s commerce features and current seller fees with adding a checkout yourself.

Before you build

Check the numbers for your own team first. Stack Scan reads a card statement in your browser and shows whether building beats what you actually pay.

Get new blueprints by email

One email when a new blueprint is out. Tell us which tool you want next.