npm create astro@latest -- --template minimal🧑🚀 Seasoned astronaut? Delete this file. Have fun!
Inside of your Astro project, you'll see the following folders and files:
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
Astro looks for .astro or .md files in the src/pages/ directory. Each page is exposed as a route based on its file name.
There's nothing special about src/components/, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the public/ directory.
All commands are run from the root of the project, from a terminal:
| Command | Action |
|---|---|
npm install |
Installs dependencies |
npm run dev |
Starts local dev server at localhost:4321 |
npm run build |
Build the site. Static pages land in ./dist/client/, the contact-form function in ./.vercel/output/ |
npm test |
Run the unit tests (src/**/*.test.ts), also run by npm run build |
npm run preview |
Preview your build locally, before deploying |
npm run astro ... |
Run CLI commands like astro add, astro check |
npm run astro -- --help |
Get help using the Astro CLI |
Node 22.18 or newer is required: the tests are TypeScript and run on Node's own test runner, which needs the type stripping that landed in that version.
The site supports Google Analytics 4. It only loads when a measurement ID is set:
- Get your GA4 measurement ID (format
G-XXXXXXXXXX) from the Google Analytics admin. - Set
PUBLIC_GA_IDin the environment:- Local: copy
.env.exampleto.envand fill in the value. - Vercel: add
PUBLIC_GA_IDunder Project Settings, Environment Variables.
- Local: copy
- Redeploy (or restart
npm run dev). With no ID set, no analytics script loads.
generate_lead is the conversion. It fires from two places, both in
src/components/Analytics.astro and src/components/CtaSection.astro:
| method | when |
|---|---|
form |
the contact endpoint confirmed it captured the lead |
email |
a mailto: CTA tagged data-cta="book_working_session" was clicked |
The form conversion is counted on confirmed capture, not on submit. That is
the whole reason the endpoint exists: the old mailto link was counted on click,
including for the many visitors whose mail client never opened. It is suppressed
when the server reports skipped (a honeypot hit, or a dev host), and it fires at
most once per page view even though the form deliberately re-enables itself, so a
visitor sending twice is one lead in GA4 and one contact in HubSpot.
Every event carries source, the property it happened on, matching the slugs in
src/lib/source.ts. Set PUBLIC_SITE_SOURCE on each property (it defaults to
visdom-site); the conversion uses the value the server resolved, so GA4 and
HubSpot never disagree about the same lead. Supporting events: contact_email,
matrix_click, outbound_click, cta_click, plus Core Web Vitals as events.
Two steps live in the GA4 console and cannot be done from this repo:
- Mark
generate_leadas a key event, otherwise it is recorded but not reported as a conversion. - Register
source(andlocale) as custom dimensions, otherwise the parameters are collected but cannot be used to segment reports.
Known gap: a visitor with JavaScript blocked posts the form natively, gets the
server-rendered thank-you page, and is never counted, because gtag cannot run
either. HubSpot still has the lead, so the CRM is the source of truth for lead
volume and GA4 undercounts by that slice. Closing it properly means the GA4
Measurement Protocol (a server-side event with an API secret and the _ga
client id), which is worth doing only if that slice turns out to matter.
POST /api/contact sends the working-session form to HubSpot. HubSpot is both the
system of record for the lead and the sender of every email, in two channels that
are configured independently (src/lib/hubspot.ts):
A. Form submission (no add-on needed, the main path)
- In HubSpot, create a form with the fields
email,firstname,lastname,company,messageand, for the page language,hs_language("Preferred language"). A field the form does not define is dropped and the submission is retried, so a missing optional field costs data, not the lead. - Turn ON "Automatically create new contacts from unknown email addresses" in the form's General settings. It is OFF by default, and with it off a first-time visitor never becomes a contact, which silently loses the lead.
- Do NOT enable reCAPTCHA on the HubSpot form, and ignore HubSpot's warning about it. Submissions arrive server-side from this endpoint rather than from a rendered HubSpot form, so a captcha there rejects every lead. Spam is handled here: Cloudflare Turnstile (verified against siteverify before HubSpot is contacted), a honeypot field, and per-IP rate limiting.
- Set the form's notification recipients, and its follow-up email if the visitor should get a confirmation. That copy lives in HubSpot on purpose: marketing can change it without a deploy. Only people with a HubSpot seat can be picked as recipients.
- Set
HUBSPOT_PORTAL_IDandHUBSPOT_FORM_GUID. The GUID stays in the environment rather than in this repo, so the endpoint is not handed to spammers. - Optional: a private app token in
HUBSPOT_ACCESS_TOKENwith theformsscope uses the authenticated endpoint, which has higher rate limits. Without the scope the endpoint falls back to the public one automatically.
Changing either variable needs a redeploy, and the way to trigger one is to push a
commit. Vercel captures environment variables when a deployment is built, so an
already-running deployment keeps the old values. (vercel redeploy currently
fails on this project: it builds without the cache and then cannot authenticate to
GitHub Packages for @virtuslab/visdom-ui.)
The widget lives on the working-session form; /api/contact verifies the token
with Cloudflare before writing to HubSpot.
- In the Cloudflare dashboard, Turnstile → Add widget. Use Managed. Add
hostnames
visdom.virtuslab.com,localhost, and*.vercel.app(or each preview host you actually use). - Set
PUBLIC_TURNSTILE_SITE_KEY(site key, ships in the page) andTURNSTILE_SECRET_KEY(server-side only, never rename toPUBLIC_) in Vercel, then push a commit so the new values are captured at build time. - A production build without both keys fails (
npm run check:env). Local and preview builds skip verification when the secret is unset, so contributors can still submit the form. Dummy keys from Cloudflare's testing docs work if you want the widget locally without a real challenge.
The same HubSpot form is shared, on purpose: duplicating it would split the
submission history and double the settings that can silently drift apart. Nothing
in HubSpot needs changing to add a property, because the origin travels as
submission context (pageUri, pageName) rather than as a form field.
Each lead is attributed in three places: the page name and page URL on the HubSpot
submission, a Source: line in the transactional notification, and source= in
the server log.
To add a property (src/lib/source.ts):
- Add its hostname to
SOURCESwith a slug and a human label. - Have its form send
sourcewith that slug, both in the JSON payload and as a hidden input for the no-JS path. SeeSOURCEinsrc/components/CtaSection.astro. - Decide how it reaches the endpoint:
- Ship a copy of the endpoint on that property, which is what the Maturity Matrix already does for its own email. Nothing else to configure.
- Or post to this endpoint cross-origin, which additionally needs the
property's origin in
CONTACT_ALLOWED_ORIGINShere. It is an explicit allow-list, never a wildcard: this endpoint is unauthenticated and writes to the CRM.
Step 1 is a convenience, not a requirement. A property that declares a slug this
repo has never heard of is still recorded under that slug, and a property that
declares nothing is classified by Origin, then Referer, then the serving host.
A caller-supplied slug is trusted for labelling only: it can never change where the
lead goes, who is notified, or whether the CRM write happens, and anything outside
[a-z0-9-] is discarded rather than sanitised.
A misconfigured deployment builds and serves exactly like a working one, which is how this form spent three days answering every visitor with an error before anyone noticed. Three things now make that loud:
npm run check:env, part ofnpm run build. A production build fails if no channel can accept a lead or Turnstile keys are missing, and names the missing variables. Local and preview builds only warn, so a contributor without HubSpot or Cloudflare access can still build.VISDOM_ALLOW_UNCONFIGURED=1overrides it and says so in the log.GET /api/contact, a health check reporting booleans only, never values. It answers 503 when nothing can accept a lead, so an uptime monitor pointed at it treats a silently broken form as the outage it is.configured.captchareports whether Turnstile can be verified. This is what catches a variable that was changed but never redeployed, which the build guard cannot see.npm run smoke(optionallynpm run smoke -- https://preview-url) asks a running deployment the same question and exits non-zero if it is unhealthy. It has no side effects: the health check is a read, its honeypot submission returns before contacting HubSpot, and a tokenless POST (when captcha is configured) is refused with 4xx. Safe to run against production as often as you like.
None of these prove a lead reaches the right HubSpot form. Nothing observable from outside can, because a submission to the wrong form succeeds just as loudly as one to the right form. That check is a real submission plus a look at the CRM. Two settings on the HubSpot side deserve the same suspicion, since both fail silently with a 200: contact creation being off, and reCAPTCHA being on.
B. Transactional Single-Send emails (needs the transactional email add-on)
- Connect the sending domain in HubSpot and create the templates in the email tool as transactional emails.
- The team notification template receives
{{ custom.summary }}(the whole request as text) pluslead_name,lead_email,lead_company,lead_message,locale,page_uriandsubmitted_at. The visitor confirmation template receives only{{ custom.first_name }}and{{ custom.locale }}: nothing else the visitor typed is echoed back to an address we have not verified. - Do not use
|safeon those tokens. They carry visitor input. - Set
HUBSPOT_ACCESS_TOKEN(scopetransactional-email) and the template IDs inHUBSPOT_NOTIFY_EMAIL_ID/HUBSPOT_CONFIRM_EMAIL_ID.
Behaviour worth knowing:
- The endpoint succeeds when either channel accepted the lead, and returns 502
with a reference like
HS-1A2B3C4Dwhen neither did. Every failure is logged once against that reference. - A refusal HubSpot reports inside a 200 (bounced before, unsubscribed, bad template) is a failure here. It is never counted as a delivery.
- Each submission carries an idempotency key, so a retry after a timeout cannot send the same email twice.
localhost,*.vercel.appand*.pages.devskip the CRM write and are left out of the GA4 conversion, so a test submission cannot look like a lead. SetHUBSPOT_ALLOW_DEV_SUBMIT=1to point them at a sandbox portal instead.
See .env.example for every variable. Set them in Vercel under Project Settings,
Environment Variables. The token is server-side only and must never be renamed to
PUBLIC_.
Feel free to check our documentation or jump into our Discord server.