Catch the leads that leave your Astro forms without hitting submit.
When a visitor types into your form and walks away, the lead is saved. Around that capture sits the complete lead-ops platform: recovery emails, quote payments, and a self-hosted admin, with zero external services by default, just a SQLite file and your existing SMTP env vars. 1,373 unit tests and a Playwright e2e suite cover it.
Running in production on a live services business: versions 0.1.2 through 0.1.10 each shipped from a real-world finding there. The stories are in Hardened in production.
cool-astro-forms instruments the Astro forms you already have; it is not a form builder. It rebuilds the lead-capture layer WordPress sites assemble from premium form plugins and their paid addons as one MIT Astro integration for server-output sites, covering 5 stages:
- Capture: form-abandonment capture, per-visitor journey tracking, and IP geolocation on every saved entry
- Recover: a save-confirmed "progress saved" toast plus one automated follow-up email per abandoned lead
- Convert: quote-first Stripe and PayPal payments, plus shareable
/forms-paypayment links - Manage: the
/forms-adminUI (entries, abandoned leads, payments, analytics funnel, CSV +.dbexport) - Integrate: Google Drive uploads, signed outbound webhooks, and Cloudflare Turnstile spam control
We haven't found another open-source package that offers abandonment capture for Astro or static sites. The other 4 stages ship alongside it so a captured lead has somewhere to go.
Every step below is executed end-to-end by scripts/verify-quickstart.mjs: a real npm pack tarball installed into a scratch Astro project, built, served, and hit with a real abandon POST that lands a row in SQLite. If this section and that script ever disagree, the script is right.
npm install cool-astro-forms @astrojs/nodeRequires Astro 7.2.8 or a later 7.x release (Astro 6 is not supported), a server-output adapter, and Node 22.12+. The install compiles better-sqlite3 natively when no prebuilt binary matches your platform; if that build fails you need Python plus a C toolchain, or skip native modules entirely with the Turso/libSQL path (docs/serverless.md).
Optional scaffold first: init writes a full .env.example (every optional env var this package reads) and appends data/ to .gitignore:
npx cool-astro-forms initastro add cool-astro-forms also works, but it inserts a bare coolForms() call that fails validation on your next astro dev/astro build: siteId, siteUrl, and each form's notifyTo are required, with no defaults. Replace the bare call with the snippet below.
Every field shown here is required:
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import coolForms from 'cool-astro-forms';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [
coolForms({
siteId: 'my-site',
siteUrl: 'http://localhost:4321', // must match the origin you actually serve on — swap for your real domain once deployed
forms: {
contact: { notifyTo: 'owner@example.com' },
},
}),
],
});Deploying behind a proxy or CDN? Production hosts that terminate TLS need one extra security line; see the note after step 4.
Add a data-caf="<formId>" attribute; no other markup changes are needed:
<form data-caf="contact" method="post" action="/api/contact">
<input type="text" name="name" />
<input type="email" name="email" />
<button type="submit">Send</button>
</form>npm run build
node dist/server/entry.mjsThe capture route is auto-injected at /api/forms/abandon. A visitor who types into that form and leaves lands a row in data/forms.db at once and, after a short delay (abandonment.alertDelayMins, 15 minutes by default), a notification email at notifyTo. That is the entire adoption contract: one coolForms() call, one attribute. Payments, Drive uploads, lead recovery, and the admin UI stay inert until you opt in.
Deploying behind a proxy? Most production Node hosts (Hostinger, Passenger/LiteSpeed, most PaaS) terminate TLS before your Node process, so Astro sees a plain-HTTP socket and only trusts the proxy's
X-Forwarded-Protoheader whensecurity.allowedDomainsis set. Without it, every urlencoded form POST to a server route fails with403 Cross-site POST form submissions are forbidden, and the first place you will meet that is the/forms-adminlogin form. Add your real domain once you deploy:security: { allowedDomains: [{ hostname: 'example.com', protocol: 'https' }], },Abandonment capture keeps working either way (it posts JSON, which Astro's CSRF check ignores); the break hits admin login and payment form posts.
Package emails (abandoned-lead alerts, payment quotes and receipts, lead recovery) go out through your own SMTP server, set with environment variables. There are two modes:
| Variable | Credential mode (default) | Relay mode (EMAIL_AUTH=ip) |
|---|---|---|
EMAIL_HOST |
Required | Required |
EMAIL_PORT |
Required | Required. Port 587 must upgrade to TLS (STARTTLS), 465 uses TLS from the start |
EMAIL_USER |
Required | Not used, and never sent |
EMAIL_PASS |
Required | Not used, and never sent |
EMAIL_AUTH |
Leave unset | ip: the relay accepts mail from your server's IP address (Google Workspace SMTP relay works this way), so no login is attempted |
EMAIL_FROM |
Optional sender address | Strongly recommended: an address on your site's domain |
EMAIL_FROM_NAME |
Optional display name, such as Acme Bookings |
Same |
The sender is EMAIL_FROM, then EMAIL_USER if it is an email address, then NOTIFY_EMAIL, then noreply@cool-astro-forms.local. EMAIL_FROM takes a bare address; the display name goes in EMAIL_FROM_NAME. When the sender's domain is not your siteUrl domain, the server logs one notify.from-domain-mismatch warning, because relays and receiving servers often refuse that mail. If the variables your mode needs are missing, production skips package emails and logs notify.smtp-unconfigured once; outside production the emails are printed to the log instead.
With Astro's security.csp turned on, the /forms-admin pages stay styled and working: each one registers the hash of its inline stylesheet with Astro, and the payment-link copy button runs from a bundled script rather than inline handlers. If you set your own Content-Security-Policy header instead, add ADMIN_STYLE_HASH (exported from cool-astro-forms/server/admin/_shared.js) to its style-src; every admin page, the login page included, uses that one stylesheet. The copy button's script is built by Astro (a small one is inlined), so only Astro's own security.csp lists it automatically; under a hand-written header the button may not work, though copying the link by hand still does.
-
The injected client script stages fields as the visitor types. Passwords,
data-caf-ignorefields, card/CSRF-shaped names, and captcha widget tokens (cf-turnstile-response,h-captcha-response,g-recaptcha-response) are never staged. -
Any of the 4 capture triggers POSTs the staged fields to
/api/forms/abandon. -
The server gates the save (origin check, rate limit, honeypot, email-or-phone requirement), dedupes repeat abandons within a 60-minute window, and writes one SQLite row with the journey trail and geolocation attached.
-
After a short delay (
forms.<id>.abandonment.alertDelayMins, default 15 minutes; a submission inside it cancels the alert) you get a notification email carrying the captured fields, the journey trail, the visitor's location, and the traffic source; the visitor sees a save-confirmed toast, and lead recovery (opt-in) sends one follow-up email with a link to finish. -
A visitor who returns and submits converts:
recordSubmission()marks the entry converted and stitches the full journey onto it. -
/forms-admin/analyticsturnsform_startedpings and captures into a funnel with abandonment rate and top drop-off fields. -
Payments close the loop: create a quote from any entry, or share
/forms-pay?amount=200(the payer enters an email; Stripe records them as a Customer when they pay, and their next payment is matched to it). The server recomputes every total, and only a verified provider webhook marks a payment paid. The pay page submits over fetch and hops to the provider as a plain GET, so edge bot-challenges (Cloudflare and friends) cannot break the checkout hop; seedocs/payments.mdsection 3b.
Every trigger, gate, and fallback in detail, with screenshots: docs/how-it-works.md.
| Feature | Detail |
|---|---|
| Abandonment capture | 4 triggers: exit-intent, external-link click, beforeunload, tab-hidden; dedupe window; per-form overrides; owner alert deferred by abandonment.alertDelayMins (default 15, 0 = immediate) so a tab switch followed by a submit sends nothing |
| User-journey tracking | Per-visitor page trail, shown on each entry's timeline |
| IP geolocation | Every saved entry enriched (GEO_PROVIDER override supported); a failed lookup never blocks the save |
/forms-admin UI |
Entries, abandoned, payments, analytics funnel, CSV + .db export; server-rendered, password-protected |
| Payments | Quote-first Stripe Checkout + PayPal; shareable /forms-pay?amount= links; server-computed fees; fetch-first transport (edge-bot-challenge-proof checkout hop); inbound webhooks as the sole payment truth; "payment received" email to the form's notifyTo, else payments.notifyTo, else the first form's |
| Spam control | Cloudflare Turnstile (soft-fail), honeypot, rate limiting |
| File uploads | Google Drive via raw REST (no SDK); Drive failures fall back to email attachment, the entry always saves, and oversized files are flagged |
| Lead recovery | One automated follow-up email per visitor, one-click unsubscribe honored forever |
| Outbound webhooks | Signed (X-Caf-Signature): entry.submitted, entry.abandoned, payment.paid |
| GDPR mechanics | retentionDays purge, purgeVisitor() erasure, requireConsent gating |
| Storage | SQLite file by default; optional Turso/libSQL for serverless hosts (docs/serverless.md) |
WordPress sites pay for form-abandonment capture; Astro and static sites have had no open-source equivalent. That gap is why this package exists. It is an independent, clean-room implementation, not affiliated with or endorsed by any commercial form-plugin vendor (see NOTICE). The table maps the premium form plugin features you lose leaving WordPress to what this provides:
| Capability | WordPress forms stack (Pro + addons) | Hosted form SaaS | Self-hosted OSS form backends | cool-astro-forms |
|---|---|---|---|---|
| Abandonment / partial-entry capture | Paid addon | Not offered | Not offered | Built in |
| Automated lead-recovery emails | Via automation addons | Not offered | Not offered | Built in |
| Stripe / PayPal quote payments | Paid addons | Varies by plan | Not offered | Built in |
| Admin UI + analytics | WordPress admin | Their dashboard | Minimal or none | /forms-admin |
| Where your data lives | Your WP database | Their servers | Your infrastructure | Your SQLite file |
| Cost | Annual per-site license | Monthly plan | Free | Free, MIT |
One deliberate trade-off: this package captures and manages form data, but it doesn't generate form markup. Bring your own form; a drag-and-drop builder is the one WordPress feature it won't replace.
This package runs in production on a live services business. Versions 0.1.2 through 0.1.10 each shipped from a finding that surfaced there, and the fixes are in the defaults you install today:
-
Payments survive edge bot-challenges. A challenged navigation POST is structurally unrecoverable: the interstitial cannot replay the body. Only real browsers get challenged, so every curl and dev-environment test passes while production clicks die. The pay page submits over fetch (unchallengeable by design) and hops to checkout as a plain GET (docs/payments.md section 3b). When a Turnstile token expires mid-payment, the page recovers itself: inline explanation, refreshed widget, re-armed button.
-
Lead history survives redeploys. Git-deploy hosts rebuild the app directory on every release, wiping the default
data/dir.dbPathis env-configurable so the SQLite file can live outside the deploy dir; the package creates the directory and keeps the admin secret beside it. -
Turnstile in the real world. Test sitekeys mint short dummy tokens, so submit buttons arm on any non-empty token, never on token shape.
remoteipis not sent to siteverify because dual-stack visitors solve on one IP family and post on the other. Every rejection carries its Cloudflare error code, so failures diagnose themselves. A token works once and the abandonment capture spends it, so the client re-arms the widget after every send that carried one, and a host gating its own submit endpoint should still retry once ontimeout-or-duplicaterather than refuse a real person.verifyTurnstilereportsverified/rejected/skipped/unreachable, so "nothing was checked" never looks like "the visitor failed" — and a missing secret key warns once per process instead of running an inert gate in silence. -
Notification emails carry the whole picture. Journey trail, IP geolocation, and traffic source arrive next to the form fields, and hosts can brand every template through
templatesModule. A branded override looks like this (fictional demo brand):
docs/how-it-works.md— the full lifecycle walkthrough with screenshots: capture triggers, the server gate, admin views, analytics, recovery, payments, storagedocs/payments.md— Stripe/PayPal setup, the/forms-paycontract, fee caveats, webhook receiver recipesdocs/drive.md— Google Drive uploads: one-time consent, the 7-day token-expiry pitfall, the fallback contractdocs/recovery.md— lead-recovery emails: consent modes, per-form scoping, unsubscribe mechanicsdocs/serverless.md— Turso/libSQL storage, explicit secrets, cold-start-safe rate limitingdocs/gdpr.md— each retention, erasure, and consent mechanic mapped to the GDPR concept it servesdocs/faq.md— short self-contained answers: static sites, external services, payments, GDPR, production readinessdocs/comparison.md— the capability table as a standalone page, including when NOT to choose this packageCHANGELOG.md— release notes; 0.1.12 changes a default (payments.feeOverrides), read before upgrading
Tag the form with data-caf and configure coolForms(). The injected client script stages typed values and POSTs them to /api/forms/abandon when any of the 4 capture triggers fires. The entry lands in SQLite with the visitor's journey trail and geolocation attached.
This package: it rebuilds the Pro-tier lead-capture layer of commercial WordPress form plugins (abandonment capture, payments, uploads, recovery emails, admin UI) as one MIT Astro integration with no per-site license and no hosted service.
Every abandoned entry stores the fields typed so far, the last-edited field, and a form_started ping. /forms-admin/analytics turns those into a captured→converted funnel with abandonment rate and top drop-off fields.
Create a pay-link from any entry in the admin, or share /forms-pay?amount=200 with no entry at all. The server recomputes every total, and payments are marked paid only by verified Stripe or PayPal webhooks (docs/payments.md).
Yes: /forms-admin ships in this package with entries, abandoned leads, payments, analytics, CSV and .db export, enabled by a single FORMS_ADMIN_PASSWORD env var. Server-rendered, no client framework.
Uploads land in your own Google Drive over raw REST (no SDK dependency). If Drive fails, files up to ~10MB fall back to email attachment; larger files are flagged and the entry still saves (docs/drive.md).
Test and build commands, the clean-room statement, and the blocking pre-publish checklist (including a required security audit) live in CONTRIBUTING.md.
Built and maintained by Nipun Arora.
MIT; see LICENSE.








