Vibecode Carrd
track this build6 phases, 29 steps, beginner friendly0%A one-page static site generator with forms and custom domains is absolutely vibecodable; Carrd's price is so low that DIY only makes sense for control or learning.
You are building a lean indie version of Carrd.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# Carrd · indie build
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 sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22 | one runtime for the build script and the form server, nothing else to install |
| Rendering | Static HTML from a build script | no framework means no dependency to break and a perfect performance score by default |
| Styling | One inlined stylesheet | a single file with no build step is the whole reason this stays fast |
| Form server | node:http, no Express | one file that appends a line to a log is not worth a framework |
| Hosting | Any static host plus one small server | the HTML is static; only the form needs a process |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- 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.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- 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).
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- 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.
- Verify: git --version prints a version
- [ ] **Your content, written down** · free
- 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.
- [ ] **A domain name** (optional) · 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.
- [ ] **Somewhere to host it** (optional) · 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.
- [ ] **Chrome with DevTools** · free
- 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
## Quick start
```sh
mkdir my-site && cd my-site
git init
npm init -y
npm pkg set type=module
mkdir public
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- A visual drag-and-drop editor. That is most of what Carrd's price buys and it is a multi-month product on its own; editing content.json is the trade.
- Hosted form storage and spam filtering as a service. messages.log and a honeypot are honest for one person's inbox.
- Multi-page sites. If you need pages, you have outgrown a one-page tool.
- the visual editor
- hosted forms
- their template gallery
- not having to think about hosting
If one of those is essential to you, that is the reason to keep paying for Carrd, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Carrd
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a one-page personal site to replace Carrd. Build it in phases, in the
order below. Do not write the whole site in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- A Node build script that renders static HTML from data. No React, no Vue, no
Tailwind, no CSS framework, no build toolchain beyond that one script.
- One Node http server file (no Express) for the contact form only.
- Output is plain HTML + inlined CSS. The site must work with JS disabled.
### Data model (create this before Phase 1)
`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.
### Phase 1 · Build pipeline
Build: `build.mjs` reads content.json, renders `dist/index.html` from a template
literal, copies /public. One npm script: `npm run build`.
Done when: changing `name` in content.json and re-running the build changes the
rendered heading, and a missing required key fails the build with a clear error.
Do not build yet: styling beyond raw semantic HTML, the form, OG images.
### Phase 2 · Design layer
Build: the real visual design as one inlined stylesheet · a strong typographic
scale (clamp() for fluid sizing), generous whitespace, the accent color read
from `content.json` into a CSS custom property, dark mode via
prefers-color-scheme, tasteful hover and focus-visible states.
Done when: the page looks designed rather than templated at 375px, 768px and
1440px, and every interactive element has a visible keyboard focus ring.
Do not build yet: the projects grid.
### Phase 3 · Content blocks
Build: the socials row (inline SVG sprite, no icon font, no external requests)
and the projects grid rendered from the array. Both blocks disappear entirely
when their array is empty · no empty headings, no dangling borders.
Done when: emptying `projects` in content.json renders a page with no visual
trace of the section.
### Phase 4 · Contact form
Build: a POST endpoint in the server file that appends one JSON line per message
to `messages.log`, then re-renders the page with an inline thank-you. Include a
honeypot field hidden with CSS (not `type=hidden`), a minimum fill time check
(reject submissions faster than 2 seconds), and a per-IP rate limit of 5 per
hour held in memory. Reject anything over 4KB. The form must degrade to a normal
HTML form post when JS is off.
Done when: a normal submission appends exactly one line, a bot filling the
honeypot gets the same thank-you but writes nothing, and the sixth submission in
an hour is rejected.
Do not build yet: email delivery · the log file is the inbox for v1.
### Phase 5 · Share and performance
Build: complete OG and Twitter meta tags from `seo`, plus a generated 1200x630
OG image rendered at build time by the same build script (satori + resvg, or a
canvas library · pick one and pin it). Inline all CSS. Self-host any webfont or
use a system font stack. No render-blocking requests.
Done when: Lighthouse scores 100 on Performance, Accessibility and Best
Practices for the built page served over a local static server, and the OG image
renders correctly in a card validator.
### Phase 6 · Deploy and document
Build: a README with the clone-to-live path, and deploy notes for two targets ·
a plain VPS behind Caddy or nginx (with the form server under systemd), and a
static host where the form falls back to a serverless function or a mailto link.
Done when: someone who has never seen the repo can follow the README from clone
to a live site with a working form, without asking a question.
### Out of scope (and why)
- A visual drag-and-drop editor. That is most of what Carrd's price buys, and it
is a multi-month product on its own. Editing JSON is the tradeoff, say so.
- Hosted form storage, spam filtering as a service, and a template gallery.
- Multi-page sites. If you need pages, you have outgrown this build.
### README must contain
- The content.json key reference, with which keys are required.
- Where messages land and how to read them.
- A one-line statement that this replaces the Carrd output, not the Carrd editor.
===== AGENTS.md =====
# Agent instructions · Carrd indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, Static HTML from a build script, One inlined stylesheet, node:http, no Express, Any static host plus one small server. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
## Known traps
- 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.
- 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.
- 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.
- Run Lighthouse against the built site served by your server, not against a file:// URL or the dev preview. The scores differ.
===== BUILD_PLAN.md =====
# Build plan · Carrd
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.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 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.
### Steps
1. Create the project folder and initialise it
Everything lives in one folder. The type: module line lets you use import syntax in the build script.
Files: `package.json`
```sh
mkdir my-site && cd my-site
git init
npm init -y
npm pkg set type=module
mkdir public
```
2. Write content.json with every key the site will use
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. Write build.mjs: read the JSON, render index.html, copy public/
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. Validate content.json before rendering
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. Add the build script and run it
```sh
npm pkg set scripts.build="node build.mjs"
npm run build
npx serve dist
```
### Done when
- [ ] npm run build creates dist/index.html and the page opens in a browser at the URL serve prints
- [ ] Changing name in content.json and running the build again changes the heading on the page
- [ ] Deleting the headline key from content.json makes the build exit with an error naming headline
- [ ] A project description containing a < character renders as text, not as broken markup
### 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.
## Phase 2 · Design layer
Make it look designed rather than templated, with one inlined stylesheet, and prove it on three screen widths.
### Steps
1. Create styles.css and inline it into the HTML at build time
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.css`, `build.mjs`
2. Set the typographic scale with clamp() and a system font stack
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. Read the accent color from content.json into a CSS custom property
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. Add dark mode with prefers-color-scheme
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. Add hover and focus-visible states to every link and button
A visible focus ring on every interactive element, using the accent. Keyboard visitors must be able to see where they are.
### Done when
- [ ] The page looks intentional at 375px, 768px and 1440px wide with no horizontal scrollbar at any of them
- [ ] Switching the operating system to dark mode changes the page and every piece of text still reads clearly
- [ ] Tabbing through the page shows a visible focus ring on every link
- [ ] The built page makes zero requests for fonts or stylesheets: everything is in the one HTML file
### 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.
## Phase 3 · Content blocks
Render the socials and projects from their arrays, and make an empty array disappear completely instead of leaving a stray heading.
### Steps
1. Build an inline SVG sprite for social icons
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.svg`, `build.mjs`
2. Render the socials row from content.json
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. Render the projects grid from content.json
A responsive grid (auto-fit, minmax(240px, 1fr)) of cards with title, description and the link. Same rule: empty array, no section.
4. Add rel="noopener noreferrer" and target="_blank" to outbound links
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
- [ ] With three socials and two projects in content.json both sections render with the right icons and links
- [ ] Setting projects to [] and rebuilding leaves no trace of the section: no heading, no empty grid, no extra spacing
- [ ] Every outbound link opens in a new tab and carries rel="noopener noreferrer" in the built HTML
## Phase 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.
### Steps
1. Write server.mjs with node:http that serves dist/ and handles POST /contact
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`
```sh
npm pkg set scripts.start="node --env-file=.env server.mjs"
```
2. Create .env from .env.example and start the server
Node 22 reads .env with the --env-file flag, so no dotenv dependency is needed.
Files: `.env`
```sh
cp .env.example .env
npm start
```
3. Add the form to the page with a honeypot field
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. Reject bots: honeypot, minimum fill time, size cap
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. Rate limit by IP in memory
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. Render the inline thank-you
A #thanks element hidden by default; the :target CSS pseudo-class shows it when the redirect lands on /#thanks. No JavaScript required.
### Done when
- [ ] Submitting the form appends exactly one JSON line to messages.log and the page shows the thank-you
- [ ] Filling the hidden website field shows the same thank-you and appends nothing
- [ ] A submission sent within one second of the page loading is rejected
- [ ] The sixth submission from the same address inside an hour gets a 429
- [ ] The form still submits and shows the thank-you with JavaScript disabled in the browser
### 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.
## Phase 5 · Share cards and performance
The page previews properly when shared, and scores 100 on Lighthouse for Performance, Accessibility and Best Practices.
### Steps
1. Add Open Graph and Twitter meta tags from seo in content.json
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. Generate a 1200x630 OG image at build time
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.mjs`, `dist/og.png`
```sh
npm install satori@0.29.0 @resvg/resvg-js@2.6.2
```
3. Run Lighthouse and fix what it names
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. Set width and height on the avatar and compress it
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
- [ ] Lighthouse reports 100 for Performance, Accessibility and Best Practices on the built page
- [ ] Pasting the page URL into a card validator (or a Slack message) shows the generated OG image and the right title
- [ ] The built HTML is under 30 KB and makes no third-party requests
### Watch out
- Run Lighthouse against the built site served by your server, not against a file:// URL or the dev preview. The scores differ.
## Phase 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.
### Steps
1. Choose the deploy path and write it down
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. Path A: add the Caddyfile and the systemd unit
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: `Caddyfile`, `deploy/site.service`
3. Point the domain at the host
One A record (VPS) or the CNAME the static host gives you. Wait for it to resolve, then load the site over https.
4. Write the README
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. Do a clean-clone test
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.
```sh
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
- [ ] The site loads over https on your domain and the form appends a line to messages.log on the server
- [ ] Someone who has never seen the repo follows the README from clone to a working site without asking a question
- [ ] The README states which keys in content.json are required
## Not in this build
- A visual drag-and-drop editor. That is most of what Carrd's price buys and it is a multi-month product on its own; editing content.json is the trade.
- Hosted form storage and spam filtering as a service. messages.log and a honeypot are honest for one person's inbox.
- Multi-page sites. If you need pages, you have outgrown a one-page tool.
## After v1, if you want it
- Email yourself each message with a small SMTP step in the form server, once messages.log has proven itself for a few weeks
- A second content.json and build target for a project landing page, reusing the same renderer
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port for the form server. Behind Caddy or nginx this stays internal.
PORT=3000
# Required. Your public address. Used to build absolute Open Graph URLs at build time.
SITE_URL=https://yourname.com
# Required. A path the form server can write to. This file is your inbox; back it up.
MESSAGES_LOG=./messages.log
# Optional. Max form submissions per IP per hour. Defaults to 5 when unset.
FORM_RATE_LIMIT=5
# Optional. Submissions faster than this are treated as bots. Defaults to 2.
FORM_MIN_SECONDS=2
You are building a lean indie version of Carrd.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# Carrd · indie build
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 sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22 | one runtime for the build script and the form server, nothing else to install |
| Rendering | Static HTML from a build script | no framework means no dependency to break and a perfect performance score by default |
| Styling | One inlined stylesheet | a single file with no build step is the whole reason this stays fast |
| Form server | node:http, no Express | one file that appends a line to a log is not worth a framework |
| Hosting | Any static host plus one small server | the HTML is static; only the form needs a process |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- 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.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- 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).
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- 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.
- Verify: git --version prints a version
- [ ] **Your content, written down** · free
- 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.
- [ ] **A domain name** (optional) · 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.
- [ ] **Somewhere to host it** (optional) · 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.
- [ ] **Chrome with DevTools** · free
- 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
## Quick start
```sh
mkdir my-site && cd my-site
git init
npm init -y
npm pkg set type=module
mkdir public
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- A visual drag-and-drop editor. That is most of what Carrd's price buys and it is a multi-month product on its own; editing content.json is the trade.
- Hosted form storage and spam filtering as a service. messages.log and a honeypot are honest for one person's inbox.
- Multi-page sites. If you need pages, you have outgrown a one-page tool.
- the visual editor
- hosted forms
- their template gallery
- not having to think about hosting
If one of those is essential to you, that is the reason to keep paying for Carrd, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Carrd
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a one-page personal site to replace Carrd. Build it in phases, in the
order below. Do not write the whole site in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- A Node build script that renders static HTML from data. No React, no Vue, no
Tailwind, no CSS framework, no build toolchain beyond that one script.
- One Node http server file (no Express) for the contact form only.
- Output is plain HTML + inlined CSS. The site must work with JS disabled.
### Data model (create this before Phase 1)
`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.
### Phase 1 · Build pipeline
Build: `build.mjs` reads content.json, renders `dist/index.html` from a template
literal, copies /public. One npm script: `npm run build`.
Done when: changing `name` in content.json and re-running the build changes the
rendered heading, and a missing required key fails the build with a clear error.
Do not build yet: styling beyond raw semantic HTML, the form, OG images.
### Phase 2 · Design layer
Build: the real visual design as one inlined stylesheet · a strong typographic
scale (clamp() for fluid sizing), generous whitespace, the accent color read
from `content.json` into a CSS custom property, dark mode via
prefers-color-scheme, tasteful hover and focus-visible states.
Done when: the page looks designed rather than templated at 375px, 768px and
1440px, and every interactive element has a visible keyboard focus ring.
Do not build yet: the projects grid.
### Phase 3 · Content blocks
Build: the socials row (inline SVG sprite, no icon font, no external requests)
and the projects grid rendered from the array. Both blocks disappear entirely
when their array is empty · no empty headings, no dangling borders.
Done when: emptying `projects` in content.json renders a page with no visual
trace of the section.
### Phase 4 · Contact form
Build: a POST endpoint in the server file that appends one JSON line per message
to `messages.log`, then re-renders the page with an inline thank-you. Include a
honeypot field hidden with CSS (not `type=hidden`), a minimum fill time check
(reject submissions faster than 2 seconds), and a per-IP rate limit of 5 per
hour held in memory. Reject anything over 4KB. The form must degrade to a normal
HTML form post when JS is off.
Done when: a normal submission appends exactly one line, a bot filling the
honeypot gets the same thank-you but writes nothing, and the sixth submission in
an hour is rejected.
Do not build yet: email delivery · the log file is the inbox for v1.
### Phase 5 · Share and performance
Build: complete OG and Twitter meta tags from `seo`, plus a generated 1200x630
OG image rendered at build time by the same build script (satori + resvg, or a
canvas library · pick one and pin it). Inline all CSS. Self-host any webfont or
use a system font stack. No render-blocking requests.
Done when: Lighthouse scores 100 on Performance, Accessibility and Best
Practices for the built page served over a local static server, and the OG image
renders correctly in a card validator.
### Phase 6 · Deploy and document
Build: a README with the clone-to-live path, and deploy notes for two targets ·
a plain VPS behind Caddy or nginx (with the form server under systemd), and a
static host where the form falls back to a serverless function or a mailto link.
Done when: someone who has never seen the repo can follow the README from clone
to a live site with a working form, without asking a question.
### Out of scope (and why)
- A visual drag-and-drop editor. That is most of what Carrd's price buys, and it
is a multi-month product on its own. Editing JSON is the tradeoff, say so.
- Hosted form storage, spam filtering as a service, and a template gallery.
- Multi-page sites. If you need pages, you have outgrown this build.
### README must contain
- The content.json key reference, with which keys are required.
- Where messages land and how to read them.
- A one-line statement that this replaces the Carrd output, not the Carrd editor.
===== AGENTS.md =====
# Agent instructions · Carrd indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, Static HTML from a build script, One inlined stylesheet, node:http, no Express, Any static host plus one small server. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
## Known traps
- 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.
- 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.
- 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.
- Run Lighthouse against the built site served by your server, not against a file:// URL or the dev preview. The scores differ.
===== BUILD_PLAN.md =====
# Build plan · Carrd
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.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 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.
### Steps
1. Create the project folder and initialise it
Everything lives in one folder. The type: module line lets you use import syntax in the build script.
Files: `package.json`
```sh
mkdir my-site && cd my-site
git init
npm init -y
npm pkg set type=module
mkdir public
```
2. Write content.json with every key the site will use
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. Write build.mjs: read the JSON, render index.html, copy public/
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. Validate content.json before rendering
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. Add the build script and run it
```sh
npm pkg set scripts.build="node build.mjs"
npm run build
npx serve dist
```
### Done when
- [ ] npm run build creates dist/index.html and the page opens in a browser at the URL serve prints
- [ ] Changing name in content.json and running the build again changes the heading on the page
- [ ] Deleting the headline key from content.json makes the build exit with an error naming headline
- [ ] A project description containing a < character renders as text, not as broken markup
### 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.
## Phase 2 · Design layer
Make it look designed rather than templated, with one inlined stylesheet, and prove it on three screen widths.
### Steps
1. Create styles.css and inline it into the HTML at build time
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.css`, `build.mjs`
2. Set the typographic scale with clamp() and a system font stack
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. Read the accent color from content.json into a CSS custom property
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. Add dark mode with prefers-color-scheme
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. Add hover and focus-visible states to every link and button
A visible focus ring on every interactive element, using the accent. Keyboard visitors must be able to see where they are.
### Done when
- [ ] The page looks intentional at 375px, 768px and 1440px wide with no horizontal scrollbar at any of them
- [ ] Switching the operating system to dark mode changes the page and every piece of text still reads clearly
- [ ] Tabbing through the page shows a visible focus ring on every link
- [ ] The built page makes zero requests for fonts or stylesheets: everything is in the one HTML file
### 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.
## Phase 3 · Content blocks
Render the socials and projects from their arrays, and make an empty array disappear completely instead of leaving a stray heading.
### Steps
1. Build an inline SVG sprite for social icons
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.svg`, `build.mjs`
2. Render the socials row from content.json
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. Render the projects grid from content.json
A responsive grid (auto-fit, minmax(240px, 1fr)) of cards with title, description and the link. Same rule: empty array, no section.
4. Add rel="noopener noreferrer" and target="_blank" to outbound links
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
- [ ] With three socials and two projects in content.json both sections render with the right icons and links
- [ ] Setting projects to [] and rebuilding leaves no trace of the section: no heading, no empty grid, no extra spacing
- [ ] Every outbound link opens in a new tab and carries rel="noopener noreferrer" in the built HTML
## Phase 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.
### Steps
1. Write server.mjs with node:http that serves dist/ and handles POST /contact
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`
```sh
npm pkg set scripts.start="node --env-file=.env server.mjs"
```
2. Create .env from .env.example and start the server
Node 22 reads .env with the --env-file flag, so no dotenv dependency is needed.
Files: `.env`
```sh
cp .env.example .env
npm start
```
3. Add the form to the page with a honeypot field
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. Reject bots: honeypot, minimum fill time, size cap
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. Rate limit by IP in memory
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. Render the inline thank-you
A #thanks element hidden by default; the :target CSS pseudo-class shows it when the redirect lands on /#thanks. No JavaScript required.
### Done when
- [ ] Submitting the form appends exactly one JSON line to messages.log and the page shows the thank-you
- [ ] Filling the hidden website field shows the same thank-you and appends nothing
- [ ] A submission sent within one second of the page loading is rejected
- [ ] The sixth submission from the same address inside an hour gets a 429
- [ ] The form still submits and shows the thank-you with JavaScript disabled in the browser
### 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.
## Phase 5 · Share cards and performance
The page previews properly when shared, and scores 100 on Lighthouse for Performance, Accessibility and Best Practices.
### Steps
1. Add Open Graph and Twitter meta tags from seo in content.json
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. Generate a 1200x630 OG image at build time
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.mjs`, `dist/og.png`
```sh
npm install satori@0.29.0 @resvg/resvg-js@2.6.2
```
3. Run Lighthouse and fix what it names
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. Set width and height on the avatar and compress it
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
- [ ] Lighthouse reports 100 for Performance, Accessibility and Best Practices on the built page
- [ ] Pasting the page URL into a card validator (or a Slack message) shows the generated OG image and the right title
- [ ] The built HTML is under 30 KB and makes no third-party requests
### Watch out
- Run Lighthouse against the built site served by your server, not against a file:// URL or the dev preview. The scores differ.
## Phase 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.
### Steps
1. Choose the deploy path and write it down
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. Path A: add the Caddyfile and the systemd unit
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: `Caddyfile`, `deploy/site.service`
3. Point the domain at the host
One A record (VPS) or the CNAME the static host gives you. Wait for it to resolve, then load the site over https.
4. Write the README
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. Do a clean-clone test
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.
```sh
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
- [ ] The site loads over https on your domain and the form appends a line to messages.log on the server
- [ ] Someone who has never seen the repo follows the README from clone to a working site without asking a question
- [ ] The README states which keys in content.json are required
## Not in this build
- A visual drag-and-drop editor. That is most of what Carrd's price buys and it is a multi-month product on its own; editing content.json is the trade.
- Hosted form storage and spam filtering as a service. messages.log and a honeypot are honest for one person's inbox.
- Multi-page sites. If you need pages, you have outgrown a one-page tool.
## After v1, if you want it
- Email yourself each message with a small SMTP step in the form server, once messages.log has proven itself for a few weeks
- A second content.json and build target for a project landing page, reusing the same renderer
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port for the form server. Behind Caddy or nginx this stays internal.
PORT=3000
# Required. Your public address. Used to build absolute Open Graph URLs at build time.
SITE_URL=https://yourname.com
# Required. A path the form server can write to. This file is your inbox; back it up.
MESSAGES_LOG=./messages.log
# Optional. Max form submissions per IP per hour. Defaults to 5 when unset.
FORM_RATE_LIMIT=5
# Optional. Submissions faster than this are treated as bots. Defaults to 2.
FORM_MIN_SECONDS=2
You are building a production product version of Carrd.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== PRODUCT.md =====
# Carrd · product brief
## Problem
A one-page static site generator with forms and custom domains is absolutely vibecodable; Carrd's price is so low that DIY only makes sense for control or learning.
## Product outcome
A personal site and contact inbox you fully own, deployable by anyone from the README, monitored so a dead form is noticed, and with its messages backed up off the machine.
## Target user
A builder who needs a maintainable product foundation, not a one-off demo.
## Required capabilities
- static hosting
- domain/DNS
- form endpoint
- HTML/CSS templates
- optional LLM/API for generation
## Explicit non-goals for v1
- A visual drag-and-drop editor. That is most of what Carrd's price buys and it is a multi-month product on its own; editing content.json is the trade.
- Hosted form storage and spam filtering as a service. messages.log and a honeypot are honest for one person's inbox.
- Multi-page sites. If you need pages, you have outgrown a one-page tool.
- the visual editor
- hosted forms
- their template gallery
- not having to think about hosting
## Success criteria
- A clean clone reaches a working site with a working form using only the README
- Lighthouse reports 100 for Performance, Accessibility and Best Practices on the live URL
- The uptime check alerts when the server is stopped
- One restore drill of messages.log has been performed and dated in OPERATIONS.md
- The README states plainly that this replaces Carrd's output, not its editor
===== BRIEF.md =====
# Build brief · Carrd
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a one-page personal site to replace Carrd. Build it in phases, in the
order below. Do not write the whole site in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- A Node build script that renders static HTML from data. No React, no Vue, no
Tailwind, no CSS framework, no build toolchain beyond that one script.
- One Node http server file (no Express) for the contact form only.
- Output is plain HTML + inlined CSS. The site must work with JS disabled.
### Data model (create this before Phase 1)
`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.
### Phase 1 · Build pipeline
Build: `build.mjs` reads content.json, renders `dist/index.html` from a template
literal, copies /public. One npm script: `npm run build`.
Done when: changing `name` in content.json and re-running the build changes the
rendered heading, and a missing required key fails the build with a clear error.
Do not build yet: styling beyond raw semantic HTML, the form, OG images.
### Phase 2 · Design layer
Build: the real visual design as one inlined stylesheet · a strong typographic
scale (clamp() for fluid sizing), generous whitespace, the accent color read
from `content.json` into a CSS custom property, dark mode via
prefers-color-scheme, tasteful hover and focus-visible states.
Done when: the page looks designed rather than templated at 375px, 768px and
1440px, and every interactive element has a visible keyboard focus ring.
Do not build yet: the projects grid.
### Phase 3 · Content blocks
Build: the socials row (inline SVG sprite, no icon font, no external requests)
and the projects grid rendered from the array. Both blocks disappear entirely
when their array is empty · no empty headings, no dangling borders.
Done when: emptying `projects` in content.json renders a page with no visual
trace of the section.
### Phase 4 · Contact form
Build: a POST endpoint in the server file that appends one JSON line per message
to `messages.log`, then re-renders the page with an inline thank-you. Include a
honeypot field hidden with CSS (not `type=hidden`), a minimum fill time check
(reject submissions faster than 2 seconds), and a per-IP rate limit of 5 per
hour held in memory. Reject anything over 4KB. The form must degrade to a normal
HTML form post when JS is off.
Done when: a normal submission appends exactly one line, a bot filling the
honeypot gets the same thank-you but writes nothing, and the sixth submission in
an hour is rejected.
Do not build yet: email delivery · the log file is the inbox for v1.
### Phase 5 · Share and performance
Build: complete OG and Twitter meta tags from `seo`, plus a generated 1200x630
OG image rendered at build time by the same build script (satori + resvg, or a
canvas library · pick one and pin it). Inline all CSS. Self-host any webfont or
use a system font stack. No render-blocking requests.
Done when: Lighthouse scores 100 on Performance, Accessibility and Best
Practices for the built page served over a local static server, and the OG image
renders correctly in a card validator.
### Phase 6 · Deploy and document
Build: a README with the clone-to-live path, and deploy notes for two targets ·
a plain VPS behind Caddy or nginx (with the form server under systemd), and a
static host where the form falls back to a serverless function or a mailto link.
Done when: someone who has never seen the repo can follow the README from clone
to a live site with a working form, without asking a question.
### Out of scope (and why)
- A visual drag-and-drop editor. That is most of what Carrd's price buys, and it
is a multi-month product on its own. Editing JSON is the tradeoff, say so.
- Hosted form storage, spam filtering as a service, and a template gallery.
- Multi-page sites. If you need pages, you have outgrown this build.
### README must contain
- The content.json key reference, with which keys are required.
- Where messages land and how to read them.
- A one-line statement that this replaces the Carrd output, not the Carrd editor.
===== ARCHITECTURE.md =====
# Architecture · Carrd
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22 | one runtime for the build script and the form server, nothing else to install |
| Rendering | Static HTML from a build script | no framework means no dependency to break and a perfect performance score by default |
| Styling | One inlined stylesheet | a single file with no build step is the whole reason this stays fast |
| Form server | node:http, no Express | one file that appends a line to a log is not worth a framework |
| Hosting | Any static host plus one small server | the HTML is static; only the form needs a process |
## Modules
Each module has one owner concern and a documented way to replace it.
| Module | Owns | How to replace it |
| --- | --- | --- |
| Content | content.json and its validation rules | Point build.mjs at a different source (a Markdown file, a headless CMS) that yields the same object |
| Renderer | build.mjs: HTML, inlined CSS, OG image, dist/ | Any static generator that consumes the same content object; the deploy does not change |
| Form server | server.mjs: POST /contact, bot checks, rate limit, messages.log | A serverless function with the same fields and the same log line format |
| Delivery | Caddy or the static host, TLS, the domain | Any reverse proxy or static host; dist/ is plain files |
## Configuration
Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists.
- `PORT` · required · Any free port for the form server. Behind Caddy or nginx this stays internal.
- `SITE_URL` · required · Your public address. Used to build absolute Open Graph URLs at build time.
- `MESSAGES_LOG` · required · A path the form server can write to. This file is your inbox; back it up.
- `FORM_RATE_LIMIT` · optional · Max form submissions per IP per hour. Defaults to 5 when unset.
- `FORM_MIN_SECONDS` · optional · Submissions faster than this are treated as bots. Defaults to 2.
## Production baseline
- Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives.
- Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised.
- Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts.
- Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists.
- Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path.
## Decision records
For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
===== AGENTS.md =====
# Agent instructions · Carrd product build
- Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, Static HTML from a build script, One inlined stylesheet, node:http, no Express, Any static host plus one small server.
- Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit.
- Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present.
- Never invent cryptography or silently weaken a requirement to make a check pass.
- Put every external service behind an interface with a deterministic fake for tests.
- Add migrations and rollback or recovery notes for every persistent data change.
- Log useful operational context without credentials, tokens, passwords or personal data.
- Update documentation and run every check before completing a milestone.
## Known traps
- 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.
- 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.
- 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.
- Run Lighthouse against the built site served by your server, not against a file:// URL or the dev preview. The scores differ.
===== MILESTONES.md =====
# Delivery milestones · Carrd
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · 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.
### Steps
1. Create the project folder and initialise it
Everything lives in one folder. The type: module line lets you use import syntax in the build script.
Files: `package.json`
```sh
mkdir my-site && cd my-site
git init
npm init -y
npm pkg set type=module
mkdir public
```
2. Write content.json with every key the site will use
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. Write build.mjs: read the JSON, render index.html, copy public/
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. Validate content.json before rendering
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. Add the build script and run it
```sh
npm pkg set scripts.build="node build.mjs"
npm run build
npx serve dist
```
### Done when
- [ ] npm run build creates dist/index.html and the page opens in a browser at the URL serve prints
- [ ] Changing name in content.json and running the build again changes the heading on the page
- [ ] Deleting the headline key from content.json makes the build exit with an error naming headline
- [ ] A project description containing a < character renders as text, not as broken markup
### 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.
## M2 · Design layer
Make it look designed rather than templated, with one inlined stylesheet, and prove it on three screen widths.
### Steps
1. Create styles.css and inline it into the HTML at build time
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.css`, `build.mjs`
2. Set the typographic scale with clamp() and a system font stack
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. Read the accent color from content.json into a CSS custom property
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. Add dark mode with prefers-color-scheme
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. Add hover and focus-visible states to every link and button
A visible focus ring on every interactive element, using the accent. Keyboard visitors must be able to see where they are.
### Done when
- [ ] The page looks intentional at 375px, 768px and 1440px wide with no horizontal scrollbar at any of them
- [ ] Switching the operating system to dark mode changes the page and every piece of text still reads clearly
- [ ] Tabbing through the page shows a visible focus ring on every link
- [ ] The built page makes zero requests for fonts or stylesheets: everything is in the one HTML file
### 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.
## M3 · Content blocks
Render the socials and projects from their arrays, and make an empty array disappear completely instead of leaving a stray heading.
### Steps
1. Build an inline SVG sprite for social icons
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.svg`, `build.mjs`
2. Render the socials row from content.json
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. Render the projects grid from content.json
A responsive grid (auto-fit, minmax(240px, 1fr)) of cards with title, description and the link. Same rule: empty array, no section.
4. Add rel="noopener noreferrer" and target="_blank" to outbound links
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
- [ ] With three socials and two projects in content.json both sections render with the right icons and links
- [ ] Setting projects to [] and rebuilding leaves no trace of the section: no heading, no empty grid, no extra spacing
- [ ] Every outbound link opens in a new tab and carries rel="noopener noreferrer" in the built HTML
## M4 · 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.
### Steps
1. Write server.mjs with node:http that serves dist/ and handles POST /contact
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`
```sh
npm pkg set scripts.start="node --env-file=.env server.mjs"
```
2. Create .env from .env.example and start the server
Node 22 reads .env with the --env-file flag, so no dotenv dependency is needed.
Files: `.env`
```sh
cp .env.example .env
npm start
```
3. Add the form to the page with a honeypot field
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. Reject bots: honeypot, minimum fill time, size cap
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. Rate limit by IP in memory
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. Render the inline thank-you
A #thanks element hidden by default; the :target CSS pseudo-class shows it when the redirect lands on /#thanks. No JavaScript required.
### Done when
- [ ] Submitting the form appends exactly one JSON line to messages.log and the page shows the thank-you
- [ ] Filling the hidden website field shows the same thank-you and appends nothing
- [ ] A submission sent within one second of the page loading is rejected
- [ ] The sixth submission from the same address inside an hour gets a 429
- [ ] The form still submits and shows the thank-you with JavaScript disabled in the browser
### 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.
## M5 · Share cards and performance
The page previews properly when shared, and scores 100 on Lighthouse for Performance, Accessibility and Best Practices.
### Steps
1. Add Open Graph and Twitter meta tags from seo in content.json
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. Generate a 1200x630 OG image at build time
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.mjs`, `dist/og.png`
```sh
npm install satori@0.29.0 @resvg/resvg-js@2.6.2
```
3. Run Lighthouse and fix what it names
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. Set width and height on the avatar and compress it
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
- [ ] Lighthouse reports 100 for Performance, Accessibility and Best Practices on the built page
- [ ] Pasting the page URL into a card validator (or a Slack message) shows the generated OG image and the right title
- [ ] The built HTML is under 30 KB and makes no third-party requests
### Watch out
- Run Lighthouse against the built site served by your server, not against a file:// URL or the dev preview. The scores differ.
## M6 · 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.
### Steps
1. Choose the deploy path and write it down
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. Path A: add the Caddyfile and the systemd unit
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: `Caddyfile`, `deploy/site.service`
3. Point the domain at the host
One A record (VPS) or the CNAME the static host gives you. Wait for it to resolve, then load the site over https.
4. Write the README
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. Do a clean-clone test
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.
```sh
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
- [ ] The site loads over https on your domain and the form appends a line to messages.log on the server
- [ ] Someone who has never seen the repo follows the README from clone to a working site without asking a question
- [ ] The README states which keys in content.json are required
## M7 · Operate it like a product (production only)
Only for the product-builder path: know when the form server is down, never lose a message, and keep the box patched.
### Steps
1. Add a /healthz endpoint and an uptime check against it
The server answers 200 with the build id. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so a dead form server is noticed before a visitor notices.
2. Log structured lines and rotate them
One JSON line per request with method, path, status and duration; no IPs stored beyond the rate limiter's memory. Rotate messages.log and the request log with logrotate weekly, keeping eight.
3. Back up messages.log off the box nightly
A cron job that copies the log to object storage or another machine. The log is your inbox; a lost disk must not mean lost messages.
```sh
crontab -e
# 0 3 * * * rclone copy /srv/site/messages.log remote:site-backups/$(date +\%F)/
```
4. Enable unattended security updates and a firewall
Only 22, 80 and 443 open. Automatic security updates on. This is the whole cost of owning a box instead of renting Carrd: say so in OPERATIONS.md.
### Done when
- [ ] Stopping the server triggers an alert from the uptime monitor within a few minutes
- [ ] A restore from last night's backup contains yesterday's messages
- [ ] The firewall shows only 22, 80 and 443 open from outside
===== OPERATIONS.md =====
# Operations · Carrd
## Backup
messages.log is the only state. Copy it off the box nightly with rclone or scp to object storage or a second machine, keeping thirty days.
## Restore
Copy the newest backup back to the path in MESSAGES_LOG and restart the server. Read the last line to confirm it is yesterday's.
Do a restore drill before the first real user, and write the date here when it passes.
## Monitoring
An external uptime check against /healthz every five minutes, alerting to a chat webhook. Watch the 429 count in the request log for a bot flood.
## Incident checklist
If spam floods the log: lower FORM_RATE_LIMIT, restart, and add the offending pattern to the honeypot logic. If the box is compromised: rebuild from the repo, restore messages.log from backup, rotate the SSH key.
1. Contain the issue without destroying evidence or user data.
2. Record the timeline and affected scope.
3. Rotate exposed secrets and revoke compromised sessions or credentials.
4. Restore from a verified backup when needed.
5. Document the root cause, the remediation and the regression test.
## Release gate
- [ ] A clean clone reaches a working site with a working form using only the README
- [ ] Lighthouse reports 100 for Performance, Accessibility and Best Practices on the live URL
- [ ] The uptime check alerts when the server is stopped
- [ ] One restore drill of messages.log has been performed and dated in OPERATIONS.md
- [ ] The README states plainly that this replaces Carrd's output, not its editor
## Launch constraint
Do not market omitted Carrd capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port for the form server. Behind Caddy or nginx this stays internal.
PORT=3000
# Required. Your public address. Used to build absolute Open Graph URLs at build time.
SITE_URL=https://yourname.com
# Required. A path the form server can write to. This file is your inbox; back it up.
MESSAGES_LOG=./messages.log
# Optional. Max form submissions per IP per hour. Defaults to 5 when unset.
FORM_RATE_LIMIT=5
# Optional. Submissions faster than this are treated as bots. Defaults to 2.
FORM_MIN_SECONDS=2
# Carrd · indie build 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 sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 | one runtime for the build script and the form server, nothing else to install | | Rendering | Static HTML from a build script | no framework means no dependency to break and a perfect performance score by default | | Styling | One inlined stylesheet | a single file with no build step is the whole reason this stays fast | | Form server | node:http, no Express | one file that appends a line to a log is not worth a framework | | Hosting | Any static host plus one small server | the HTML is static; only the form needs a process | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - 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. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - 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). - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - 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. - Verify: git --version prints a version - [ ] **Your content, written down** · free - 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. - [ ] **A domain name** (optional) · 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. - [ ] **Somewhere to host it** (optional) · 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. - [ ] **Chrome with DevTools** · free - 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 ## Quick start ```sh mkdir my-site && cd my-site git init npm init -y npm pkg set type=module mkdir public ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - A visual drag-and-drop editor. That is most of what Carrd's price buys and it is a multi-month product on its own; editing content.json is the trade. - Hosted form storage and spam filtering as a service. messages.log and a honeypot are honest for one person's inbox. - Multi-page sites. If you need pages, you have outgrown a one-page tool. - the visual editor - hosted forms - their template gallery - not having to think about hosting If one of those is essential to you, that is the reason to keep paying for Carrd, and the README should say so rather than pretend.
# Build brief · Carrd
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a one-page personal site to replace Carrd. Build it in phases, in the
order below. Do not write the whole site in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- A Node build script that renders static HTML from data. No React, no Vue, no
Tailwind, no CSS framework, no build toolchain beyond that one script.
- One Node http server file (no Express) for the contact form only.
- Output is plain HTML + inlined CSS. The site must work with JS disabled.
### Data model (create this before Phase 1)
`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.
### Phase 1 · Build pipeline
Build: `build.mjs` reads content.json, renders `dist/index.html` from a template
literal, copies /public. One npm script: `npm run build`.
Done when: changing `name` in content.json and re-running the build changes the
rendered heading, and a missing required key fails the build with a clear error.
Do not build yet: styling beyond raw semantic HTML, the form, OG images.
### Phase 2 · Design layer
Build: the real visual design as one inlined stylesheet · a strong typographic
scale (clamp() for fluid sizing), generous whitespace, the accent color read
from `content.json` into a CSS custom property, dark mode via
prefers-color-scheme, tasteful hover and focus-visible states.
Done when: the page looks designed rather than templated at 375px, 768px and
1440px, and every interactive element has a visible keyboard focus ring.
Do not build yet: the projects grid.
### Phase 3 · Content blocks
Build: the socials row (inline SVG sprite, no icon font, no external requests)
and the projects grid rendered from the array. Both blocks disappear entirely
when their array is empty · no empty headings, no dangling borders.
Done when: emptying `projects` in content.json renders a page with no visual
trace of the section.
### Phase 4 · Contact form
Build: a POST endpoint in the server file that appends one JSON line per message
to `messages.log`, then re-renders the page with an inline thank-you. Include a
honeypot field hidden with CSS (not `type=hidden`), a minimum fill time check
(reject submissions faster than 2 seconds), and a per-IP rate limit of 5 per
hour held in memory. Reject anything over 4KB. The form must degrade to a normal
HTML form post when JS is off.
Done when: a normal submission appends exactly one line, a bot filling the
honeypot gets the same thank-you but writes nothing, and the sixth submission in
an hour is rejected.
Do not build yet: email delivery · the log file is the inbox for v1.
### Phase 5 · Share and performance
Build: complete OG and Twitter meta tags from `seo`, plus a generated 1200x630
OG image rendered at build time by the same build script (satori + resvg, or a
canvas library · pick one and pin it). Inline all CSS. Self-host any webfont or
use a system font stack. No render-blocking requests.
Done when: Lighthouse scores 100 on Performance, Accessibility and Best
Practices for the built page served over a local static server, and the OG image
renders correctly in a card validator.
### Phase 6 · Deploy and document
Build: a README with the clone-to-live path, and deploy notes for two targets ·
a plain VPS behind Caddy or nginx (with the form server under systemd), and a
static host where the form falls back to a serverless function or a mailto link.
Done when: someone who has never seen the repo can follow the README from clone
to a live site with a working form, without asking a question.
### Out of scope (and why)
- A visual drag-and-drop editor. That is most of what Carrd's price buys, and it
is a multi-month product on its own. Editing JSON is the tradeoff, say so.
- Hosted form storage, spam filtering as a service, and a template gallery.
- Multi-page sites. If you need pages, you have outgrown this build.
### README must contain
- The content.json key reference, with which keys are required.
- Where messages land and how to read them.
- A one-line statement that this replaces the Carrd output, not the Carrd editor.# Agent instructions · Carrd indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, Static HTML from a build script, One inlined stylesheet, node:http, no Express, Any static host plus one small server. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - Run Lighthouse against the built site served by your server, not against a file:// URL or the dev preview. The scores differ.
# Build plan · Carrd
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.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 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.
### Steps
1. Create the project folder and initialise it
Everything lives in one folder. The type: module line lets you use import syntax in the build script.
Files: `package.json`
```sh
mkdir my-site && cd my-site
git init
npm init -y
npm pkg set type=module
mkdir public
```
2. Write content.json with every key the site will use
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. Write build.mjs: read the JSON, render index.html, copy public/
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. Validate content.json before rendering
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. Add the build script and run it
```sh
npm pkg set scripts.build="node build.mjs"
npm run build
npx serve dist
```
### Done when
- [ ] npm run build creates dist/index.html and the page opens in a browser at the URL serve prints
- [ ] Changing name in content.json and running the build again changes the heading on the page
- [ ] Deleting the headline key from content.json makes the build exit with an error naming headline
- [ ] A project description containing a < character renders as text, not as broken markup
### 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.
## Phase 2 · Design layer
Make it look designed rather than templated, with one inlined stylesheet, and prove it on three screen widths.
### Steps
1. Create styles.css and inline it into the HTML at build time
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.css`, `build.mjs`
2. Set the typographic scale with clamp() and a system font stack
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. Read the accent color from content.json into a CSS custom property
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. Add dark mode with prefers-color-scheme
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. Add hover and focus-visible states to every link and button
A visible focus ring on every interactive element, using the accent. Keyboard visitors must be able to see where they are.
### Done when
- [ ] The page looks intentional at 375px, 768px and 1440px wide with no horizontal scrollbar at any of them
- [ ] Switching the operating system to dark mode changes the page and every piece of text still reads clearly
- [ ] Tabbing through the page shows a visible focus ring on every link
- [ ] The built page makes zero requests for fonts or stylesheets: everything is in the one HTML file
### 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.
## Phase 3 · Content blocks
Render the socials and projects from their arrays, and make an empty array disappear completely instead of leaving a stray heading.
### Steps
1. Build an inline SVG sprite for social icons
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.svg`, `build.mjs`
2. Render the socials row from content.json
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. Render the projects grid from content.json
A responsive grid (auto-fit, minmax(240px, 1fr)) of cards with title, description and the link. Same rule: empty array, no section.
4. Add rel="noopener noreferrer" and target="_blank" to outbound links
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
- [ ] With three socials and two projects in content.json both sections render with the right icons and links
- [ ] Setting projects to [] and rebuilding leaves no trace of the section: no heading, no empty grid, no extra spacing
- [ ] Every outbound link opens in a new tab and carries rel="noopener noreferrer" in the built HTML
## Phase 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.
### Steps
1. Write server.mjs with node:http that serves dist/ and handles POST /contact
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`
```sh
npm pkg set scripts.start="node --env-file=.env server.mjs"
```
2. Create .env from .env.example and start the server
Node 22 reads .env with the --env-file flag, so no dotenv dependency is needed.
Files: `.env`
```sh
cp .env.example .env
npm start
```
3. Add the form to the page with a honeypot field
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. Reject bots: honeypot, minimum fill time, size cap
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. Rate limit by IP in memory
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. Render the inline thank-you
A #thanks element hidden by default; the :target CSS pseudo-class shows it when the redirect lands on /#thanks. No JavaScript required.
### Done when
- [ ] Submitting the form appends exactly one JSON line to messages.log and the page shows the thank-you
- [ ] Filling the hidden website field shows the same thank-you and appends nothing
- [ ] A submission sent within one second of the page loading is rejected
- [ ] The sixth submission from the same address inside an hour gets a 429
- [ ] The form still submits and shows the thank-you with JavaScript disabled in the browser
### 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.
## Phase 5 · Share cards and performance
The page previews properly when shared, and scores 100 on Lighthouse for Performance, Accessibility and Best Practices.
### Steps
1. Add Open Graph and Twitter meta tags from seo in content.json
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. Generate a 1200x630 OG image at build time
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.mjs`, `dist/og.png`
```sh
npm install satori@0.29.0 @resvg/resvg-js@2.6.2
```
3. Run Lighthouse and fix what it names
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. Set width and height on the avatar and compress it
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
- [ ] Lighthouse reports 100 for Performance, Accessibility and Best Practices on the built page
- [ ] Pasting the page URL into a card validator (or a Slack message) shows the generated OG image and the right title
- [ ] The built HTML is under 30 KB and makes no third-party requests
### Watch out
- Run Lighthouse against the built site served by your server, not against a file:// URL or the dev preview. The scores differ.
## Phase 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.
### Steps
1. Choose the deploy path and write it down
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. Path A: add the Caddyfile and the systemd unit
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: `Caddyfile`, `deploy/site.service`
3. Point the domain at the host
One A record (VPS) or the CNAME the static host gives you. Wait for it to resolve, then load the site over https.
4. Write the README
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. Do a clean-clone test
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.
```sh
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
- [ ] The site loads over https on your domain and the form appends a line to messages.log on the server
- [ ] Someone who has never seen the repo follows the README from clone to a working site without asking a question
- [ ] The README states which keys in content.json are required
## Not in this build
- A visual drag-and-drop editor. That is most of what Carrd's price buys and it is a multi-month product on its own; editing content.json is the trade.
- Hosted form storage and spam filtering as a service. messages.log and a honeypot are honest for one person's inbox.
- Multi-page sites. If you need pages, you have outgrown a one-page tool.
## After v1, if you want it
- Email yourself each message with a small SMTP step in the form server, once messages.log has proven itself for a few weeks
- A second content.json and build target for a project landing page, reusing the same renderer# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port for the form server. Behind Caddy or nginx this stays internal. PORT=3000 # Required. Your public address. Used to build absolute Open Graph URLs at build time. SITE_URL=https://yourname.com # Required. A path the form server can write to. This file is your inbox; back it up. MESSAGES_LOG=./messages.log # Optional. Max form submissions per IP per hour. Defaults to 5 when unset. FORM_RATE_LIMIT=5 # Optional. Submissions faster than this are treated as bots. Defaults to 2. FORM_MIN_SECONDS=2
# Carrd · product brief ## Problem A one-page static site generator with forms and custom domains is absolutely vibecodable; Carrd's price is so low that DIY only makes sense for control or learning. ## Product outcome A personal site and contact inbox you fully own, deployable by anyone from the README, monitored so a dead form is noticed, and with its messages backed up off the machine. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - static hosting - domain/DNS - form endpoint - HTML/CSS templates - optional LLM/API for generation ## Explicit non-goals for v1 - A visual drag-and-drop editor. That is most of what Carrd's price buys and it is a multi-month product on its own; editing content.json is the trade. - Hosted form storage and spam filtering as a service. messages.log and a honeypot are honest for one person's inbox. - Multi-page sites. If you need pages, you have outgrown a one-page tool. - the visual editor - hosted forms - their template gallery - not having to think about hosting ## Success criteria - A clean clone reaches a working site with a working form using only the README - Lighthouse reports 100 for Performance, Accessibility and Best Practices on the live URL - The uptime check alerts when the server is stopped - One restore drill of messages.log has been performed and dated in OPERATIONS.md - The README states plainly that this replaces Carrd's output, not its editor
# Build brief · Carrd
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a one-page personal site to replace Carrd. Build it in phases, in the
order below. Do not write the whole site in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- A Node build script that renders static HTML from data. No React, no Vue, no
Tailwind, no CSS framework, no build toolchain beyond that one script.
- One Node http server file (no Express) for the contact form only.
- Output is plain HTML + inlined CSS. The site must work with JS disabled.
### Data model (create this before Phase 1)
`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.
### Phase 1 · Build pipeline
Build: `build.mjs` reads content.json, renders `dist/index.html` from a template
literal, copies /public. One npm script: `npm run build`.
Done when: changing `name` in content.json and re-running the build changes the
rendered heading, and a missing required key fails the build with a clear error.
Do not build yet: styling beyond raw semantic HTML, the form, OG images.
### Phase 2 · Design layer
Build: the real visual design as one inlined stylesheet · a strong typographic
scale (clamp() for fluid sizing), generous whitespace, the accent color read
from `content.json` into a CSS custom property, dark mode via
prefers-color-scheme, tasteful hover and focus-visible states.
Done when: the page looks designed rather than templated at 375px, 768px and
1440px, and every interactive element has a visible keyboard focus ring.
Do not build yet: the projects grid.
### Phase 3 · Content blocks
Build: the socials row (inline SVG sprite, no icon font, no external requests)
and the projects grid rendered from the array. Both blocks disappear entirely
when their array is empty · no empty headings, no dangling borders.
Done when: emptying `projects` in content.json renders a page with no visual
trace of the section.
### Phase 4 · Contact form
Build: a POST endpoint in the server file that appends one JSON line per message
to `messages.log`, then re-renders the page with an inline thank-you. Include a
honeypot field hidden with CSS (not `type=hidden`), a minimum fill time check
(reject submissions faster than 2 seconds), and a per-IP rate limit of 5 per
hour held in memory. Reject anything over 4KB. The form must degrade to a normal
HTML form post when JS is off.
Done when: a normal submission appends exactly one line, a bot filling the
honeypot gets the same thank-you but writes nothing, and the sixth submission in
an hour is rejected.
Do not build yet: email delivery · the log file is the inbox for v1.
### Phase 5 · Share and performance
Build: complete OG and Twitter meta tags from `seo`, plus a generated 1200x630
OG image rendered at build time by the same build script (satori + resvg, or a
canvas library · pick one and pin it). Inline all CSS. Self-host any webfont or
use a system font stack. No render-blocking requests.
Done when: Lighthouse scores 100 on Performance, Accessibility and Best
Practices for the built page served over a local static server, and the OG image
renders correctly in a card validator.
### Phase 6 · Deploy and document
Build: a README with the clone-to-live path, and deploy notes for two targets ·
a plain VPS behind Caddy or nginx (with the form server under systemd), and a
static host where the form falls back to a serverless function or a mailto link.
Done when: someone who has never seen the repo can follow the README from clone
to a live site with a working form, without asking a question.
### Out of scope (and why)
- A visual drag-and-drop editor. That is most of what Carrd's price buys, and it
is a multi-month product on its own. Editing JSON is the tradeoff, say so.
- Hosted form storage, spam filtering as a service, and a template gallery.
- Multi-page sites. If you need pages, you have outgrown this build.
### README must contain
- The content.json key reference, with which keys are required.
- Where messages land and how to read them.
- A one-line statement that this replaces the Carrd output, not the Carrd editor.# Architecture · Carrd ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 | one runtime for the build script and the form server, nothing else to install | | Rendering | Static HTML from a build script | no framework means no dependency to break and a perfect performance score by default | | Styling | One inlined stylesheet | a single file with no build step is the whole reason this stays fast | | Form server | node:http, no Express | one file that appends a line to a log is not worth a framework | | Hosting | Any static host plus one small server | the HTML is static; only the form needs a process | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Content | content.json and its validation rules | Point build.mjs at a different source (a Markdown file, a headless CMS) that yields the same object | | Renderer | build.mjs: HTML, inlined CSS, OG image, dist/ | Any static generator that consumes the same content object; the deploy does not change | | Form server | server.mjs: POST /contact, bot checks, rate limit, messages.log | A serverless function with the same fields and the same log line format | | Delivery | Caddy or the static host, TLS, the domain | Any reverse proxy or static host; dist/ is plain files | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Any free port for the form server. Behind Caddy or nginx this stays internal. - `SITE_URL` · required · Your public address. Used to build absolute Open Graph URLs at build time. - `MESSAGES_LOG` · required · A path the form server can write to. This file is your inbox; back it up. - `FORM_RATE_LIMIT` · optional · Max form submissions per IP per hour. Defaults to 5 when unset. - `FORM_MIN_SECONDS` · optional · Submissions faster than this are treated as bots. Defaults to 2. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Carrd product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, Static HTML from a build script, One inlined stylesheet, node:http, no Express, Any static host plus one small server. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - Run Lighthouse against the built site served by your server, not against a file:// URL or the dev preview. The scores differ.
# Delivery milestones · Carrd
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · 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.
### Steps
1. Create the project folder and initialise it
Everything lives in one folder. The type: module line lets you use import syntax in the build script.
Files: `package.json`
```sh
mkdir my-site && cd my-site
git init
npm init -y
npm pkg set type=module
mkdir public
```
2. Write content.json with every key the site will use
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. Write build.mjs: read the JSON, render index.html, copy public/
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. Validate content.json before rendering
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. Add the build script and run it
```sh
npm pkg set scripts.build="node build.mjs"
npm run build
npx serve dist
```
### Done when
- [ ] npm run build creates dist/index.html and the page opens in a browser at the URL serve prints
- [ ] Changing name in content.json and running the build again changes the heading on the page
- [ ] Deleting the headline key from content.json makes the build exit with an error naming headline
- [ ] A project description containing a < character renders as text, not as broken markup
### 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.
## M2 · Design layer
Make it look designed rather than templated, with one inlined stylesheet, and prove it on three screen widths.
### Steps
1. Create styles.css and inline it into the HTML at build time
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.css`, `build.mjs`
2. Set the typographic scale with clamp() and a system font stack
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. Read the accent color from content.json into a CSS custom property
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. Add dark mode with prefers-color-scheme
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. Add hover and focus-visible states to every link and button
A visible focus ring on every interactive element, using the accent. Keyboard visitors must be able to see where they are.
### Done when
- [ ] The page looks intentional at 375px, 768px and 1440px wide with no horizontal scrollbar at any of them
- [ ] Switching the operating system to dark mode changes the page and every piece of text still reads clearly
- [ ] Tabbing through the page shows a visible focus ring on every link
- [ ] The built page makes zero requests for fonts or stylesheets: everything is in the one HTML file
### 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.
## M3 · Content blocks
Render the socials and projects from their arrays, and make an empty array disappear completely instead of leaving a stray heading.
### Steps
1. Build an inline SVG sprite for social icons
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.svg`, `build.mjs`
2. Render the socials row from content.json
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. Render the projects grid from content.json
A responsive grid (auto-fit, minmax(240px, 1fr)) of cards with title, description and the link. Same rule: empty array, no section.
4. Add rel="noopener noreferrer" and target="_blank" to outbound links
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
- [ ] With three socials and two projects in content.json both sections render with the right icons and links
- [ ] Setting projects to [] and rebuilding leaves no trace of the section: no heading, no empty grid, no extra spacing
- [ ] Every outbound link opens in a new tab and carries rel="noopener noreferrer" in the built HTML
## M4 · 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.
### Steps
1. Write server.mjs with node:http that serves dist/ and handles POST /contact
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`
```sh
npm pkg set scripts.start="node --env-file=.env server.mjs"
```
2. Create .env from .env.example and start the server
Node 22 reads .env with the --env-file flag, so no dotenv dependency is needed.
Files: `.env`
```sh
cp .env.example .env
npm start
```
3. Add the form to the page with a honeypot field
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. Reject bots: honeypot, minimum fill time, size cap
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. Rate limit by IP in memory
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. Render the inline thank-you
A #thanks element hidden by default; the :target CSS pseudo-class shows it when the redirect lands on /#thanks. No JavaScript required.
### Done when
- [ ] Submitting the form appends exactly one JSON line to messages.log and the page shows the thank-you
- [ ] Filling the hidden website field shows the same thank-you and appends nothing
- [ ] A submission sent within one second of the page loading is rejected
- [ ] The sixth submission from the same address inside an hour gets a 429
- [ ] The form still submits and shows the thank-you with JavaScript disabled in the browser
### 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.
## M5 · Share cards and performance
The page previews properly when shared, and scores 100 on Lighthouse for Performance, Accessibility and Best Practices.
### Steps
1. Add Open Graph and Twitter meta tags from seo in content.json
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. Generate a 1200x630 OG image at build time
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.mjs`, `dist/og.png`
```sh
npm install satori@0.29.0 @resvg/resvg-js@2.6.2
```
3. Run Lighthouse and fix what it names
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. Set width and height on the avatar and compress it
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
- [ ] Lighthouse reports 100 for Performance, Accessibility and Best Practices on the built page
- [ ] Pasting the page URL into a card validator (or a Slack message) shows the generated OG image and the right title
- [ ] The built HTML is under 30 KB and makes no third-party requests
### Watch out
- Run Lighthouse against the built site served by your server, not against a file:// URL or the dev preview. The scores differ.
## M6 · 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.
### Steps
1. Choose the deploy path and write it down
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. Path A: add the Caddyfile and the systemd unit
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: `Caddyfile`, `deploy/site.service`
3. Point the domain at the host
One A record (VPS) or the CNAME the static host gives you. Wait for it to resolve, then load the site over https.
4. Write the README
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. Do a clean-clone test
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.
```sh
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
- [ ] The site loads over https on your domain and the form appends a line to messages.log on the server
- [ ] Someone who has never seen the repo follows the README from clone to a working site without asking a question
- [ ] The README states which keys in content.json are required
## M7 · Operate it like a product (production only)
Only for the product-builder path: know when the form server is down, never lose a message, and keep the box patched.
### Steps
1. Add a /healthz endpoint and an uptime check against it
The server answers 200 with the build id. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so a dead form server is noticed before a visitor notices.
2. Log structured lines and rotate them
One JSON line per request with method, path, status and duration; no IPs stored beyond the rate limiter's memory. Rotate messages.log and the request log with logrotate weekly, keeping eight.
3. Back up messages.log off the box nightly
A cron job that copies the log to object storage or another machine. The log is your inbox; a lost disk must not mean lost messages.
```sh
crontab -e
# 0 3 * * * rclone copy /srv/site/messages.log remote:site-backups/$(date +\%F)/
```
4. Enable unattended security updates and a firewall
Only 22, 80 and 443 open. Automatic security updates on. This is the whole cost of owning a box instead of renting Carrd: say so in OPERATIONS.md.
### Done when
- [ ] Stopping the server triggers an alert from the uptime monitor within a few minutes
- [ ] A restore from last night's backup contains yesterday's messages
- [ ] The firewall shows only 22, 80 and 443 open from outside# Operations · Carrd ## Backup messages.log is the only state. Copy it off the box nightly with rclone or scp to object storage or a second machine, keeping thirty days. ## Restore Copy the newest backup back to the path in MESSAGES_LOG and restart the server. Read the last line to confirm it is yesterday's. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring An external uptime check against /healthz every five minutes, alerting to a chat webhook. Watch the 429 count in the request log for a bot flood. ## Incident checklist If spam floods the log: lower FORM_RATE_LIMIT, restart, and add the offending pattern to the honeypot logic. If the box is compromised: rebuild from the repo, restore messages.log from backup, rotate the SSH key. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A clean clone reaches a working site with a working form using only the README - [ ] Lighthouse reports 100 for Performance, Accessibility and Best Practices on the live URL - [ ] The uptime check alerts when the server is stopped - [ ] One restore drill of messages.log has been performed and dated in OPERATIONS.md - [ ] The README states plainly that this replaces Carrd's output, not its editor ## Launch constraint Do not market omitted Carrd capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port for the form server. Behind Caddy or nginx this stays internal. PORT=3000 # Required. Your public address. Used to build absolute Open Graph URLs at build time. SITE_URL=https://yourname.com # Required. A path the form server can write to. This file is your inbox; back it up. MESSAGES_LOG=./messages.log # Optional. Max form submissions per IP per hour. Defaults to 5 when unset. FORM_RATE_LIMIT=5 # Optional. Submissions faster than this are treated as bots. Defaults to 2. FORM_MIN_SECONDS=2
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay because the product costs almost nothing and removes all hosting/domain friction.
xthe visual editor
xhosted forms
xtheir template gallery
xnot having to think about hosting
Don't feel like building it? These folks already made it free.
all 5 free alternatives to Carrd →· no votes, no pay-to-list · just what's real
Carrd pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| basic | $0 | $0 | 3 total site slots; Carrd-branded .carrd.co URL; no custom domains, forms, embeds or third-party analytics |
| pro lite | — | $0.75 | 3 site slots; premium Carrd URLs and no branding; no custom domains, forms, widgets or embeds |
| pro lite 10 | — | $1.17 | 10 site slots; premium Carrd URLs and no branding; no custom domains, forms, widgets or embeds |
| pro lite 25 | — | $2.42 | 25 site slots; premium Carrd URLs and no branding; no custom domains, forms, widgets or embeds |
| pro standard | — | $1.58 | 10 site slots; custom domains, forms, widgets/embeds and analytics |
| pro standard 25 | — | $3.25 | 25 site slots; custom domains, forms, widgets/embeds and analytics |
| pro standard 50 | — | $5.75 | 50 site slots; custom domains, forms, widgets/embeds and analytics |
| pro standard 100 | — | $9.92 | 100 site slots; custom domains, forms, widgets/embeds and analytics |
| pro standard 250 | — | $20.75 | 250 site slots; custom domains, forms, widgets/embeds and analytics |
| pro standard 500 | — | $33.25 | 500 site slots; custom domains, forms, widgets/embeds and analytics |
| pro standard 1000 | — | $49.92 | 1000 site slots; custom domains, forms, widgets/embeds and analytics |
| pro plus | — | $4.08 | 25 site slots; Standard features plus advanced forms/settings, downloads, redirects, password protection and site files |
| pro plus 50 | — | $7.42 | 50 site slots; Standard features plus advanced forms/settings, downloads, redirects, password protection and site files |
| pro plus 100 | — | $13.25 | 100 site slots; Standard features plus advanced forms/settings, downloads, redirects, password protection and site files |
| pro plus 250 | — | $29.08 | 250 site slots; Standard features plus advanced forms/settings, downloads, redirects, password protection and site files |
| pro plus 500 | — | $49.92 | 500 site slots; Standard features plus advanced forms/settings, downloads, redirects, password protection and site files |
| pro plus 1000 | — | $83.25 | 1000 site slots; Standard features plus advanced forms/settings, downloads, redirects, password protection and site files |
free tier3 total site slots on Carrd-branded URLs; no custom domains, forms, embeds/widgets or analytics
billingannual only for Pro plans; optional automatic yearly renewal or manual pay-as-you-go renewal
hidden costsThe low-cost Lite plans do not support custom domains or business features; higher site-slot variants increase the annual price, and account credits/virtual balance are non-cash and non-transferable.
verified 2026-08-14 · source ↗
Vibecode Carrd
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Carrd replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Carrd cost?
Carrd costs about $1.58/month (Pro Standard, checked 2026-07-30), which is $18.96 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Carrd?
Honestly: the visual editor; hosted forms; their template gallery; not having to think about hosting. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Carrd?
Yes: Webstudio (Far more CSS control than Carrd and far more self-hosting responsibility.) Plasmic (A custom-domain visual site on the free plan; comically overpowered for a one-page profile and branded accordingly.) Silex (A visual one-page builder that exports plain HTML and costs nothing; publishing begins with a GitLab login.) All 5 curated free alternatives are at vibecodeit.com/carrd/alternatives. The prompt is for when you want it exactly your way.