Build Carrd

YESreplaces $1.58/mosaves $18.96/yrback to the verdict

0%0 of 36 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

A one-page personal site you own: content lives in one JSON file, a build script renders it to fast static HTML, and a tiny Node server handles the contact form with no third-party service. When every item is ticked you have a live site on your own domain that scores 100 on Lighthouse and costs nothing to run beyond the domain.

estimated effort one sittingthe files for this build are in the project pack

RuntimeNode 22RenderingStatic HTML from a build scriptStylingOne inlined stylesheetForm servernode:http, no ExpressHostingAny static host plus one small server

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why It runs the build script that turns content.json into HTML and the small server that receives the contact form.

    Get it Download the LTS installer from nodejs.org, or use your package manager (brew install node, or nvm install 22). Restart your terminal afterwards. open ↗

    Verify node --version prints v22 or higher

  2. installfree

    Why Every step below is a command you type or a file you edit. VS Code, Cursor or Zed all work; the built-in terminal in any of them is enough.

    Get it Install VS Code from code.visualstudio.com if you have nothing yet. Open a folder for this project and use its terminal (View > Terminal). open ↗

    Verify You can open a folder and run a command in its terminal

  3. installfree

    Why Your content.json is your CMS. Git is how you keep its history and how a static host deploys the site.

    Get it Install from git-scm.com, or with your package manager. Run git init in the project folder once it exists. open ↗

    Verify git --version prints a version

  4. have readyfree

    Why The build renders exactly what content.json says. Writing the words first stops the layout phase from turning into a writing session.

    Get it In a plain text file, draft: your name, a one-line headline, a short about paragraph (three to five sentences), your social links, and up to six projects with a title, one sentence and a URL. Have a square avatar image (at least 400x400, JPG or PNG) ready.

  5. roughly $10 a year

    Why The whole point of leaving Carrd is that the site is yours. It is only needed in the last phase, so you can start without it.

    Get it Register one at Cloudflare Registrar, Porkbun or Namecheap. You will add one DNS record in the final phase; the host tells you what. open ↗

  6. free to $5 a month

    Why The HTML is static and can live on any free static host. The form server needs one small always-on process: a $5 VPS, or a serverless function on the same host.

    Get it Pick one before the final phase: Cloudflare Pages or Netlify (free, static, with functions for the form) or a small VPS from Hetzner or DigitalOcean if you want one box for everything.

  7. installfree

    Why Phase 5 requires a Lighthouse score of 100, and Lighthouse ships inside Chrome's DevTools.

    Get it Any Chromium browser. Open DevTools (F12), find the Lighthouse tab.

    Verify The Lighthouse tab is visible in DevTools

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

`content.json` is the CMS. Editing it and re-running the build is the only way
content changes. Shape it exactly like this:

- `name`, `headline`, `about` (one paragraph), `avatar` (path in /public)
- `accent` (hex color), `email` (for the form recipient)
- `socials`: array of { label, url, icon } where icon is an inline SVG path id
- `projects`: array of { title, description, url } · may be empty
- `seo`: { title, description, ogImage }

Validate content.json at build time: fail the build with a readable message if a
required key is missing or a URL is malformed. A silent half-rendered page is
the failure mode to prevent.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
PORTrequired3000Any free port for the form server. Behind Caddy or nginx this stays internal.
SITE_URLrequiredhttps://yourname.comYour public address. Used to build absolute Open Graph URLs at build time.
MESSAGES_LOGrequired./messages.logA path the form server can write to. This file is your inbox; back it up.
FORM_RATE_LIMIToptional5Max form submissions per IP per hour. Defaults to 5 when unset.
FORM_MIN_SECONDSoptional2Submissions faster than this are treated as bots. Defaults to 2.

