How to build a Carrd alternative

How to build a Carrd alternative with stacked sections, a contact form, and a publish button. Follow the prompts, check each step, then skip the builder.

  • guide
  • vibe-coding
  • carrd

You can learn how to build a Carrd alternative as stacked sections, a contact form, and a publish button. Carrd is that product. The catalog line is “It’s one page. You can build one page.”

You pay $2–$4 a month. That is $24–$48 a year. The verdict is “One page, zero rent”. The first estimate is 2 hours: stacked sections, a contact form, publish.

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

The audience is one person who needs exactly one landing page. If you sell pages to clients, or you need a template marketplace, keep paying. A list of five buttons is a smaller job. That is the Linktree guide.

How to build a Carrd alternative without a visual editor

The Carrd page splits the work into three rows.

The first row is the one you want. A hero. A block of text. A few links. A contact form. A URL that stays put when you edit a heading. That is the 2-hour v1.

The second row is 8 hours. Themes, live preview, a gallery, draft states. Do that after one real bio already points here.

The third row is a month and up. A visual editor, templates, multi-tenant hosting, custom domains as a product. Skip it.

If you tell an agent to “clone Carrd”, 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 2 hours.

const site = {
  slug: 'flavio',
  theme: {
    bg: '#f4f1e8',
    ink: '#171713',
    accent: '#ff755d',
    font: 'Georgia',
  },
}

const sections = [
  {
    id: 'sec-hero',
    type: 'hero',
    position: 1,
    heading: 'I write tiny software.',
    sub: 'Tutorials at flaviocopes.com',
  },
  {
    id: 'sec-about',
    type: 'text',
    position: 2,
    body: 'I write about JavaScript and shipping small tools you own.',
  },
  {
    id: 'sec-links',
    type: 'links',
    position: 3,
    items: [
      { label: 'Newsletter', url: 'https://flaviocopes.com' },
      { label: 'YouTube', url: 'https://youtube.com/@flaviocopes' },
    ],
  },
  {
    id: 'sec-contact',
    type: 'contact',
    position: 4,
    heading: 'Say hello',
  },
]

A visitor hits https://hello.flaviocopes.com and sees those sections in order. You change the heading later. The Instagram bio still works.

The card lists Site, Section, Theme, and FormMessage. Tonight Theme is four CSS variables on the site row. FormMessage is a row you write before you try SMTP.

const message = {
  id: 'msg-ada',
  name: 'Ada Lovelace',
  email: '[email protected]',
  body: 'Loved the JavaScript course. Can we talk about Slot?',
  notification: 'pending',
  createdAt: '2026-10-26T14:00:00.000Z',
}

sites.slug is unique. sections belong to one site and sort by position. form_messages keep the text even when the mail provider is down.

SITE slug flavio, unique bg, ink, accent, Georgia SECTIONS hero, text, links, contact stable id, integer position fixed types, no free HTML FORM MESSAGES Ada Lovelace, stored first notification is pending Email is a later attempt
The live URL reads a snapshot. The inbox reads storage, not SMTP.

Pick the stack you already write

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Carrd is Astro, Alpine, and D1.

This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. Alpine is on the card because the editor rail updates a preview. A TanStack Start client component can do the same job. Do not invent a second product to host the page.

Open Build your own Carrd. The guardrail is the useful sentence: sanitize all section content server-side and rate-limit the contact form with a honeypot field. The non-goal sits next to it. No multi-page sites, a template marketplace, e-commerce, or hosting for other people’s pages.

The stack post is which tab you copy. The prompt post is how you pin the first estimate. Pick one stack and stay on it.

Prompt 1. Scaffold Onepager and stop

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

Ship the 2-hour row only. Stacked
sections, contact form, publish.

Project name: Onepager.
Public URL is https://hello.flaviocopes.com.
Site slug: flavio.
Owner is Flavio. Visitors do not create
accounts.

Theme defaults:
bg #f4f1e8, ink #171713,
accent #ff755d, font Georgia.

