How to build a Linktree alternative

How to build a Linktree alternative with a profile, five links, and one theme. Follow the prompts, check each step, and skip the theme marketplace.

  • guide
  • vibe-coding
  • linktree

You can learn how to build a Linktree alternative as a profile, five links, and one theme. Linktree is that product. The catalog line is “A page of links. That is the whole app.”

You pay $9–$24 a month. That is $108–$288 a year. The verdict is “Absolute no-brainer”. The first estimate is 30 minutes: links list, avatar, one theme.

I already wrote the teardown, Build a Linktree alternative. This guide is the walk. Prompts in order, then what to look at.

The audience is one creator managing a single public profile. If you sell bio pages to clients, keep paying. The same v1 also replaces Beacons and Bio Link.

How to build a Linktree alternative without a marketplace

The Linktree page splits the work into three rows.

The first row is the one you want. A display name. A short bio. An avatar. Five destinations in a fixed order. One set of colors and a typeface. A public URL that stays put when you edit a label.

The second row is 2 hours. Drag-to-reorder, drafts, daily click counts. Do that after the Instagram bio already points here.

The third row is a week. Multi-tenant hosting, billing, a theme marketplace, analytics. Skip it.

If you tell an agent to “clone Linktree”, it aims at the third row. Pin every prompt to the first one.

Here is the shape of the data. Nothing else has to exist at 30 minutes.

const profile = {
  slug: 'flavio',
  name: 'Flavio Copes',
  bio: 'I write about JavaScript and tiny software.',
  theme: 'ink-on-paper',
  avatarPath: '/Users/flavio/avatar.jpg',
}

const links = [
  { label: 'Newsletter', url: 'https://flaviocopes.com', position: 1 },
  { label: 'YouTube', url: 'https://youtube.com/@flaviocopes', position: 2 },
  { label: 'X', url: 'https://x.com/flaviocopes', position: 3 },
  { label: 'Bootcamp', url: 'https://bootcamp.dev', position: 4 },
  { label: 'JavaScript course', url: 'https://flaviocopes.com/javascript/', position: 5 },
]

A visitor hits https://links.flaviocopes.com and sees that list. You change a label later. The Instagram bio still works.

Theme can be four CSS variables on the profile row. ClickEvent waits. The card lists Profile, Link, ClickEvent, and Theme. Tonight you need the first two.

SAVE THE PROFILE Flavio Copes, one avatar ink-on-paper theme, slug flavio ADD FIVE LINKS Label, destination, integer position Order is the order you typed VALIDATE THE URL http or https only, no credentials javascript: dies at save time PUBLISH ONE SNAPSHOT Draft stays off the live page
Profile, five links, a safe URL. Publish copies the draft. The Instagram bio never moves.

Pick the stack you already write

Every product prompt ships in four flavors: AHA (Astro, HTMX, Alpine.js), Next.js, Laravel, and Rails. The capability line for Linktree is Astro, Alpine, and D1.

This site is an Astro app on Cloudflare, so that is the path I default to. Use Next.js if that is your weekday language.

Open Build your own Linktree. The guardrail is the useful sentence: never collect visitor fingerprints or precise location data. The non-goal sits next to it. No multi-tenant billing, theme marketplace, or enterprise analytics.

Pick one stack and stay on it.

Prompt 1. Scaffold Linkhouse and stop

Copy the AHA prompt from the product page. Paste it into your coding tool. Put this block above it.

Ship the 30-minute row only. Links list, avatar, one theme.

Project name: Linkhouse.
Owner is one person. Visitors do not create accounts.

Allow exactly one profile. Reserve slug flavio during setup.
Public URL is https://links.flaviocopes.com.

Do not add drag-to-reorder, drafts, or click counts yet.
Do not add a theme gallery or a second profile.
Do not add billing or visitor accounts.

When the plan is done, stop and wait. Do not write
implementation code until I say go.

The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.

You want pages like these:

  • /dashboard for the owner
  • /profile for name, bio, avatar, theme
  • /links for the five destinations
  • / on the public host for visitors

You want a profiles table and a links table. click_days should not appear. themes can be four columns on the profile, not a catalog table.

If the plan includes Stripe, a users table for visitors, or a /themes marketplace, send it back. Paste the non-goal again.

When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.

What to check after the scaffold

Start the app. Sign in as the owner. You should see an empty dashboard, a public URL, and a create-profile action.