The build, in order

  1. Build pipeline

    Prove the whole idea in the smallest form: content.json goes in, an HTML page comes out, and a mistake in the JSON fails loudly instead of rendering a half-empty page.

    1. Everything lives in one folder. The type: module line lets you use import syntax in the build script.

      Files package.json

      terminal
      mkdir my-site && cd my-site
      git init
      npm init -y
      npm pkg set type=module
      mkdir public
    2. Fill it with your real content from the prerequisites. Keep projects and socials as arrays even if you only have one.

      Files content.json

      {
        "name": "Ada Lovelace",
        "headline": "I write software that explains itself.",
        "about": "Three to five sentences about you.",
        "avatar": "/avatar.jpg",
        "accent": "#5b8def",
        "email": "you@example.com",
        "socials": [{ "label": "GitHub", "url": "https://github.com/you", "icon": "github" }],
        "projects": [{ "title": "A project", "description": "One sentence.", "url": "https://example.com" }],
        "seo": { "title": "Ada Lovelace", "description": "One line for search results." }
      }
    3. Use a template literal for the HTML. Escape every value from the JSON before inserting it (replace &, <, > with their entities) so a stray character in your bio cannot break the page. Write to dist/index.html and copy public/ into dist/.

      Files build.mjs

    4. At the top of build.mjs, check that name, headline, about, avatar, email and seo exist and that every url starts with http. On failure, print which key is wrong and exit with code 1. A build that renders a page with a missing name is the failure this step prevents.

      Files build.mjs

    5. terminal
      npm pkg set scripts.build="node build.mjs"
      npm run build
      npx serve dist
    done when · tick each as it passes
    watch out
    • Do not reach for a framework or a bundler here. The output is one HTML file; a build tool would be the heaviest part of the project.
    • Escape JSON values on the way into the HTML. This is the one place the page can be broken by its own content.
  2. Design layer

    Make it look designed rather than templated, with one inlined stylesheet, and prove it on three screen widths.

    1. Read the file in build.mjs and place its contents inside a style tag in the head. One request fewer, and the page paints with its styles on the first byte.

      Files styles.cssbuild.mjs

    2. Headline around clamp(2rem, 6vw, 4rem), body 1.05rem with line-height 1.65, a measure of about 60 characters for paragraphs. No web fonts: they cost a request and Lighthouse points, and the system stack looks native everywhere.

    3. In build.mjs, write :root { --accent: <value> } into the inlined stylesheet. Use var(--accent) for links, focus rings and one decorative element. Changing the hex in content.json is the only way the theme changes.

    4. Define the light palette on :root and override the same custom properties inside @media (prefers-color-scheme: dark). Check contrast in both: the accent on both backgrounds must pass 4.5:1 for text.

    5. A visible focus ring on every interactive element, using the accent. Keyboard visitors must be able to see where they are.

    done when · tick each as it passes
    watch out
    • Self-host or skip web fonts entirely. A fonts.googleapis.com request is a third-party call, a privacy question and a Lighthouse deduction all at once.
  3. Content blocks

    Render the socials and projects from their arrays, and make an empty array disappear completely instead of leaving a stray heading.

    1. One svg element with a symbol per icon (github, x, linkedin, mastodon, email, website), hidden at the top of the body. Each social link uses a use element pointing at its symbol. No icon font, no external request.

      Files icons.svgbuild.mjs

    2. Map socials to links with the icon and an aria-label of the label. If the array is empty, render nothing at all: no wrapper, no heading.

    3. A responsive grid (auto-fit, minmax(240px, 1fr)) of cards with title, description and the link. Same rule: empty array, no section.

    4. Every URL in content.json points off your site. Opening in a new tab is the expected behaviour for a link page; noopener is the security half of that.

    done when · tick each as it passes
  4. Contact form

    A working contact form with no third-party service: the small server appends each message to a log file and shows an inline thank-you, and it turns away the obvious bots.

    1. Serve files from dist/ for GET requests. For POST /contact, read the body (cap it at 4 KB), parse the form fields, and append one JSON line with a timestamp to the file named in MESSAGES_LOG. Respond by redirecting back to /#thanks so the page shows the inline thank-you.

      Files server.mjs

      terminal
      npm pkg set scripts.start="node --env-file=.env server.mjs"
    2. Node 22 reads .env with the --env-file flag, so no dotenv dependency is needed.

      Files .env

      terminal
      cp .env.example .env
      npm start
    3. Fields: name, email, message, plus a text input named website that is hidden with CSS (position absolute, left -9999px), not type=hidden. Real people never see it; bots fill it. The form must work as a plain HTML POST with JavaScript disabled.

    4. If website is filled, respond with the same thank-you but write nothing. Put a hidden timestamp in the form and reject submissions arriving faster than FORM_MIN_SECONDS. Reject bodies over 4 KB with a 413.

    5. A Map of IP to an array of timestamps; allow FORM_RATE_LIMIT submissions per rolling hour, respond 429 beyond that. In-memory is fine: a restart resetting the counters costs nothing here.

    6. A #thanks element hidden by default; the :target CSS pseudo-class shows it when the redirect lands on /#thanks. No JavaScript required.

    done when · tick each as it passes
    watch out
    • Do not use type=hidden for the honeypot: bots know to skip those. Hide it with CSS so it looks like a real field to a script.
    • Never write the raw request body to the log. Parse the fields, then write only the fields you expect.
  5. Share cards and performance

    The page previews properly when shared, and scores 100 on Lighthouse for Performance, Accessibility and Best Practices.

    1. og:title, og:description, og:image, og:url (built from SITE_URL), twitter:card summary_large_image. Absolute URLs only: relative ones do not work in share previews.

    2. Install satori and @resvg/resvg-js, render your name and headline on your accent color to SVG then PNG, write dist/og.png. Pin the versions in package.json.

      Files build.mjsdist/og.png

      terminal
      npm install satori@0.29.0 @resvg/resvg-js@2.6.2
    3. DevTools > Lighthouse > Analyze page load, on the built page served by npm start. Typical fixes: an image without width and height attributes, a contrast miss, a missing lang attribute on html.

    4. Resize the avatar to 320x320 and save as WebP with a JPG fallback. Explicit dimensions stop layout shift, which is a Performance deduction.

    done when · tick each as it passes
    watch out
    • Run Lighthouse against the built site served by your server, not against a file:// URL or the dev preview. The scores differ.
  6. Deploy and document

    The site is live on your domain with a working form, and the README lets someone else run it without asking you anything.

    1. Path A, one VPS: Caddy serves dist/ and proxies /contact to the Node server, with a systemd unit for the server. Path B, static host plus function: dist/ on Cloudflare Pages or Netlify, and the form handler as a function writing to a KV store or emailing you. Pick one; the README documents that one.

    2. Caddyfile: your domain, file_server on dist, reverse_proxy /contact to localhost:PORT. Unit: Restart=on-failure, EnvironmentFile pointing at .env, running as an unprivileged user.

      Files Caddyfiledeploy/site.service

    3. One A record (VPS) or the CNAME the static host gives you. Wait for it to resolve, then load the site over https.

    4. Sections: what this is, the content.json key reference with which keys are required, how to build and run, where messages land and how to read them, the deploy path you chose, and one line stating this replaces Carrd's output rather than Carrd's editor.

      Files README.md

    5. Clone the repo into a fresh folder, follow only the README, and get to a running site with a working form. Fix every step the README skipped.

      terminal
      git clone <your-repo-url> /tmp/site-test && cd /tmp/site-test
      npm install && npm run build && cp .env.example .env && npm start
    done when · tick each as it passes
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more website builders.