Do not add a theme gallery, live
preview, or a photo gallery yet.
Do not add multi-page navigation, a
template marketplace, e-commerce, or
hosting for other people's pages.

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:

  • /editor for the section rail
  • /messages for the inbox
  • / for the public page
  • /dashboard for Flavio if you want publish next to the rail

You want a sites table and a sections table. form_messages can wait one prompt. templates should not appear. pages as a second public route should not appear.

If the plan includes a visual canvas, a theme shop, or a domain checkout, 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 Flavio. You should see an empty rail and a public URL that renders nothing useful yet.

Open the migrations. sites.slug is unique. sections have a stable id and a position. Section type is a string you will restrict in prompt 2, not a blob of HTML.

If the README is a placeholder, stop and make the agent write a real one before you add logic. The README has to say the public page renders a fixed set of section types.

Prompt 2. Stack the sections and clean the paste

The editor is an ordered rail. You add a section, you type into fields, you move it up or down. The public page renders that list and nothing else.

Agents trust innerHTML. People paste from Google Docs. The paste arrives with inline styles, nested spans, and the occasional javascript: link.

The mini-prompt on the card is “The paste that arrives wearing Word’s clothes”. Intercept paste in the editor if you want. The server pass on save is the security boundary.

Build the section editor for Onepager on
https://hello.flaviocopes.com.

Allowed types tonight: hero, text, image,
links, contact. Image is a URL field, not
an upload. Gallery waits.

Each section has a stable id and an
integer position. Add, remove, move up,
and move down. Drag can wait for the
8-hour row.

Sanitize every field on save. Allow
paragraphs, bold, italic, links, and
lists. Drop every attribute except a
validated href. href is http or https
only. Reject javascript: and data:.
Reject credentials in the URL.

Seed this page:
- hero: I write tiny software.
  sub: Tutorials at flaviocopes.com
- text: I write about JavaScript and
  shipping small tools you own.
- links: Newsletter -> https://flaviocopes.com
  YouTube -> https://youtube.com/@flaviocopes
- contact: Say hello

Theme is four CSS variables on the site
row. No second theme.

Do not add a live preview iframe yet.
Do not add a gallery or file uploads.

Done when I can move contact above
links, reload, see the same order on
the public page, and a pasted
<script>alert(1)</script> in the about
section renders as text.

Your public page becomes an XSS host the moment a text section stores raw HTML.

function allowedHref(raw) {
  try {
    const url = new URL(raw.trim())
    if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
    if (url.username !== '' || url.password !== '') return null
    return url.href
  } catch {
    return null
  }
}

allowedHref('javascript:alert(1)')
//null

allowedHref('https://flaviocopes.com')
//'https://flaviocopes.com/'

Strip every attribute except that href. Collapse the span soup into those tags. Fall back to plain text when the HTML is a mess.

const allowedTypes = new Set([
  'hero',
  'text',
  'image',
  'links',
  'contact',
])

function acceptSection(section) {
  if (!allowedTypes.has(section.type)) return null
  return section
}

acceptSection({ type: 'checkout' })
//null

A checkout type is e-commerce leaking in through the side door. Refuse it.

What to check after the rail saves

Open /editor. You should see the four seed sections in order.

Move contact above links. Reload. The rail still has that order. Open https://hello.flaviocopes.com. Same order.

Paste <script>alert(1)</script> into the about field. Save. View source on the public page. There is no script tag. The words may remain. The markup does not.

Add a links item with javascript:alert(1). The save fails. No row.

Add an image section. The field is a URL. There is no file picker. Point it at https://flaviocopes.com/images/avatar.jpg if you want a face in the hero.

If the agent opened an R2 bucket for screenshots, delete the upload path. The capability line is D1. A URL is enough tonight.

Prompt 3. Store the message before you send mail

The form is why this is not just Linktree. A visitor types a name, an email, a message. You want that text even if the mail provider is down.

The mini-prompt is “The message that outlives the email outage”. Validate, store, then attempt the email. In that order.

