Training and curated, valuable news for agentic engineering. Bilingual (NL/EN), dark terminal-native aesthetic, deployed on Vercel.
Live: https://agenticengineering.nl
| Layer | Choice |
|---|---|
| Framework | Next.js 15 (App Router, RSC) |
| Runtime | Node.js 20 |
| Package manager | pnpm 9 |
| Styling | Tailwind CSS v4 (@theme tokens in app/globals.css) |
| i18n | next-intl (NL default, EN alt) |
| Forms | react-hook-form + Zod |
| Resend | |
| Payments | Stripe (Checkout Sessions + webhook signature verification for pilot booking) |
| Tests | Vitest (unit) + Playwright (e2e + axe a11y) |
| Hosting | Vercel (Fluid Compute) |
Prerequisites: Node.js 20 (.nvmrc), pnpm 9, git, Claude CLI (claude).
git clone https://github.com/<owner>/agenticengineering.nl.git
cd agenticengineering.nl
nvm use # picks Node 20 from .nvmrc
corepack enable # provides pnpm 9
pnpm install
pnpm exec playwright install # one-time, only if you'll run e2e
cp .env.example .env.local # required: copy and fill in for local dev (contact form + feature flags)
pnpm dev # http://localhost:3000 → redirects to /nlThe pre-commit readme-check hook requires the Claude CLI and an Anthropic API key:
curl -fsSL https://claude.ai/install.sh | bash # or: brew install --cask claude-code
export ANTHROPIC_API_KEY=<your-api-key> # add to ~/.zshrc / ~/.bashrc to persistFirst-run sanity check:
pnpm typecheck && pnpm lint && pnpm test && pnpm verify:i18nContact form needs RESEND_API_KEY, CONTACT_FROM_EMAIL, CONTACT_EMAIL in .env.local (see Environment variables). Skip if you're only touching UI/copy — the rest of the site renders without them.
Editing checklist:
- UI/copy → read
PRODUCT.md+DESIGN.mdfirst. - New translation key → add to both
messages/nl.jsonandmessages/en.json;pnpm verify:i18ngates CI. - New training → add a
Trainingentry indata/trainings.ts(typed:id,durationDays,priceEUR,modules[],deliveryFormats[], plus optionalschedule,earlyBird, andsoldOut) and add its copy keys to bothmessages/{nl,en}.jsonundertrainings.<id>(name,tagline,audience[],prerequisites[],outcomes[]— plusearlyBirdNotewhen the training has anearlyBird), plustrainings.duration.<id>andtrainings.cardMeta.<id>. A scheduled date is optional and lives in two places: the human-facing date inside thenamestring in parentheses (e.g."Pilot - Basic Training (June 29th & 30th 2026)"/"... (29 en 30 juni 2026)", localised per file), and — for trainings that run on a known date — a machine-readableschedulefield on theTraining(ISOstartDate/endDate+courseMode: ['online']). Thescheduledrives the schema.orgCourseInstancein the structured data (lib/structured-data.ts); omit both for trainings with no fixed date. An optionalearlyBird({ discountPct, deadline }, with the deadline as an ISO string carrying an explicit timezone offset) applies a server-enforced discount whilenowis strictly before the deadline — seepriceFor()inlib/pricing.ts; the card and detail page then render the struck base price, the discounted price, and theearlyBirdNote. New module IDs need a matching block undermodules.<module-id>(title,bullets[],short) in both files. Use kebab-case IDs; reuse existing module IDs where the content overlaps. - Significant feature change (new/reworked training, etc.) → capture the design in a dated spec under
docs/superpowers/specs/<YYYY-MM-DD>-<slug>.md. - New route → add it under
app/[locale]/, give it agenerateMetadataviaexport const generateMetadata = metadataFor('/about', 'pages.about')(themetadataFor(path, key)wrapper readsmeta.<key>.title/.description, both locales). Pages with a dynamic param, non-metanamespace, or a composed title callbuildPageMetadata({ locale, path, title, description })directly (seeapp/[locale]/trainings/[trainingId]/page.tsx). Add the path toapp/sitemap.ts(the sitemap is an explicitPATHSlist — training detail pages are derived fromdata/trainings.ts, other routes are listed by hand). - API/server logic → keep validation in
lib/validation.ts, side effects inlib/*. - New news article → create
news/<slug>.mdwith required frontmatter (see below). Runpnpm article:image <source-url> <slug>to fetch and save the OG image before committing. - Pre-commit
lefthookhook runsformat,lint, andreadme-check(validates README stays in sync; requiresclaudeCLI +ANTHROPIC_API_KEY). Don't bypass with--no-verifyunless you're fixing the hook itself. - Bookable trainings (self-serve Stripe checkout) are the set in
bookableTrainingEnum(lib/validation.ts) — currentlypilotanddiscount-aug-26. Each one needs its booking routes (/trainings/<id>/book+/trainings/<id>/book/success) and, in bothTrainingCard.tsxandTrainingDetail.tsx, its booking CTA (/trainings/<id>/book) and secondary contact link kept in sync. Each booking page file (app/[locale]/trainings/<id>/book/page.tsx) is a thin wrapper that callssetRequestLocaleand delegates to the sharedcomponents/BookPage.tsxwith itstrainingId;BookPagefetches the training name fromgetTranslations('trainings')and passes it to the booking messages as{ trainingName }(bothbooking.titleandbooking.introexpect it) — seeapp/[locale]/trainings/pilot/book/page.tsxfor the pattern. - The primary CTA label is conditional in both components (
isBookable ? 'bookCta' : 'requestCta', whereisBookableis membership ofbookableTrainingEnum): bookable trainings showtrainings.labels.bookCta("Book training" / "Boek training"), the rest showtrainings.labels.requestCta("Request training" / "Vraag training aan"). - A training with
soldOut: trueon itsTrainingentry renders a sold-out state in bothTrainingCard.tsxandTrainingDetail.tsx: the sharedcomponents/SoldOutBadge.tsxribbon (trainings.labels.soldOut, "Sold out" / "Uitverkocht") — a diagonal banner (position: absolute,rotate-45, pinned to the top-right corner, clipped by the parent'soverflow-hidden) — overlays both the card and the detail page section (it does not replace the pilot badge; on the pilot card both render), the card content and large price are dimmed (opacity-60; the small struck price / suffix / note stay full-opacity for AA contrast), and the primary CTA renders disabled with nohrefand anaria-labelthat names the sold-out state; the secondary contact link stays so visitors can always get in touch about a (sold-out) training. On the detail page, the bottom price box additionally shows a sold-out note (trainings.labels.soldOutNote+ asoldOutContactlink to/<locale>/contact?training=<id>) below the price, with the disabled CTA kept. The sharedcomponents/BookPage.tsx(used by every/trainings/<id>/bookroute) guards generically ontrainings[trainingId].soldOut, so a sold-out training's booking page automatically swaps its copy tobooking.soldOutHeading/soldOutBody/soldOutBackand hides theBookingForm— no per-route check. The authoritative gate is server-side:POST /api/checkoutreturns409 { ok: false, error: 'sold_out' }for a sold-outtrainingIdbefore reaching Stripe, regardless of what the client posts — the disabled CTA is UX only. - The
/trainingsoverview and the homepage render a curated, ordered list of cards (DISPLAYED_TRAININGSinapp/[locale]/trainings/page.tsx; hardcoded cards on the homepage), not every entry indata/trainings.ts.basicstays in the dataset (its detail route still resolves and it is the template the dated cohorts mirror) but is not shown as a card.
title_nl: 'NL title' # required
title_en: 'EN title' # required
url: 'https://...' # required — canonical link shown to readers
source_url: 'https://...' # optional — URL visited to scrape og:image (defaults to url)
type: article # optional — 'article' (default) or 'blog'
date: 'YYYY-MM-DD' # required
author: 'First Last' # optional
placed_by: 'Name' # optional — who curated this entry
summary_nl: '...' # required
summary_en: '...' # required
image: '/news/<slug>.jpg' # optional — path relative to /public; falls back to /qas-icon.svgFetch the image with:
pnpm article:image <source-url> <slug>
# Example: pnpm article:image https://medium.com/some-post my-article-slug"The script reads the page's og:image in headless mode, downloads it, and saves it to public/news/<slug>.<ext>. The source host and the image host must both be listed in data/trusted-domains.json — add new domains there when needed.
Deploy: push to main → auto-prod via Vercel GitHub App. Push any other branch → preview URL (see Preview environment).
app/
[locale]/ # NL/EN routed pages (home, about, contact, impressum)
api/checkout/ # POST handler — creates Stripe Checkout Session
api/contact/ # POST handler — Zod + rate-limit + Resend
api/stripe/webhook/ # POST handler — Stripe webhook signature verification + fulfillment
robots.ts # /robots.txt
sitemap.ts # /sitemap.xml
globals.css # Tailwind v4 @theme block (single source of design tokens)
components/ # Hero, Nav, Footer, TrainingCard, TrainingDetail, ContactForm,
# BookingForm, ArticleFilterBar, InstructorCard, Button, DayAgenda,
# ProofStrip, TimelineEntry, JsonLd, LangSwitcher, MobileMenu, …
lib/
validation.ts # Zod schemas (contactSchema, bookingSchema, trainingInterestEnum, …)
email.ts # Resend wrapper, sendContactEmail(), sendBookingConfirmation(), sendBookingNotification()
rate-limit.ts # Per-IP token bucket (in-memory; per-instance)
http.ts # HTTP utilities (isAllowedOrigin, clientIp for origin/IP validation)
sanitize.ts # CRLF strip for email headers
articles.ts # Article/news loader (reads news/ markdown files)
parseFrontmatter.ts # Frontmatter parser for markdown articles
flags.ts # Feature flag helpers (BLOGS_ENABLED, …)
pricing.ts # VAT calculation + `priceWithVat` function
stripe.ts # Stripe client factory with memoization (getStripe, __resetStripeForTests)
webhook-dedupe.ts # Webhook event deduplication (markHandled, unmarkHandled, __resetWebhookDedupeForTests)
structured-data.ts # schema.org JSON-LD graph builder for the homepage (buildHomeJsonLd)
page-metadata.ts # metadataFor(path, key) wrapper + buildPageMetadata({ locale, path, title, description }) — single source for per-page SEO (canonical, hreflang, OpenGraph)
data/
trainings.ts # Training catalogue + modules (typed)
instructors.ts # Instructor profiles (typed)
trusted-domains.json # Allowlist for origin/CSRF checks
news/ # Markdown news + blog posts (frontmatter + body)
i18n/ # next-intl config (routing.ts, request.ts)
messages/ # nl.json, en.json (translation keys)
scripts/
verify-i18n.ts # CI gate: NL/EN key parity
fetch-article-images.ts # Downloads OG images for news articles
metrics.ts # Site metrics helper
tests/ # Vitest unit + Playwright e2e
docs/
superpowers/specs/ # Design specs for significant feature changes (dated markdown)
PRODUCT.md # Brand register (users, tone, anti-references, principles)
DESIGN.md # Design system (colors, typography, components, do's/don'ts)
CLAUDE.md # Operational shortlist for agents (commands, conventions, deployment)
LICENSE # MIT license (© QualityAtSpeed)
next.config.ts # Security headers + next-intl plugin
pnpm install
pnpm dev # http://localhost:3000 (auto-redirects / → /nl)Routes:
/nl,/en— locale-scoped pages/nl/about,/nl/contact,/nl/impressum(and/en/*)/[locale]/trainings— trainings overview/[locale]/trainings/[trainingId]— training detail page/[locale]/trainings/<id>/book— booking form for each bookable training (pilot,discount-aug-26)/[locale]/trainings/<id>/book/success— post-payment UXPOST /api/contact— POST endpointPOST /api/checkout— creates Stripe Checkout SessionPOST /api/stripe/webhook— Stripe fulfillment webhook/sitemap.xml,/robots.txt/llms.txt— static machine-readable site summary for AI crawlers/assistants (public/llms.txt, hand-maintained; update prices/dates/links when trainings change)
Assistants (ChatGPT, Claude) surface the site through their web-search tools, so discoverability rides on standard crawlability plus machine-readable facts:
robots.txtallows all user agents (GPTBot, ClaudeBot, OAI-SearchBot included).lib/structured-data.tsbuilds the homepage schema.org graph:Organization+ oneCourseper training, with aCourseInstance(online mode + ISO dates) for any training carrying aschedule. Keep this the single source — don't re-inline JSON-LD in pages.public/llms.txtgives crawlers a concise overview with deep links.
Out of scope for the repo (one-time, owner-side): submitting the sitemap to Bing Webmaster Tools (ChatGPT leans on Bing) and Google Search Console.
pnpm typecheck # tsc --noEmit
pnpm lint # eslint
pnpm test # vitest run (unit)
pnpm test:e2e # playwright (requires `pnpm exec playwright install` once)
pnpm verify:i18n # NL/EN translation key parity check
pnpm build # production build
pnpm format # prettier --write .A pre-commit lefthook hook runs format, lint, and readme-check. The readme-check command calls the Claude CLI — requires ANTHROPIC_API_KEY set in your shell. Don't bypass with --no-verify unless you're fixing the hook itself.
Copy .env.example to .env.local and fill in the keys you need. Everything except the contact-form and Stripe keys has a safe default when unset.
cp .env.example .env.local| Key | Scope | Purpose |
|---|---|---|
RESEND_API_KEY |
server | Auth for Resend API (40+ char re_... token). Never a placeholder. Separate key per env. |
CONTACT_FROM_EMAIL |
server | FROM address on outbound mail. Must be on a Resend-verified domain. Currently hello@agenticengineering.nl. |
CONTACT_EMAIL |
server | TO address (inbox that receives form submissions). Differs by env (see Preview section below). |
BLOGS_ENABLED |
server | Feature flag for blog entries on /articles. Set to 'true' to show blog entries and the all/blogs/articles filter bar. Unset/empty hides both. See below. |
STRIPE_SECRET_KEY |
server | Stripe secret API key for creating Checkout Sessions and verifying webhooks. |
STRIPE_PUBLISHABLE_KEY |
client-safe | Stripe publishable key (reserved for future Elements; redirect uses the Session URL). |
STRIPE_WEBHOOK_SECRET |
server | Signing secret for /api/stripe/webhook signature verification. |
The three contact-form keys are set in Production and Preview scopes (Development scope is intentionally empty — local dev uses .env.local). BLOGS_ENABLED is set per-scope as needed (see Feature flags below). Set via Vercel CLI or UI:
vercel env add RESEND_API_KEY production
vercel env add CONTACT_FROM_EMAIL production
vercel env add CONTACT_EMAIL production
vercel env add STRIPE_SECRET_KEY production
vercel env add STRIPE_PUBLISHABLE_KEY production
vercel env add STRIPE_WEBHOOK_SECRET production
vercel env add RESEND_API_KEY preview
vercel env add CONTACT_FROM_EMAIL preview
vercel env add CONTACT_EMAIL preview
vercel env add STRIPE_SECRET_KEY preview
vercel env add STRIPE_PUBLISHABLE_KEY preview
vercel env add STRIPE_WEBHOOK_SECRET previewOr paste real values in Vercel UI → Project → Settings → Environment Variables. After editing, redeploy (vercel --prod for production, or push a branch for preview) for new values to land in the function.
Local dev does not need these (contact form requires them at runtime). For local mail testing, create .env.local with the same keys.
BLOGS_ENABLED gates the blog feature on /articles. Implementation in lib/flags.ts.
| Value | Behavior |
|---|---|
'true' |
Blog entries (frontmatter type: blog) appear on /articles; filter bar (all/blogs/articles) renders. |
| unset / empty / anything else | Blog entries are filtered out; filter bar is hidden entirely. |
Production stays gated until launch. To preview the flag locally, set BLOGS_ENABLED=true in .env.local (or inline: BLOGS_ENABLED=true pnpm dev). Playwright e2e already sets it via playwright.config.ts so the filter-bar tests run.
When flipping the flag on in Vercel, set it for the relevant scope:
vercel env add BLOGS_ENABLED preview # paste: true
vercel env add BLOGS_ENABLED production # paste: true (when ready to launch)Browser → POST /api/contact → app/api/contact/route.ts
├─ Origin allowlist (CSRF) → 403 if mismatch
├─ Per-IP rate-limit → 429 if exceeded
├─ Honeypot `website` field → 200 silent-drop if set
├─ Zod contactSchema validate → 400 if invalid
└─ lib/email.ts → Resend.emails.send()
├─ EmailError → 502
└─ ok → 200
Allowed origins (regex in app/api/contact/route.ts):
https://agenticengineering.nlandhttps://www.agenticengineering.nlhttps://agenticengineering*.vercel.apphttp://localhost(:port)
To-field is never user-controlled. CRLF-stripped via lib/sanitize.ts to prevent header injection.
Browser → POST /api/checkout → app/api/checkout/route.ts
├─ Server-side pricing from data/trainings.ts via priceFor() (early-bird date-conditional, +21% VAT)
└─ lib/stripe.ts → Stripe Checkout Session
└─ redirect to Stripe-hosted checkout
Stripe → POST /api/stripe/webhook → app/api/stripe/webhook/route.ts
├─ Signature verification (STRIPE_WEBHOOK_SECRET)
├─ completed / async_payment_succeeded → only if payment_status === 'paid'
│ ├─ Resend confirmation email to customer ← critical
│ └─ Resend notification to operator ← best-effort, production-only
└─ async_payment_failed → logged, no fulfillment
The booking form (BookingForm.tsx) collects, besides attendees, an account type (zakelijk / persoonlijk, default zakelijk) and billing details. Address (street, postal code, city, country) and the free-text notes box (max 500 chars, the Stripe metadata value limit) are collected for every booking. Company name and KvK number (optional, 8 digits when filled) are collected only for business (zakelijk) accounts — the form hides those two fields when accountType === 'persoonlijk'. These are validated by bookingSchema (lib/validation.ts), passed through POST /api/checkout into the Stripe Checkout Session metadata, read back from metadata in the webhook, and included only in the operator notification email (sendBookingNotification) — the customer confirmation does not echo them.
Company name is conditionally required: bookingSchema uses .superRefine() to require it only when accountType === 'zakelijk' (it is unconstrained for personal bookings). Both accountType and company carry zod .default() values, so the schema's input and output types differ — the form uses BookingFormInput (z.input), the server uses BookingInput (z.infer).
Fulfillment happens on the webhook, never on the success redirect. The success page (/[locale]/trainings/pilot/book/success) is purely cosmetic — it cannot be trusted as proof of payment.
Promotion / referral codes. The booking form has an optional referralCode field (bookingSchema). When set, /api/checkout resolves it server-side via stripe.promotionCodes.list({ code, active: true }): an unknown/expired code returns 400 { error: 'invalid_referral' } (the form shows booking.errors.invalidReferral), a valid one is applied with discounts: [{ promotion_code }] and tagged on the session as metadata.referralCode (+ metadata.referrer copied from the promotion code's metadata). When no code is posted the session falls back to allow_promotion_codes: true so a customer can still enter one on Stripe's hosted checkout (Stripe forbids discounts + allow_promotion_codes together). Stripe enforces the coupon (e.g. 10% off) and max_redemptions. On fulfillment the webhook forwards referralCode/referrer into the operator notification email for the referrer-refund reconciliation. Per-referrer code generation (promotionCodes.create({ coupon, code, max_redemptions, metadata: { referrer } })) and the refund itself are operational (Stripe-side).
Delayed-notification methods (iDEAL, Bancontact, …). These fire checkout.session.completed before the money clears, then a checkout.session.async_payment_succeeded (or _failed) once it settles. The webhook fulfils on either event but only when session.payment_status === 'paid', so an unpaid completed is ignored and the async success event does the fulfilment — exactly one confirmation per booking. async_payment_failed is logged and not fulfilled. (Instant payments are already paid at completed, so they fulfil immediately.) See Stripe's delayed-notification fulfillment docs.
The two email sends use distinct error handling to prevent duplicate customer confirmations:
- Customer confirmation is critical: if it throws, the event id is un-marked and the webhook returns 500, allowing Stripe to retry. Because the confirmation never sent, the retry is safe (no duplicate).
- Internal notification is best-effort and production-only: it is gated on
VERCEL_ENV === 'production', so preview/dev submissions skip the operator email entirely. In production it is sent to the configuredCONTACT_EMAIL(TO) address; if it throws, the error is logged and the webhook still returns 200. The event id stays marked, so no Stripe retry occurs and the customer never receives a duplicate confirmation. A missed notification is acceptable because the booking is visible in the Stripe Dashboard.
Payment testing happens on one designated preview environment: the preview branch. All previews share one Stripe sandbox (sandboxes are Dashboard-only, no create API); only the designated branch has a working webhook:
- The sandbox holds one manually created webhook endpoint pointing at the
previewbranch's stable URL (/api/stripe/webhook); itswhsec_lives in Vercel asSTRIPE_WEBHOOK_SECRETon the Preview environment. Set up once in the Stripe + Vercel dashboards, then left alone. - The sandbox's
STRIPE_SECRET_KEYis on Vercel's Preview environment, so every PR preview has a working checkout redirect — but only bookings made on the designatedpreviewbranch get webhook fulfillment (confirmation email). That's the payment-test environment. - Because previews sit behind Vercel Deployment Protection, external callers (Stripe) get a 401 unless the registered webhook URL carries the bypass token (
?x-vercel-protection-bypass=…, from Vercel → Settings → Deployment Protection → Protection Bypass for Automation) — unless the designated domain is excluded from protection.
An earlier iteration auto-provisioned a webhook endpoint + branch-scoped secret per PR branch via a stripe-preview.yml workflow; it was removed in favour of this simpler designated-environment setup (the automation had non-atomic provisioning and cleanup edge cases — see PR #74).
Set in next.config.ts. Apply only in production (dev keeps relaxed for local tooling).
Strict-Transport-Security: max-age=63072000; includeSubDomains; preloadX-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=()Content-Security-Policy:default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.resend.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
brew install vercel-cli
vercel login
vercel link # links cwd to existing Vercel projectvercel --prod # production deploy + alias to apexVercel GitHub App is connected, so pushes to main auto-deploy to production and pushes to any other branch auto-deploy to a preview URL. Manual vercel --prod still works for out-of-band hot deploys.
A GitHub Actions workflow (.github/workflows/discord-pr.yml) posts PR notifications to a Discord channel when PRs open or merge, plus a manual workflow_dispatch (with a pr_number input) to publish any PR on demand. Set the DISCORD_WEBHOOK secret in GitHub repo settings to enable it; the workflow skips silently when the secret is unset.
Mail and web records coexist on the same zone.
Web (Vercel):
| Name | Type | Value |
|---|---|---|
@ |
A | 76.76.21.21 |
www |
CNAME | cname.vercel-dns.com. (note trailing dot) |
Mail (Resend — for hello@agenticengineering.nl sending):
| Name | Type | Value |
|---|---|---|
resend._domainkey |
TXT | p=… (DKIM, copy from Resend dashboard) |
send |
MX | feedback-smtp.eu-west-1.amazonses.com. (trailing dot, priority 10) |
send |
TXT | v=spf1 include:amazonses.com ~all |
_dmarc |
TXT | v=DMARC1; p=none; |
Mail (TransIP — apex inbox, unrelated to outbound sending): existing MX mx.transip.email., SPF _spf.transip.email, DKIM CNAMEs transip-{a,b,c}._domainkey. Untouched by Vercel migration.
TransIP gotcha: any record value that should resolve as an absolute FQDN must end with a . (MX, CNAME). Otherwise TransIP auto-appends the zone apex and breaks resolution.
curl -sI https://agenticengineering.nl/nl
# Expect: HTTP/2 200, all 6 security headers present.
curl -s -X POST https://agenticengineering.nl/api/contact \
-H 'Content-Type: application/json' \
-H 'Origin: https://agenticengineering.nl' \
-d '{"name":"t","email":"t@example.com","trainingInterest":"basic","deliveryPref":"noPreference","message":"smoke test 0123456789","website":""}'
# Expect: {"ok":true}Every non-main branch push gets a Vercel preview deployment at https://agenticengineering-<hash>-<scope>.vercel.app. Previews exist to smoke-test code before promotion to production without touching the live inbox.
Isolation from production:
| Concern | Production | Preview |
|---|---|---|
| Resend API key | full-access key (prod-only) | separate domain-scoped key (revocable independently) |
| FROM address | hello@agenticengineering.nl |
hello@agenticengineering.nl (same verified sender) |
| TO address | hello@agenticengineering.nl |
hello+preview@agenticengineering.nl (plus-addressing — same inbox, filterable label) |
| URL | agenticengineering.nl (+ www alias) |
agenticengineering-<hash>-<scope>.vercel.app |
| Access | public | SSO wall (see below) |
The plus-addressing trick (hello+preview@…) routes preview submissions to the same mailbox as production but with a +preview label, so a Gmail/IMAP filter can sort them automatically without provisioning a second mailbox. The + part is stripped by the SMTP server during delivery — hello+anything@… always lands at hello@….
SSO wall (Hobby tier limitation):
Vercel Deployment Protection is forced on for previews on the Hobby plan. Public access (no Vercel login) requires upgrading to a paid tier. Three ways to access previews under the current plan:
-
Browser, logged in: open the preview URL, get redirected to Vercel SSO, sign in with the project-owner account → preview loads. Works for the project owner; not for external testers.
-
CLI, authenticated:
vercel curl <preview-url>/pathauto-injects a bypass token from your local Vercel auth. Use this for smoke tests:vercel curl https://agenticengineering-<hash>-<scope>.vercel.app/nl -I # Expect: HTTP/2 200 vercel curl https://agenticengineering-<hash>-<scope>.vercel.app/api/contact \ -X POST -H 'Content-Type: application/json' \ -H 'Origin: https://agenticengineering-<hash>-<scope>.vercel.app' \ -d '{"name":"preview probe","email":"t@example.com","trainingInterest":"basic","deliveryPref":"noPreference","message":"preview smoke 0123456789","website":""}' # Expect: {"ok":true}; mail lands at hello+preview@agenticengineering.nl
-
Share with an external tester: generate a one-off bypass URL — Vercel UI → Project → Settings → Deployment Protection → "Protection Bypass for Automation" → generate token, then share
https://<preview-url>/path?x-vercel-set-bypass-cookie=true&x-vercel-protection-bypass=<token>. Sets a cookie so subsequent navigation within the preview works.
Rotating the preview Resend key:
The preview key has Resend "Sending access" scoped to agenticengineering.nl only. To rotate: Resend dashboard → API Keys → revoke old → create new → vercel env rm RESEND_API_KEY preview && vercel env add RESEND_API_KEY preview (paste new value at the prompt — never via --value on a shared terminal) → trigger a new preview deploy. Production is unaffected.
Cleanup:
Preview deployments are automatically deleted when their PR closes (via .github/workflows/preview-teardown.yml).
Translation messages live in messages/{nl,en}.json. Locale routing in i18n/routing.ts. NL is the default locale (no prefix-less root — / redirects to /nl).
CI runs pnpm verify:i18n to enforce key parity between NL and EN. Add a new key → add it to both files.
Namespaces in use: meta, nav, hero, trainings, modules, proof, footer, about, articles, contact, booking, impressum, theme, home, why. The booking namespace covers the booking form: seat selector and attendees (seatsLabel, attendeeName, attendeeEmail), account-type radio options (accountBusiness, accountPersonal), company billing details (companyHeading, company, kvk, street, zipCode, city, country, notes), referral-code (referralLabel, referralHint), submit/contact (submit, submitting, contactLink), sold-out copy (soldOutHeading, soldOutBody, soldOutBack), errors.* (required, invalidEmail, invalidKvk, generic, rateLimited, invalidReferral), and success.*.
- Unit (
tests/**/*.test.ts): Vitest, jsdom env for component tests.pnpm test. - E2E (
tests/e2e/): Playwright, hits dev server.pnpm test:e2e. - A11y: axe-core integrated into Playwright tests. Zero WCAG 2.1 AA violations enforced.
CI workflow: .github/workflows/ci.yml runs typecheck + lint + unit + i18n integrity gate on every push.
PRODUCT.md— who the site is for, tone of voice, anti-references, strategic principles.DESIGN.md— Stitch-format design system: colors (OKLCH dark palette), typography (JetBrains Mono display, Inter body), components, do's/don'ts.
These two files inform every UI decision. Read them before touching components.
CLAUDE.md— agent-facing operational shortlist (commands, layout, conventions, pre-commit hook, deployment). Complements this README rather than duplicating it.
Contributions are welcome. See CONTRIBUTING.md for local setup, the test/lint gates your change must pass, and PR conventions. By participating you agree to the Code of Conduct.
Please do not open public issues for security vulnerabilities. See SECURITY.md for how to report them privately.
Released under the MIT License. © QualityAtSpeed.
