Technical Design Document: WordPress Decommission & Blog + Form Migration¶
| Project | WordPress Decommission & Blog + Form Migration |
| Target repo | appcircle-website |
| Decommission | WordPress host ac.appcircle.io |
| Team | Frontend (FE) |
| Lead | gina@appcircle.io |
| Initiative | 2026 Roadmap |
| Status | Implemented |
| Date | 2026-06-07 (updated 2026-06-26) |
TL;DR
The site (
appcircle-website) depends on a single WordPress host —ac.appcircle.io— through exactly two surfaces: WPGraphQL (all blog content) and Gravity Forms REST (all lead forms). This TDD proposes (1) migrating the blog to repo-hosted content and images, served statically by Next.js with a PR-based authoring flow, and (2) replacing Gravity Forms with in-house Next.js API routes that send email over the existing SMTP infrastructure and persist submissions to Postgres. Once both ship and are verified, the WordPress hostac.appcircle.iocan be shut down.
1. Background & motivation¶
The current Appcircle website is backed by a WordPress stack that has been compromised. Keeping it running is an ongoing security and maintenance liability. The goal is to fully decommission the WordPress infrastructure and serve the website, blog, and form handling entirely from the modern appcircle-website (Next.js 15, App Router).
The existing UI must be preserved exactly so the migration creates no additional design work. Shutting down the WordPress infrastructure is a follow-up step that happens only after blog and forms are fully migrated and verified on v2.
2. Goals & non-goals¶
Goals¶
- Remove every runtime dependency on
ac.appcircle.io(WPGraphQL + Gravity Forms REST). - Preserve current UI, URLs, and SEO characteristics (canonical/OpenGraph/Twitter metadata, sitemap, redirects).
- Blog: a PR-reviewed authoring flow with full content history in Git — explicitly called out as a core goal of the project.
- Forms: reproduce notification + confirmation emails and persist submissions to a database.
Non-goals¶
- Redesign or UX changes to any page.
- Migrating the newsletter — it already runs on MailBluster (app/api/mail/route.ts), independent of WordPress.
- Re-hosting whitepaper PDFs — already served from
cdn.appcircle.io. - Building a full CMS admin UI (the authoring flow is Git/PR-based by design).
3. Current state — what depends on WordPress¶
One host, two surfaces, zero environment variables — every endpoint and credential is hardcoded in source.
| Surface | Endpoint | Feeds | Configured in |
|---|---|---|---|
| WPGraphQL | https://ac.appcircle.io/graphql/ | All blog content | app/lib/fetchAPI.ts:1 (hardcoded) |
| Gravity Forms REST | …/wp-json/gf/v2/forms/{id}/submissions | All 7 lead forms | 6 page files + 1 API route (hardcoded) |
3.1 Blog (WPGraphQL)¶
All blog data flows through one transport, app/lib/fetchAPI.ts (POSTs GraphQL, next: { revalidate: 600 } → 10-min ISR cache). Query helpers:
- app/lib/getPosts.ts — listing + sitemap (paginated, excludes category id
330+uncategorized). - app/lib/getSinglePost.ts — single post incl. raw HTML
content. - app/lib/getCategoryPosts.ts — category pages.
- app/lib/getSearch.ts — pulls the whole corpus; search is a client-side substring filter.
The blog depends on three WordPress plugins, not just core:
| Plugin / feature | What the code consumes | Migration impact |
|---|---|---|
| WPGraphQL (core) | posts, post(idType:SLUG), category with edges/node + cursor pageInfo | The { node: … } envelope is baked into every blog component's types. |
| WPGraphQL for Yoast SEO | seo { title, metaDesc, canonical, opengraph, twitter, readingTime } | All page metadata (generateMetadata) comes from Yoast. Must be reproduced or SEO regresses. |
| ACF + WPGraphQL | extraPostInfo (CTA, banners, square image, specialTag), extraAuthorInfo, extraCategoryInfo | Non-standard fields; UI degrades without them (sidebar image, summaries, CTAs). |
HTML rendering pipeline (app/blog/[slug]/components/BlogContent.tsx): raw WP HTML is parsed client-side with DOMParser, transformed (TOC anchors on <h2>, table wrapping, CTA conversion), sanitized with DOMPurify, then injected via dangerouslySetInnerHTML. The markup carries WordPress/Gutenberg classes (aligncenter, dynamic_cta) that the page CSS targets.
Images: all post/author/category media is served from ac.appcircle.io (allow-listed in next.config.ts images.remotePatterns). Sitemap: app/sitemap.ts calls getPosts() to enumerate /blog/{slug}.
3.2 Forms (Gravity Forms REST)¶
The codebase contains 7 Gravity Forms forms (see §4 for what migrates). Six submit directly from the browser; only Partner registration goes through an internal API route. The Public URL column shows where each form is reachable on https://appcircle.io.
| # | Form | Public URL | Route / component | GF id | Submit path | Dep. | Notes |
|---|---|---|---|---|---|---|---|
| 1 | Contact us | /contact | app/contact/page.tsx | 2 | Browser → WP | WordPress | — |
| 2 | Book a demo | /book-demo | app/book-demo/page.tsx | 12 | Browser → WP | WordPress | — |
| 3 | Partner registration | /partners | app/partners/page.tsx → app/api/partner-registration | 10 | API route → WP | WordPress | — |
| 4 | Whitepaper download | /whitepapers/enhancing-mobile-ci-cd-security | app/whitepapers/enhancing-mobile-ci-cd-security/page.tsx | 13 | Browser → WP | WordPress | — |
| 5 | CI/CD Maturity Assessment | /mobile-ci-cd-maturity-assessment | app/mobile-ci-cd-maturity-assessment/page.tsx | 14 | Browser → WP | WordPress | — |
| 6 | Webinar — Future of Releases | /events/the-future-of-mobile-app-releases | app/events/the-future-of-mobile-app-releases/page.tsx | 9 | Browser → WP | WordPress | Migrating — "Watch Now" link 404'd under WP; corrected URL in webinarEvents config; extra.event stores stable key |
| 7 | Webinar — Monthly Demo | /events/monthly-product-demo-sept-24 | app/events/monthly-product-demo-sept-24/page.tsx | 11 | Browser → WP | WordPress | Migrating — "Watch Now" link 404'd under WP; corrected URL in webinarEvents config; extra.event stores stable key |
| — | Newsletter | global (footer, all pages) | app/components/Newsletter.tsx → app/api/mail | — | API route → MailBluster | Not WP | — |
Also present: app/jointheteam form is commented out (now links to external careers site); app/api/webinarRegistration/route.ts is dead code (unreferenced). No captcha anywhere. Error handling parses Gravity-Forms-specific validation_messages shapes.
3.3 Config-level & dead dependencies¶
- next.config.ts — image
remotePatterns+ CSPconnect-srcboth allow-listac.appcircle.io. Remove after migration. - redirects.json — legacy
/blog/…slug redirects (app-level, safe to keep). - Dead deps with zero source references:
algoliasearch,react-instantsearch,xml2js. Safe to drop now.
4. Migration scope¶
Decided
Seven forms migrate onto the new in-house backend: Contact, Whitepaper, Partnership, CI/CD Maturity Assessment, Book a demo, and both Webinar forms. The webinar forms are migrated with corrected access link URLs — the "Watch Now" links 404'd under WordPress; working links are configured in
webinarEvents(env.ts) and resolved at send time. The Newsletter stays as-is (already on MailBluster, not WordPress).
5. Workstream 1 — Blog migration¶
Target: serve all blog content from appcircle-website with no WordPress dependency, preserving the UI, and enabling a PR-reviewed authoring flow with Git history.
5.1 Content storage & format¶
Decided · storage location
Both blog content and images live in the repo (committed to Git) and are statically generated by Next.js. This gives PR review + full content history — a core goal — for text and media alike, with no external media host to maintain.
Note: this model is already proven on the live site. Existing images (e.g. /temenos-exchange.webp) sit in public/ and are served as static assets at the edge. Blog images committed to the repo will be delivered exactly the same way — no separate image CDN required. (The site now runs on Cloudflare Workers, where prerendered pages and static assets are served from the Workers Static Assets cache — see §15.)
Decided · content format
Raw HTML per post + a metadata sidecar (front-matter/JSON). This is the highest-fidelity option — the existing
BlogContentrender/sanitize pipeline already handles WordPress HTML, so the UI is preserved exactly with minimal conversion risk. MDX/Markdown remains a possible future authoring improvement, out of scope for this migration.
5.2 One-time export from WordPress¶
A throwaway script (run once, before WP shutdown) walks WPGraphQL and writes, per post: the HTML content, all metadata currently consumed (title, slug, date/modified, excerpt, author + extraAuthorInfo, categories, featuredImage, Yoast seo, and extraPostInfo), and a manifest. Images are downloaded and committed into the repo; in-content image URLs are rewritten from ac.appcircle.io → local repo paths.
- Reuse the existing GraphQL query shapes in app/lib/* as the export contract — they already enumerate every field the UI needs.
- Preserve category id
330exclusion andfeatures/uncategorizedfiltering rules, or fold them into the export so the runtime no longer needs them.
5.3 Runtime & rendering¶
- Replace app/lib/fetchAPI.ts + the four query helpers with a local content loader (read files at build time). Keep the same return types so blog components/pages are largely untouched.
- Switch blog routes to
generateStaticParams+ static generation (no ISR, no live fetch). - Keep BlogContent.tsx DOMPurify pipeline for HTML posts.
- Search: keep the current naive client-side substring filter exactly as-is — only its data source changes from WordPress (
getSearch()) to the local content. No new search index, no Algolia. - SEO: map exported Yoast
seofields into NextgenerateMetadata(title, canonical, OG, Twitter). This is the highest-risk fidelity area. - Sitemap: repoint app/sitemap.ts to read local content instead of
getPosts().
5.4 Authoring flow (core goal)¶
New post = a branch + PR adding the content file(s) under the blog content directory. Review happens in the PR; merge triggers a deploy; history is the Git log. No WordPress admin, no live CMS. Document the contributor steps in the repo README/CONTRIBUTING.
6. Workstream 2 — Forms migration¶
Replace Gravity Forms with first-party handling inside v2. All forms post to a single internal endpoint — app/api/forms (server-side) — sending a form_type plus the field values. That one route looks up the form's config (§7), validates, persists to Postgres, and sends email — no browser-exposed secrets, no WordPress.
Decided · in-house vs third-party
In-house — a single Next.js API route (
app/api/forms) handles every form, with full control over templates, validation, and storage and no re-introduced external form dependency. The requirements (notification + confirmation emails, DB storage) are straightforward to build first-party.
6.1 Proposed architecture¶
Browser form ──POST { form_type, ...fields }──▶ /api/forms (one route)
│ look up config for form_type (env.ts)
├─▶ validate + honeypot spam check
├─▶ persist → Postgres form_submissions
├─▶ notification email → recipients (per form_type)
└─▶ confirmation email → submitter (if enabled)
Decided · email transport
Use the existing email infrastructure (SMTP) the team already operates, rather than introducing a new provider. Email templates are rendered in-app and sent over that existing SMTP transport.
Decided · submission storage
Postgres — one
form_submissionstable for all forms. Aform_typecolumn tags each row; the shared lead fields (name, email, phone, company, job title, country, message) are typed columns, and any form-specific fields go in anextra jsonbcolumn. Adding a new form type needs no schema migration and no backend change — it just writes to the same table (common fields fill the columns, the rest lands inextra). Provisioning and credentials to be coordinated with the platform team.
form_submissions (
id bigserial primary key,
form_type text not null, -- 'contact-us', 'book-demo', 'partners', 'whitepaper', 'maturity-assessment', 'webinar'
first_name text,
last_name text,
email text not null,
phone text,
company text,
job_title text,
country text,
message text,
extra jsonb not null default '{}', -- form-specific: timezone, ci_solution, deploy_model, platform, improvements;
-- webinar: { "event": "webinar-future" } <- stable key; watch URL lives in webinarEvents config, not DB
created_at timestamptz not null default now()
)
6.2 Config-driven sending¶
The route reads everything — whether to email, who to email, and which template — from the forms[form_type] entry in env.ts (§7). No recipient or template is hardcoded in the route:
// app/api/forms/route.ts (conceptual)
import { forms, type FormType } from "@/env";
export async function POST(req) {
const { form_type, ...fields } = await req.json();
const cfg = forms[form_type as FormType]; // ← config lookup
if (!cfg) return 400;
await db.insert("form_submissions", { form_type, ...fields }); // always persist
if (cfg.admin.inform) // internal notification
await sendMail({ to: cfg.admin.emails, template: cfg.admin.template, data: fields });
if (cfg.customer.inform) // confirmation to submitter
await sendMail({ to: fields.email, template: cfg.customer.template, data: fields });
return 200;
}
| Incoming form_type | What the config drives |
|---|---|
| contact-us | admin.inform ✓ → email osman@appcircle.io with admin/contact-us.html; customer.inform ✓ → email the submitter with customer/contact-us.html |
| whitepaper | admin.inform ✗ → no internal email; customer.inform ✓ → submitter gets customer/whitepaper.html (the download email) |
| webinar | admin.inform ✗ → no internal email; customer.inform ✓ → submitter gets customer/webinar.html; "Watch Now" URL resolved from webinarEvents[extra.event].watchUrl — not stored in DB |
Separation: who / whether / which template come from env.ts (non-secret policy); how it's sent (SMTP) and the DB connection come from .env (secrets). Adding a new form type = one env.ts entry + its templates, with no change to the route.
6.3 Per-form requirements¶
| Form | On submit — must reproduce |
|---|---|
| Contact us | ① Notification email to Gizem and Osman Çelik. ② Confirmation ("we received your request") email to the submitter. ③ Persist submission to DB. |
| Whitepaper | PDF already on cdn.appcircle.io (no change). Migrate only the user-facing email + its template, currently defined inside the WP form plugin. |
| Partnership | Reproduce current notification/confirmation behavior; already routed through an API route, so least risky to port. |
| CI/CD Maturity Assessment | Reproduce current behavior; extra fields (CI solution, deployment model, platform, improvements) and a free-email-domain block — now enforced server-side via blockFreeEmail + blockedEmailDomains in env.ts. |
| Book a demo | Same fields as Contact. Same pattern: notification email + confirmation email to the submitter + persist to DB. |
| Webinar | Persist with form_type = 'webinar'; extra.event stores a stable key (e.g. "webinar-future") — not the URL path. The "Watch Now" URL is resolved at send time from webinarEvents[extra.event].watchUrl in env.ts. If the URL ever changes, only the config entry needs updating — existing DB rows remain valid. No internal notification required. |
6.4 Cross-cutting¶
- Email templates: the WP/Gravity Forms email bodies must be exported and re-templated in code (HTML email templates checked into the repo).
- Secrets: email/DB credentials live in environment variables (the project currently defines none).
- Dependencies to add:
pg(Postgres driver — connects via Cloudflare Hyperdrive in production, see §15.3) andworker-mailer(SMTP overcloudflare:sockets, Workers runtime — replaces the originally plannednodemailer, which can't run on Workers; see §15.4). No validation library (e.g. zod) — env config is plain typed TS. - Spam: none today. Recommendation: add a honeypot field since endpoints become first-party.
- Field mapping: drop Gravity Forms field ids (
input_1_3,input_19…) in favor of named fields; rewrite the GF-specific client error parsing. - Analytics: preserve the existing GTM
form_submissionevents (app/lib/gtm.ts). - Cleanup: delete dead app/api/webinarRegistration/route.ts.
- Email injection: all
fieldsvalues must be validated and sanitized before being passed tosendMail. Email headers (To,From,Subject) must be constructed server-side from trustedcfgvalues — never interpolated directly from user input. HTML email templates must escape all dynamic content using a safe templating engine (e.g. Handlebars{{value}}auto-escaping) or explicit escaping functions; string concatenation is not acceptable. Implementations must reject or sanitize inputs containing CR/LF sequences. - Parameterized queries: the
pgdriver must use parameterized queries exclusively — e.g.client.query('INSERT INTO form_submissions(...) VALUES ($1, $2, ...)', [values]). SQL strings must never be constructed via string interpolation or concatenation with user-supplied input.
7. Central configuration¶
Today the repo has no environment variables and no central config — every endpoint, token, and key is hardcoded (§3). This migration introduces the first real config + secret handling. There is one config module — env.ts at the repo root — and the whole app imports from it. No scattered app/config/* files; framework config stays in next.config.ts.
| Concern | Home | Examples |
|---|---|---|
| Framework config | next.config.ts | images, redirects, CSP/headers — unchanged |
| App config (+ secret access) | env.ts | form types & flags, site constants; lazy getters for SMTP/DB/API keys |
| Deploy/runtime env (Cloudflare) | wrangler.jsonc | per-env vars (SMTP_HOST/PORT/FROM, committed) + binding decls; secrets set via wrangler secret put (see §15.6) |
7.1 Proposed layout¶
/.env # local-dev secrets + environment-specific values (gitignored)
/.env.example # template documenting every required key (committed)
/env.ts # the single config module the app imports from
/wrangler.jsonc # Cloudflare Workers config: per-env vars (committed) + bindings;
# production secrets set via `wrangler secret put` (see §15.6)
env.ts — single source of truth
Following the same pattern used in arc:
env.tssits at the repo root next to.env. It defines the non-secret config inline (form types & flags, constants), reads secret/environment values fromprocess.env(populated bywranglervars + secrets on Cloudflare, or.envfor local dev — see §15.6), applies light inline checks so a missing required key fails fast at request time, and exports a single typed config object (no validation dependency — same as arc). Everything is imported fromenv.ts— there is no separateapp/config/forms.ts, and components / route handlers never readprocess.envdirectly.
7.2 Where form types & flags live¶
Form types and their flags are part of the non-secret config defined inside env.ts — not a separate app/config file, and not next.config.ts (which app code cannot import). They sit in a FormType union and a forms registry where each form has an admin and a customer block — each with inform (send or not), recipient emails (admin only), and a template path — plus an optional per-form blockFreeEmail flag (input rule, enforced against the shared blockedEmailDomains list). All are exported through the same unified config object as the secrets (merged in later). This is the concrete mechanism behind the storage decision's "adding a new form type needs no backend change": a new form is one entry in the config.
Concretely, env.ts looks like this:
// env.ts
export type FormType =
| "contact-us" | "book-demo" | "partners" | "whitepaper" | "maturity-assessment" | "webinar";
interface EmailConfig {
inform: boolean; // send this email?
emails?: string[]; // recipients — admin only (the customer email goes to the submitter)
template: string; // path under app/email-templates/
}
interface FormConfig {
admin: EmailConfig; // internal notification
customer: EmailConfig; // confirmation to the submitter
blockFreeEmail?: boolean; // reject free / personal email domains
}
// shared list, enforced when a form sets blockFreeEmail: true
export const blockedEmailDomains = ["gmail.com", "yahoo.com", "hotmail.com", /* … */];
export const forms: Record<FormType, FormConfig> = {
"contact-us": {
admin: { inform: true, emails: ["osman@appcircle.io"], template: "email-templates/admin/contact-us.html" },
customer: { inform: true, template: "email-templates/customer/contact-us.html" },
},
"whitepaper": {
admin: { inform: false, emails: [], template: "" },
customer: { inform: true, template: "email-templates/customer/whitepaper.html" },
},
"maturity-assessment": {
admin: { inform: true, emails: ["osman@appcircle.io"], template: "email-templates/admin/maturity-assessment.html" },
customer: { inform: true, template: "email-templates/customer/maturity-assessment.html" },
blockFreeEmail: true,
},
"webinar": {
admin: { inform: false, emails: [], template: "" },
customer: { inform: true, template: "email-templates/customer/webinar.html" },
},
// … book-demo, partners
};
// Webinar event map — keyed by page slug.
// key: stable identifier stored in DB (extra.event).
// watchUrl: current "Watch Now" link — lives only in config, never in DB.
// To fix or update a URL, change watchUrl here; existing DB rows stay valid.
export const webinarEvents: Record<string, { key: string; watchUrl: string }> = {
"the-future-of-mobile-app-releases": {
key: "webinar-future",
watchUrl: "https://appcircle.io/events/the-future-of-mobile-app-releases/663babad0637d5c5a47cd2c8",
},
"monthly-product-demo-sept-24": {
key: "webinar-monthly-demo",
watchUrl: "https://appcircle.io/events/monthly-product-demo-sept-24/...",
},
};
// Secrets are read lazily from process.env via getters (getSmtpConfig,
// getDatabaseUrl, getMailBlusterApiKey) so the build succeeds without them and
// process.env is populated at request time on Workers. On Cloudflare these come
// from wrangler vars (SMTP_HOST/PORT/FROM) + wrangler secrets (SMTP_USER/PASS,
// the Hyperdrive/Postgres connection, MailBluster key); locally from .env. See §15.6.
Secret vs. non-secret: both are reached through env.ts, but they originate differently — non-secret, environment-independent values (form flags, recipient defaults, constants) are written directly in env.ts; anything secret or environment-specific (SMTP credentials, the Postgres/Hyperdrive connection, MailBluster key) comes from process.env, provided by wrangler vars + secrets on Cloudflare or .env for local dev (see §15.6).
8. Workstream 3 — Config & cleanup¶
- Remove
ac.appcircle.iofrom next.config.ts imageremotePatternsand CSPconnect-src. Blog images are served locally from the repo, so no external image host is required. - Drop unused deps:
algoliasearch,react-instantsearch,xml2js. - Consolidate the duplicated WP/Yoast/ACF TypeScript types into the new local content types.
- Decommission: shut down
ac.appcircle.io— only after blog + forms are verified on v2.
9. Phased migration plan¶
| Phase | Work | Risk | Blocks decommission? |
|---|---|---|---|
| 0 — Prep low | Drop dead deps. Delete dead webinar API route. Provision Postgres + confirm SMTP access. | None | No (prep) |
| 1 — Forms medium | Build the single /api/forms route, email templates + SMTP, DB table, honeypot spam protection. Repoint all in-scope forms. Move secrets to env. | Form correctness | Yes |
| 2 — Blog export medium | Run one-time WPGraphQL export; download images and commit to repo (served as static assets from the Cloudflare Workers edge cache, see §15.1); commit content to repo. | Content fidelity | Yes |
| 3 — Blog runtime high | Replace fetch layer with local loader; static generation; Yoast→metadata mapping; repoint search to local content; sitemap repoint. | SEO regression, broken images/search | Yes |
| 4 — Cutover & decommission low | Strip ac.appcircle.io from config; verify the new site on Cloudflare; cut over by switching DNS to the new Worker; then shut down the WordPress host. | Low (post-verification) | Final step |
Phases 1 and 2 are independent and can run in parallel. Phase 4 gates on verification of 1–3. Because appcircle-website is a separate new service, the existing WordPress-backed site keeps serving traffic unchanged until the new Cloudflare site passes testing — at which point cutover is a single DNS change to the new Worker, followed by decommissioning WordPress.
10. Risks & mitigations¶
| Risk | Impact | Mitigation |
|---|---|---|
| SEO regression (metadata from Yoast) | High — traffic loss | Faithfully map every Yoast field into generateMetadata; diff rendered before/after on a sample of URLs; keep redirects.json. |
| Blog HTML fidelity drift | Medium | Keep raw HTML + existing DOMPurify pipeline (Phase 2/3); visual-diff a sample of posts. |
| Images still pointing at ac.appcircle.io after shutdown | High — broken images | Commit all media into the repo in Phase 2 and rewrite in-content URLs to local paths; grep for any remaining ac.appcircle.io reference before decommission. |
11. Decisions (resolved)¶
| Decision | Outcome |
|---|---|
| Form migration scope | Migrate all 7 forms (Contact, Whitepaper, Partnership, Maturity, Book a demo, and both Webinar forms); webinar forms delivered with corrected "Watch Now" links via confirmation email |
| Webinar form type | form_type = 'webinar'; extra.event stores a stable key (e.g. "webinar-future") — not the URL path; watch URL resolved at send time from webinarEvents[key].watchUrl in env.ts; URL changes need no DB migration; no internal notification |
| Webinar event map | webinarEvents in env.ts maps page slug → { key, watchUrl }; key is what the DB stores; watchUrl is the current live link — updateable in config without touching the DB |
| Cutover mechanism | DNS switch after testing. appcircle-website is a separate new service; the existing site stays live until the new Cloudflare site is verified, then DNS is repointed to the new Worker and WordPress is decommissioned (§9, §15.2) |
| Blog content storage | Content and images both in the repo (Git) |
| Blog content format | Raw HTML + metadata sidecar |
| Forms backend | In-house — a single /api/forms route handles every form, config-driven by form_type |
| Email transport | Existing SMTP infrastructure (AWS SES, eu-west-1), sent via worker-mailer over cloudflare:sockets — nodemailer can't run on Workers (§15.4) |
| Submission database | Postgres — one form_submissions table: shared fields as columns + extra jsonb; new form type needs no migration |
| DB access layer | pg (node-postgres) — raw driver; no ORM for a single table; connects via Cloudflare Hyperdrive in production, direct SSL fallback locally (§15.3) |
| Central config | One env.ts at the repo root is the only config module the app imports from; non-secret config inline + secrets read lazily from process.env (no validation dependency, same as arc). On Cloudflare those come from wrangler.jsonc vars + wrangler secrets per environment (§15.6). No app/config/*; framework config stays in next.config.ts |
| Form config shape | forms registry keyed by form_type; per form an admin + customer block (inform / emails / template) + optional blockFreeEmail; shared blockedEmailDomains list |
| Form type identifiers | contact-us, book-demo, partners, whitepaper, maturity-assessment, webinar — same strings stored in the DB form_type column |
| Email templates | HTML files committed under app/email-templates/admin/ and app/email-templates/customer/ |
12. Appendix — key files to touch¶
Blog¶
- app/lib/fetchAPI.ts — replace transport
- app/lib/getPosts.ts · getSinglePost.ts · getCategoryPosts.ts · getSearch.ts — replace with local loader
- app/blog/page.tsx · app/blog/[slug]/page.tsx · app/blog/category/[slug]/page.tsx — static params + Yoast→metadata
- app/blog/[slug]/components/BlogContent.tsx — keep render pipeline
- app/sitemap.ts — read local content
- app/components/SearchModal*.tsx — repoint to local content (keep naive filter)
Forms¶
- New: app/api/forms (single route) + app/email-templates + app/lib/db.ts (DB client) + app/lib/mailer.ts (worker-mailer)
- app/contact/page.tsx · app/book-demo/page.tsx · app/partners/page.tsx — repoint to
/api/forms - app/whitepapers/enhancing-mobile-ci-cd-security/page.tsx
- app/mobile-ci-cd-maturity-assessment/page.tsx
- app/events/the-future-of-mobile-app-releases/page.tsx · app/events/monthly-product-demo-sept-24/page.tsx — repoint to
/api/formswithform_type = 'webinar'; passextra.event = webinarEvents[slug].key - Delete: app/api/webinarRegistration/route.ts (dead) · app/api/partner-registration/route.ts (folded into
/api/forms)
Config¶
- New: env.ts + .env / .env.example — single central config module (§7)
- New: wrangler.jsonc (per-env vars, Hyperdrive,
FORMS_RATE_LIMITER) · open-next.config.ts (static-assets cache) · scripts/generate-blog-data.mjs (build-time content + email-template bundling) — Cloudflare platform (§15) - next.config.ts — image domains + CSP +
serverExternalPackages: ["worker-mailer"] - package.json — drop algoliasearch / react-instantsearch / xml2js
13. Open questions¶
Decided
Webinar pages are migrated, not removed. Both /events/the-future-of-mobile-app-releases and /events/monthly-product-demo-sept-24 are migrated to
/api/formswithform_type = 'webinar'. The "Watch Now" links are corrected — working URLs are configured inwebinarEvents(env.ts); only a stable key is persisted in the DB.Needs a decision
Do we migrate existing form submission data into the new Postgres database? "Contact us" and the other forms have historical submissions stored on the WordPress / Gravity Forms side. Either we export and import them into the new
form_submissionstable, or we start fresh and capture only submissions going forward.Decided — implemented
Spam protection: honeypot vs. Turnstile. We chose a honeypot field — zero user friction, no third-party dependency. Implemented as a hidden
customer_titlefield: a submission that fills it returns 200 without persisting or emailing. A stronger option (Cloudflare Turnstile or similar CAPTCHA) can be added later if spam on the new first-party endpoint becomes a real problem; it remains out of scope for now.Decided — implemented
Rate limiting for
/api/forms. Resolved at the platform layer with a CloudflareFORMS_RATE_LIMITERbinding (declared per environment inwrangler.jsonc): 2 submissions per client IP per 60s, keyed oncf-connecting-ip, returning 429 when exceeded. The check is guarded/try-caught so localnext dev(no binding) simply skips it. See §15.5.
14. External prerequisites & handoffs¶
None of these block writing the forms code (which can be built against a local Postgres + a dev SMTP catcher), but all are required before the forms go live in production.
14.1 Infrastructure (owner: platform team)¶
Two things are enough to ship:
| Item | What we need |
|---|---|
| SMTP | Host, port, username, password, and the "from" address of the existing email infrastructure. |
| Postgres | A database instance reachable from Cloudflare. In production the Worker connects through a per-account Hyperdrive config (Hyperdrive's egress range 172.64.0.0/13 must be allowlisted on Cloud SQL); for local dev getDatabaseUrl() reads a single connection string (CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE). The single form_submissions table (§6.1) is created there. See §15.3. |
14.2 Export from WordPress — before shutdown (owner: gizem)¶
Two things must be pulled out of WordPress and committed to the repo before the WP host is shut down; each is a discrete task:
- Email content — the form notification + confirmation bodies, and the whitepaper download email, currently defined inside the Gravity Forms plugin. Commit as HTML templates under
app/email-templates/admin/andapp/email-templates/customer/. - Blog content — all post HTML + images (the one-time WPGraphQL export described in §5.2), committed to the repo.
15. Cloudflare migration (platform — post-TDD addendum)¶
Context
This section is a post-implementation addendum, added after the original TDD shipped. The site was moved off Netlify onto Cloudflare Workers. The blog and forms decisions above are unchanged — only the platform they run on and a few mechanics (DB connection, email transport, rate limiting, config source) changed. Inline references to §15.* in the earlier sections point here.
15.1 Hosting — Cloudflare Workers via OpenNext¶
The site deploys to Cloudflare Workers through the OpenNext adapter (@opennextjs/cloudflare), not Cloudflare Pages. Pages' edge-only runtime can't provide the Node.js APIs the /api/forms route needs (pg + SMTP sockets), so Workers + OpenNext is required. wrangler.jsonc sets main: ".open-next/worker.js" with compatibility_flags: ["nodejs_compat"].
The site is fully static (SSG, no ISR). open-next.config.ts plugs in the static-assets-incremental-cache so prerendered pages are served from the read-only Workers Static Assets cache (the ASSETS binding) instead of being re-rendered per request — the default "dummy" cache would re-run the blog data loader on every navigation and exhaust the Worker's CPU/memory limits. No R2/KV required.
15.2 Two accounts / two environments¶
The app ships to two separate Cloudflare accounts (a dev account and a prod account), modelled as wrangler environments in wrangler.jsonc. Deploys are branch-driven: develop deploys to the dev account's Worker, master deploys to the prod account's Worker.
| Environment | Branch | Cloudflare account | Workers URL | Deploy command | Hyperdrive (DB) |
|---|---|---|---|---|---|
| dev | develop |
dev account | appcircle-website.dev-appcircle.workers.dev |
opennextjs-cloudflare deploy -e dev |
dev account's own Hyperdrive id |
| production | master |
prod account | appcircle-website.appcircle.workers.dev |
opennextjs-cloudflare deploy -e production |
prod account's own Hyperdrive id |
vars / hyperdrive / ratelimits are declared per environment and are not inherited from the top level — so a -e-less deploy can't ship a half-configured Worker. Shared config (main, compatibility flags, assets, observability) lives at the top level and is inherited. Each account holds its own SMTP secrets and its own Hyperdrive config.
The prod Worker URL is not yet the public site: appcircle.io still resolves to the existing WordPress-backed site. Cutover is a DNS change — once testing on the Cloudflare Workers is complete, appcircle.io is repointed to the prod Worker, and the WordPress host is then decommissioned (§9).
15.3 Database — Postgres via Hyperdrive¶
The single form_submissions table and the pg driver (§6.1) are unchanged; only the connection path moved. In production the Worker reaches Postgres through the Hyperdrive binding (Worker → Hyperdrive → Cloud SQL); Hyperdrive egresses from an allowlisted Cloudflare range (172.64.0.0/13, allowlisted on Cloud SQL) and manages TLS to the origin. app/lib/db.ts reads env.HYPERDRIVE.connectionString; when no binding is present (local next dev) it falls back to a direct pg connection over SSL (rejectUnauthorized: false) using a single local connection string. Each account has its own Hyperdrive id pointing at that account's database. Queries stay parameterized (§6.4); every stored field is clamped to FORM_FIELD_MAX_LENGTH (250) before insert.
15.4 Email — worker-mailer over cloudflare:sockets¶
SMTP is sent with worker-mailer over cloudflare:sockets — not the originally planned nodemailer, which can't run on the Workers runtime. Transport is AWS SES (email-smtp.eu-west-1.amazonaws.com:587, STARTTLS; SMTP_FROM = no-reply@appcircle.io, identity verified in SES eu-west-1). worker-mailer is dynamically imported (worker-mailer/dist/index.mjs) at send time and listed in next.config.ts serverExternalPackages, so next build (Node) never tries to bundle cloudflare:sockets. Because it needs the Workers runtime, email only works under npm run preview / deployed — not next dev. Header values are CR/LF-stripped and template values HTML-escaped ({{value}} escapes, {{{value}}} raw) — the email-injection protections from §6.4.
15.5 Edge rate limiting¶
The rate-limiting requirement for /api/forms (formerly an open question in §13) is resolved at the platform layer: a Cloudflare FORMS_RATE_LIMITER binding (declared per environment in wrangler.jsonc) throttles 2 submissions per client IP per 60s, keyed on cf-connecting-ip, returning 429 when exceeded. The check is guarded/try-caught so local next dev (no binding) simply skips it.
15.6 Configuration model (vars + secrets + env.ts)¶
env.ts is still the single module the app imports from (§7), but its values now originate from Cloudflare rather than a committed .env:
- Non-secret, environment-specific values (
SMTP_HOST,SMTP_PORT,SMTP_FROM) live in each environment'svarsblock inwrangler.jsonc(plaintext, committed). - Secrets (
SMTP_USER,SMTP_PASS, the Hyperdrive/Postgres connection, the MailBluster key) are set per account viawrangler secret put <NAME> --env <env>(or the Worker dashboard) — never committed. - Non-secret app policy (the
formsregistry,blockedEmailDomains,webinarEvents,FORM_FIELD_MAX_LENGTH) stays inline inenv.ts. env.tsexposes lazy getters (getSmtpConfig,getDatabaseUrl,getMailBlusterApiKey) that readprocess.envat request time, so the build succeeds without secrets andprocess.envis populated on Workers.
15.7 Build-time content bundling¶
The Workers runtime has no fs at request time (fs.readFile is unimplemented under OpenNext + unenv). So scripts/generate-blog-data.mjs runs in the prebuild/predev hooks and bundles everything the runtime would otherwise read from disk into JSON modules:
app/lib/blog-data.generated.json— post metadata + categories + manifest. Post bodies (content.html) are read fromfsat build time only, during the SSG prerender of/blog/[slug], then served from the prerender cache.app/lib/site-data.generated.json— the customer-logo list (/api/getLogos) and the HTML email templates read byapp/lib/mailer.ts.
Because opennextjs-cloudflare build calls next build directly, npm's prebuild hook doesn't fire under it — so the CI/deploy commands run npm run generate:blog explicitly before the build.
TDD for review — synthesizes the project scope with a source-level dependency analysis of appcircle-website. The decisions above are resolved; open questions remain (§13). Updated 2026-06-26: reconciled with the as-built deployment — the blog + forms migration shipped and the site moved from Netlify to Cloudflare Workers (see §15).