Build the contact form for Onepager on
https://hello.flaviocopes.com.

Public form: name, email, message.
Add a honeypot named company, hidden
with CSS. A filled honeypot gets the
same thanks page. No error that
teaches the bot.

Validate length and required fields
on the server. Rate-limit by IP with
a sliding window that allows an office
NAT and stops a burst of hundreds.

Insert the row first. notification
starts as pending. Then try to email
[email protected] with subject
Onepager: {name}. On success, mark
notified. On failure, queue a retry.
Never block the visitor on SMTP.

Locally, print the message id. Do not
pretend SMTP works on localhost.

Strip markup from the body on the way
in. Ada can write words. She cannot
write HTML.

Dashboard at /messages:
- sender, time, body
- notification state
- mark read
- delete

Do not add file uploads. Do not add
a second form. Do not add payments.

Done when I kill the mailer, submit
as Ada, still see the row, a filled
honeypot creates zero rows, and a
retry does not insert a second Ada.

The dashboard reads from storage. The email is a pointer to a row that already exists.

async function submitContact(db, mail, body) {
  if (typeof body.company === 'string' && body.company.trim() !== '') {
    return { ok: true, fake: true }
  }

  const message = await db.insertFormMessage({
    name: body.name.trim(),
    email: body.email.trim().toLowerCase(),
    body: stripMarkup(body.body),
    notification: 'pending',
  })

  try {
    await mail.send({
      to: '[email protected]',
      subject: `Onepager: ${message.name}`,
      text: message.body,
    })
    await db.markNotified(message.id)
  } catch {
    await db.queueRetry(message.id)
  }

  return { ok: true, fake: false, id: message.id }
}

Write the row, then send, so a timeout on SMTP cannot lose Ada.

What to check after Ada writes in

Open the public page. Submit as Ada Lovelace, [email protected], the Slot sentence from the seed. You see a thanks state. /messages shows one row. notification is pending in development because nothing sent.

Fill company in DevTools. Submit again. Thanks page. Still one row.

Submit the same words twice as a human. Two rows is fine, because this form is not a waitlist. Use the waitlist guide if you need a confirm link and a place in line.

Point the mailer at a closed port. Submit once more. The new row is there. notification stays pending or becomes retry. No 500 on the public form.

If the agent added a seven-question flow, stop. That is Typeform, $29–$99 a month, about 2 hours. The Typeform guide is one question at a time and a table.

Prompt 4. Publish a snapshot, not a live draft

Save and publish as the same button is how a half-written about section goes live while you are still typing. For a page that sits in a bio, that gets old.

The official rules already keep draft and published snapshots separate. Editing cannot alter the live page by accident. Publish copies the draft, flips a pointer, and purges the cache.

Build publish for Onepager on
https://hello.flaviocopes.com.

Keep two snapshots. The draft is what
/editor edits. The live page reads the
published snapshot only.

Publish copies the current draft into
a new immutable version, points the
live slug at it, and purges the cache
for https://hello.flaviocopes.com.
Do those three steps so a visitor mid-
publish gets the old complete page or
the new complete page, never a mix.

Show Flavio what is unpublished:
sections changed since last publish,
and the last-published time.

Seed this walk:
- publish the four seed sections
- edit the hero to I write smaller
  software
- confirm the live URL still says
  tiny software
- publish again
- confirm the live URL says smaller
- roll back to the first version

Serve the published page as cacheable
HTML from the snapshot. Do not render
the draft on the public request.

Done when I can sit on the live URL
in one tab, edit in another, refresh
the live tab, and see the old hero
until I press publish.

A visitor mid-publish gets the old complete page or the new complete page, because a mix of both snapshots is the failure the mini-prompt names.

async function publish(db, siteId) {
  const draft = await db.getDraft(siteId)
  const version = await db.insertPublished({
    siteId,
    snapshot: draft,
    publishedAt: new Date().toISOString(),
  })
  await db.setLivePointer(siteId, version.id)
  await db.purgeCache(`/${draft.slug}`)
  return version
}