Open the migrations. There is one profile row, or a unique constraint that makes a second profile impossible. The reserved slug is flavio. If the README is a placeholder, stop and make the agent write a real one before you add logic.

Now fill the editor. Keep the public page off until you publish.

Implement the profile editor and the link list for Linkhouse.

The owner can save name, bio, avatar, and theme. Theme is
four CSS variables on the profile, not a switcher:

- --ink #171713
- --paper #f4f1e8
- --accent #c8ff45
- --font Arial, Helvetica, sans-serif

Name that theme ink-on-paper. Store the avatar on R2 or in
a public folder. Serve it from the same host.

The owner can add, edit, and delete links. Each link has
label, destination, and an integer position. Order is the
order they were created. No drag handles yet.

Seed this profile:
- slug flavio
- name Flavio Copes
- bio I write about JavaScript and tiny software.
- theme ink-on-paper
- five links in this order:
  Newsletter https://flaviocopes.com
  YouTube https://youtube.com/@flaviocopes
  X https://x.com/flaviocopes
  Bootcamp https://bootcamp.dev
  JavaScript course https://flaviocopes.com/javascript/

Phone-sized preview uses the same template as the live page.
Do not publish yet. Saving the draft must leave
https://links.flaviocopes.com empty or on a coming-soon
state.

Done when I can save those fields, refresh, and see the
same values in the editor and the preview.

One theme means one look. The marketplace is how Linktree became a platform. You need a page that looks like you.

The preview has to share the public template. A second “editor preview” is how you ship a page that looks fine in admin and wrong on a phone.

What to check after the profile saves

Create the profile. Refresh. Name, bio, and the five labels are still there. The preview is 390 pixels wide, not a desktop column.

Change --accent to #7bc8ff. The buttons follow it. There is no theme dropdown.

If the agent adds “Featured”, “Hidden”, or a highlight style, delete those columns. They belong to the 2-hour row.

Prompt 3. Make the destination refuse junk

The first mini-prompt on the product page is “The link field that refuses javascript: politely”. Use a tighter version so the agent does not fetch the destination.

Implement destination validation for Linkhouse links.

Accept what a person pastes. If the paste has no scheme,
assume https. Trim whitespace. Lowercase the host.

Accept only http and https. Reject javascript:, data:,
file:, vbscript:, protocol-relative tricks, embedded
credentials, control characters, and newlines. Parse with
the language URL parser, not a regex. Never fetch the
destination.

Preserve path and query exactly. Strip default ports.
Flag a duplicate against the owner's other links using
the canonical form, but let them save after a warning.

A failed save keeps the rest of the draft. Do not wipe
the bio because link three is missing a slash.

Done when I can save instagram.com/flaviocopes as
https://instagram.com/flaviocopes, javascript:alert(1)
is refused, and a bad URL leaves the other four links
intact.

Agents invent URL fields that accept anything. The public page then becomes an open redirect with your face on it.