Keep the previous version around. Rollback is flipping the pointer and purging again.

What to check after publish flips

Publish the seed page. Open https://hello.flaviocopes.com in a second browser. The hero says tiny software.

Edit the heading to I write smaller software. Save. Refresh the public tab. It still says tiny software.

Publish. Refresh. It says smaller software.

Roll back. The public page says tiny software again. The editor still has your draft. That is correct.

Open DevTools on the public page. You should see cache headers that make a CDN useful. After publish, a hard refresh shows the new snapshot. If the old hero sticks for minutes, the purge did not run.

If the agent made every keystroke live, send prompt 4 back. The Instagram bio is looking at that URL.

Failures the agent ships anyway

Raw HTML in a text section

set:html on the public page, with the draft string as the source, is how a paste from Word becomes a script. Allowlist on save. Render the cleaned tree. The client paste filter is UX. The server pass is the product.

Save equals publish

One button that writes the draft and the live pointer together will ship a typo to everyone with the link. Keep two snapshots and press publish on purpose.

Mail inside the submit transaction

Sending SMTP before you commit the row is how a timeout creates a second submit and a confused inbox. Write the row, then send. Locally, print the id.

A type the rail did not name

video, pricing, checkout, and html show up when the agent gets bored. The allowed set is hero, text, image, links, contact. Gallery is the 8-hour row.

An upload for a hero image

The stack on the card is Astro, Alpine, and D1. An image section is a URL. Compress that file first with the TinyPNG guide if the hero is heavy. R2 can wait until you miss it.

A second public page

/about is how Onepager becomes a site generator. The non-goal is one page. If you need two URLs, publish two Onepager apps, or keep paying.

If the agent starts a template gallery or a domain checkout, paste the non-goal and delete it.

Deploy the public page

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

Onepager wants Astro, Alpine, and D1. Put it on a subdomain you already own, hello.flaviocopes.com. The Astro prompt points at Cloudflare D1 for the tables and Workers for the endpoints. The hosting post is that path: the adapter, then the bindings, then the custom domain.

The catalog lists transactional email as the integration. Owner mail has to leave localhost if you want Ada’s message in your inbox. Print the row in development. Send it in production.

Attach the domain on the Worker. The public URL is the product, so hello.flaviocopes.com has to resolve on the public internet, not only on localhost. Open it on a phone on cellular.

Ship with the seed sections. Publish. Submit as Ada. Read the row in /messages. Edit the hero. Confirm the live page did not move until the second publish.

Take a D1 backup before you delete the paid account.

Use it as the real landing page for a week, not as a demo. If you miss a gallery or a live preview, those are the 8-hour row and the other mini-prompts on the card. Add them after a stranger has already used the form.

Then cancel Carrd.

What to skip on purpose

Leave out multi-page navigation, a template marketplace, e-commerce, and hosting for other people’s pages.

2-HOUR V1 Stacked sections you can reorder Contact form stored in D1 Publish copies a snapshot SKIP THIS HALF Visual editor Template gallery Hosting other people's pages
Ship the 2-hour page. Leave the builder platform on the skip row.

A live preview is the honest next feature, not a v1 requirement. Same draft, one render, phone and desktop width toggles. Gallery is a list of image URLs with the same allowedHref function. Sortable position keys replace the integer when drag arrives.

A bio page with five URLs is Linktree, $9–$24 a month, about 30 minutes. The Linktree guide is that walk. Build this if you need an about paragraph and a “say hello” box.

A longer intake is Typeform, $29–$99 a month, about 2 hours. Build that if you need seven answers. Build this if the answer is a name, an email, and a sentence.

Compressing the hero image is TinyPNG Pro, $25–$39 a month, about 60 minutes. The TinyPNG walkthrough stays in the tab. Drop the PNG, download the smaller file, paste the URL into the image section.

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 Carrd. Copy the Astro prompt, or TanStack Start if that is what you write.

Tell the agent the first estimate only: stacked sections, contact form, publish. Two hours.

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