function normalizeLink(raw) {
  const trimmed = raw.trim()
  const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`
  const url = new URL(withScheme)

  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
    throw new Error('Only http and https links are allowed')
  }

  if (url.username !== '' || url.password !== '') {
    throw new Error('Destinations cannot include credentials')
  }

  url.host = url.host.toLowerCase()
  return url.href
}

normalizeLink('instagram.com/flaviocopes')
//https://instagram.com/flaviocopes

You are validating shape, not liveness. Fetching turns the bio page into an SSRF proxy and slows every save.

What to check after the validator

Paste javascript:alert(1) and get a clear error. Paste https://user:[email protected] and get the credentials error. Paste instagram.com/flaviocopes and see https:// added.

Add a second Newsletter that points at https://flaviocopes.com. You get a warning. You can still save if you mean it.

If the agent adds a “check that the page loads” button that hits the destination from the server, delete that function.

Prompt 4. Publish a snapshot on purpose

The official journeys mention publish as a separate step. The mini-prompt is “Publish is a snapshot, not a save button”. Stay on that idea, skip the history gallery.

Build publish for Linkhouse on https://links.flaviocopes.com.

The owner edits a draft: profile fields, theme, and the
ordered links. The public page renders only the last
published snapshot.

Publishing copies the current draft into an immutable
snapshot and points the live page at it in one write.
A visitor mid-request never sees half old name, half
new links.

Show an unpublished-changes mark by comparing the draft
to the live snapshot. Reordering later counts as a
change. Discard restores the draft to an exact copy of
the live version.

Preview and live share one template. No publish history
UI in v1. Keep the last snapshot so discard has
something to copy.

Seed: publish the flavio profile once. Then edit the
Newsletter label to Weekly notes without publishing.

Done when https://links.flaviocopes.com still says
Newsletter, the preview says Weekly notes, and a second
publish swaps the live label in one refresh.

If save and publish are the same button, a typo goes live while you are still typing the next label. For a page that sits in every bio you have, that gets old.

async function publishDraft(db, profileId) {
  const draft = await db.getDraft(profileId)
  const snapshot = {
    profileId,
    publishedAt: new Date().toISOString(),
    name: draft.name,
    bio: draft.bio,
    theme: draft.theme,
    avatarUrl: draft.avatarUrl,
    links: draft.links.map((link) => ({
      label: link.label,
      url: link.url,
      position: link.position,
    })),
  }

  return db.transaction(async (tx) => {
    const saved = await tx.insertSnapshot(snapshot)
    await tx.setLiveSnapshotId(profileId, saved.id)
    return saved
  })
}

The public query reads live_snapshot_id. It never reads the draft tables.

What to check after publish

Open https://links.flaviocopes.com in a private window. You see Flavio Copes, the bio, and five buttons in seed order. The Newsletter button goes to https://flaviocopes.com.

Edit Newsletter to Weekly notes. Refresh the public URL. It still says Newsletter. The dashboard shows unpublished changes.

Press publish. Refresh the public URL. It says Weekly notes. Press discard on a later edit and the draft snaps back to that live snapshot.

If the public page updates on every keystroke, save and publish are wired together. Split them before you point Instagram at the URL.

Failures the agent ships anyway

An open redirect with your face on it

javascript: and data: have to die at save time. A regex that looks for http still lets http:// sit in front of a newline and a second URL. Use the parser.

Fetching the destination

A health check that HEADs the target from your Worker is SSRF. You do not need to know the page is up. The bio still works if YouTube is slow for ten minutes.

A second profile

The rule is one profile in v1. A profiles list with “New page” is a hosting company. Reserve flavio at setup and refuse a second row.

Visitor fingerprints

The guardrail is explicit. No cookies, no IPs, no precise location. Cloudflare Web Analytics on the page is enough if you want a visit count. Per-link daily totals wait for the 2-hour row.

A theme marketplace

A picker full of palettes is the week-long row. Four CSS variables on the profile is the product.

Guest accounts

Visitors tap a button and leave. Magic links for people who click are a second app.

A short URL in front of one of those buttons is Bitly, $35–$300 a month, about 90 minutes. The link shortener guide is that walk.

Deploy the bio page

The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.

Linkhouse wants Astro, Alpine, and D1. Put it on a subdomain you already own, links.flaviocopes.com. The AHA prompt points at Cloudflare D1 for the tables and Workers for the endpoints. Store the avatar on R2.

Attach the custom domain on the Worker. The public URL is the product, so links.flaviocopes.com has to resolve on the public internet, not only on localhost.

Ship with the seed profile published. Open it from a phone on cellular, not the same Wi-Fi as your laptop. Tap each of the five buttons. Update the Instagram bio first, then the other places you still point at the old Linktree URL.

Use it as the real bio for a week. If you miss click counts or drag-to-reorder, those are the 2-hour row and the last two mini-prompts on the card.

Take a D1 backup, then cancel Linktree, or Beacons, or Bio Link, whichever one you pay for.

What to skip on purpose

Leave out multi-tenant billing, a theme marketplace, and anything that looks like enterprise analytics.

30-MINUTE V1 One profile, avatar, and bio Five safe http and https links Publish a snapshot on purpose SKIP THIS HALF Multi-tenant billing A theme marketplace Enterprise analytics
Ship the 30-minute page. Leave billing, themes, and analytics on the skip row.

Daily click counts are the honest next feature, not a v1 requirement. Record link_id and the UTC day. Increment a (link_id, day) row. No cookies. The Linktree walkthrough covers that 2-hour row.

A whole personal site with stacked sections and a contact form is a different job. That is Carrd, $2–$4 a month, about 2 hours. The Carrd walkthrough is that post. Build the list first if a list is the rent.

A booking page is a different job too. That is the Calendly guide. Weekly hours and a slot lock, not five buttons.

More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.

Start now

Go to Build your own Linktree. Copy the AHA prompt, or the stack you already write.

Tell the agent the first estimate only: links list, avatar, one theme. Thirty minutes.

Publish links.flaviocopes.com. Point one real bio at it tonight.