Skip to content
Frontend Feeds
  • Today
  • Archive
  • Sources
  • Categories
    • The Giants6 Sources
    • Multi Author Blogs18 Sources
    • Top Front-end Bloggers25 Sources
    • More Front-end Bloggers77 Sources
    • Browsers, engines, etc.11 Sources
    • Libraries, Frameworks, etc.12 Sources
    • Company/Startup Blogs12 Sources
    • Developer/Designer News4 Sources
    • YouTube Channels13 Sources
    • Podcasts7 Sources

The Giants

DEV Community

5 itemsVisit site ↗

Saturday, 14 November 2026

  • DEV CommunityThe Giantsread at source

    Service area map with ZIP code checker in React

    The client request I hear most often on home services sites is some version of "can the map show all the areas we serve?" The first few times I answered it with an embedded Google Map and a list of suburbs. It looked like every other contractor site and it did not answer the visitor's real question, which is not "where are you" but "will you come to me." The Service area block in Facade is the version I settled on after building this section for HVAC companies, plumbers and cleaning services. Here is how it is put together. Zones as data, not as pixels The first decision is to treat service zones as data. Each zone is an object with an id, a display name, a shape, a label anchor, the cities inside it and the ZIP codes it covers. The map, the zone list and the ZIP checker all read from the same array, so adding a zone means adding one object, not editing three places. import type { ServiceZone } from "@/components/facade/service-area" ; const zones : ServiceZone [ ] = [ { id : "north" , name : "North Austin" , path : "M150 40 L238 30 L262 70 L232 108 L170 120 L136 84 Z" , labelX : 198 , labelY : 76 , cities : [ "Allandale" , "Crestview" , "Domain" , "North Burnet" ] , zips : [ "78757" , "78758" , "78759" , "78727" ] , eta : "Same day" , } , { id : "central" , name : "Central Austin" , path : "M156 172 L190 214 L248 200 L270 252 L214 296 L140 284 L118 228 Z" , labelX : 190 , labelY : 250 , cities : [ "Zilker" , "Travis Heights" , "Bouldin Creek" , "Circle C" ] , zips : [ "78704" , "78745" , "78748" , "78749" ] , eta : "Same day" , } , ] ; The eta field is optional and it is the field that does the selling. "Same day" next to a zone name tells a visitor with a broken furnace more than any paragraph. For zones further out, "Next day" is honest and still useful. A stylized SVG map instead of Google Maps The shapes are SVG paths in a fixed 400 by 320 viewBox. That sounds like a step backward from a real map, so let me explain why I prefer it on a marketing page. A Google Maps embed loads a third party script, a tile set and its own fonts. On a mobile connection that is a noticeable hit to Largest Contentful Paint, and Google's own Core Web Vitals guidance treats 2.5 seconds as the threshold for a good LCP. An inline SVG with six polygons costs nothing. It also inherits your theme: the zones use currentColor and the brand token, so the map looks like your site in light and dark mode instead of a gray rectangle with a red pin. The other reason is honesty. Real service areas are not perfect circles around a shop. They follow highways, rivers and county lines, and a hand drawn polygon communicates "we know this town" better than a radius overlay. The decorative river line in the Facade block has a legend label you can rename or hide with riverLabel="" . Drawing the paths is a one time task. I open the real service area in a map tool, sketch the polygons on a 400 by 320 grid and copy the coordinates. Half an hour per client, and the result is a map that never changes under you when a map provider updates its styles. Keyboard and screen reader behavior Each zone is focusable. Arrow keys move between zones, Enter or Space selects one, and the zone list on the side expands to show its cities and ZIP codes. The SVG itself is a role="group" with a label, so a screen reader user hears "Service area map" and can tab into the list instead of being handed a pile of path elements. The ZIP code checker The checker is a small form under the map. The input accepts digits only and stops at five; the onChange handler strips everything else, so pasting "78704-1234" leaves "78704". On submit, the component validates against ^\d{5}$ and then runs a lookup: const zone = zones . find ( ( z ) => z . zips . includes ( zip ) ) ?? null ; That is the whole algorithm. No geocoding, no API key, no rate limits. The trade off is that you have to maintain the ZIP list, which for a local business is a list of twenty to sixty codes they already know by heart. The result messages are functions, so you can write them in the client's voice: import { ServiceArea } from "@/components/facade/service-area" ; ServiceArea eyebrow = "Service area" heading = "Heating and cooling across greater Austin" description = "Two hour arrival windows in every zone. Enter your ZIP to confirm." zones = { zones } hqLabel = "Dispatch on Burnet Rd" hqX = { 200 } hqY = { 160 } checkerLabel = "Do we serve your address?" checkerPlaceholder = "5 digit ZIP" successMessage = { ( zip , zone ) => `Yes. ${zone.name} gets ${zone.eta?.toLowerCase() ?? "fast"} service.` } failureMessage = { ( zip ) => `${zip} is outside our area for now.` } ctaLabel = "Schedule a visit" ctaHref = "/book" waitlistLabel = "Tell us when you expand" waitlistHref = "/waitlist" /> A successful check also selects the matching zone on the map, so the visitor sees their neighborhood light up at the same moment they read the answer. The animated result state Three states need motion here, and each one is small. An invalid entry, say four digits, shakes the input. The block increments a counter on each failed submit and keys the shake animation off it, which means the shake replays on every attempt instead of only the first. A match renders a success card with the zone name, the ETA and a CTA button. A miss renders a card with the failure message and a waitlist link, because a "no" with a next step keeps the lead instead of losing it. Both cards animate in through AnimatePresence with mode="wait" , so switching from a yes to a no does not stack two cards for a frame. All of it respects prefers-reduced-motion . The block reads useReducedMotion() and skips the shake and the entrance transforms for visitors who asked for less movement. Linking zones to city pages The service area is also an internal linking tool. Most home services sites have a page per city or suburb for local search. Add an href to your own data shape, or use the zone id to build the link, and render the city list as links in the panel next to the map. A visitor who finds their zone can jump straight to "AC repair in Round Rock," which is the page you want ranking for that query. I keep the zone array in a single lib/service-area.ts file and import it into the map block, the footer's service list and the city pages' generateStaticParams . One file, three consumers, no drift. Installing it The Service area is a Pro block. It installs from the private registry once your license key is in components.json , which is a one time step covered in the installation docs . npx shadcn@latest add @facadeui/service-area The file lands in components/facade/service-area.tsx and the only dependencies are motion and lucide-react . On an HVAC site I put it fourth, right after the process steps and before the reviews, because by then the visitor knows what the company does and wants to know whether it applies to them. FAQ Can I use real geographic shapes instead of hand drawn polygons? Yes. Export county or ZIP boundaries from a GIS source as SVG paths, scale them into the 400 by 320 viewBox and paste the path strings into the zone objects. The component does not care where the path came from. What about addresses, not ZIP codes? ZIP codes are deliberate. An address lookup needs a geocoding API, which adds a key, a cost and a privacy notice. Five digits are enough to answer "do you come to me" and most people know their ZIP without looking it up. How many zones work before the map gets crowded? Four to eight. Beyond that, labels start overlapping in the fixed viewBox. If a client covers a whole metro region, group suburbs into larger zones and list the individual cities in the panel instead of drawing each one. Originally published at https://facadeui.com/blog/service-area-map-react?utm_source=devto&utm_medium=social&utm_campaign=art-service-area-map-react

  • DEV CommunityThe Giantsread at source

    PostgreSQL 19 Ships on 19 November and PostgreSQL 14 Leaves Support

    TL;DR PostgreSQL is an open-source relational database that teams install on their own servers, and the project supports each major version for five years before a final minor release ends the branch. The PostgreSQL project's RC 1 announcement, published on 15 October, schedules PostgreSQL 19 general availability for 19 November 2026. A second candidate, PostgreSQL 19 RC 2, arrives on 12 November, the same day as the quarterly update release for supported versions. PostgreSQL 14 leaves support on that same 12 November with version 14.24, so a cluster on 14 stops receiving fixes that day. PostgreSQL 19 brings REPACK, a single command that replaces VACUUM FULL and CLUSTER, and REPACK (CONCURRENTLY) rebuilds a table without an access-exclusive lock. The same release adds pg_plan_advice and pg_stash_advice for planner decisions, logical replication of sequence values, a start for logical replication without a restart, and a WAIT command that lets a standby wait on a log position. Defaults change under an upgraded cluster: JIT ships disabled, max_locks_per_transaction moves to 128, standard_conforming_strings is always on, RADIUS authentication is gone, and inet and cidr indexes move to GiST. The path off version 14 runs through pg_upgrade or a dump and restore, and pg_upgrade refuses clusters that carry btree_gist indexes on inet or cidr. The planning point: the version a team runs is a support date, and the date sits on the project's published calendar. This release-notes briefing draws its dates from two PostgreSQL project documents and focuses on clearing PostgreSQL 19's documented incompatibilities before version 14 support ends. The 19 release notes carry an overview that names each headline feature, and the 19 RC 1 announcement sets the schedule plainly: RC 2 on 12 November 2026, general availability on 19 November 2026. Netics visual, 11 November 2026: what a minor release is and what installing one actually involves. The project supports each major version for five years The versioning policy gives a major version five years of bug and security fixes counted from its first release, and the last of those arrives as a final minor release. After that the branch sits unsupported. Minor releases land at least once every three months, targeted at the second Thursday of February, May, August and November. That calendar is the practical part. A team planning a migration can read the final-release column and see the wall before it arrives, which is a rare luxury in infrastructure planning. The project's own support table, read on 11 November 2026: version 14 lists 12 November 2026 in the final-release column, version 18 lists November 2030. Page from https://www.postgresql.org/support/versioning/ . PostgreSQL 14 leaves support on 12 November Version 14 first shipped on 30 September 2021, and the support table lists 14.24 as its current minor release with a final release date of 12 November 2026. After that final minor release, version 14 becomes unsupported. The release notes provide an inventory of incompatibilities. An operational decision also needs an inventory of the extensions and drivers used by the application. Rehearse the chosen migration route on a production-like copy and run the critical queries. Verify backups, the rollback procedure and the maintenance-window duration before approving the migration. The announcement as published on 15 October 2026, naming RC 2 on 12 November and general availability on 19 November. Page from https://www.postgresql.org/about/news/postgresql-19-rc-1-released-3409/ . PostgreSQL 19 arrives on 19 November with REPACK The release notes describe REPACK as the command that replaces VACUUM FULL and CLUSTER. The old commands remain for compatibility. REPACK (CONCURRENTLY) adds the version most operators want: rebuilding a table without taking an access-exclusive lock on it. Planner control arrives as two extensions. pg_plan_advice stabilizes and controls planner decisions, and pg_stash_advice applies stored advice automatically based on a query. Autovacuum also gains parallel index processing and a scoring system that orders which tables it should reach first, on top of I/O worker processes that scale through io_min_workers and io_max_workers. The overview section of the release notes, where the feature list and the migration requirements sit at the top of the page. Page from https://www.postgresql.org/docs/release/19.0/ . Replication gets the two changes that matter most on a busy cluster. Logical replication can be switched on with wal_level set to replica without restarting the server, and sequence values now replicate. A WAIT command lets a standby wait until a log position has been written, flushed or replayed. Defaults change under an upgraded cluster Five defaults shift under the cluster a team restores into 19: JIT is disabled by default, because the optimizer cost that used to trigger it proved unreliable. Sites running large analytical queries need to enable it deliberately. max_locks_per_transaction moves from 64 to 128, and the release notes say the lock size allocation changed so settings must double to hold their previous capacity. standard_conforming_strings is forced on in the server. A dump taken with a pre-19 pg_dump while the setting was off will not load into 19. RADIUS authentication is removed, on the grounds that support existed only over UDP and that transport is unfixably insecure. inet and cidr indexes default to GiST, and pg_upgrade refuses clusters carrying btree_gist opclasses on those types because the old opclasses could exclude rows that belong in the result. Netics visual, 11 November 2026: the setting-level differences between a 14 cluster and a 19 cluster, drawn from the release notes. pg_upgrade decides the path off version 14 The release notes accept three migration routes: a dump and restore with pg_dumpall, pg_upgrade, or logical replication. For planning purposes, treat pg_upgrade as a route that needs an explicit preflight check. Two pg_upgrade refusal conditions are worth checking before a maintenance window: clusters with btree_gist indexes on inet or cidr, and clusters that use carriage returns or line feeds in database, role or tablespace names. Separately, dumps produced by pre-19 tools while standard_conforming_strings was off will not properly load into PostgreSQL 19. Each is a configuration fact that can be found in advance, and each one turns a migration into a debugging session when it surfaces mid-run. There is also a spare SQL. MULE_INTERNAL encoding is gone, so a database that uses it needs a dump and restore into a different encoding. The mark the PostgreSQL project publishes for press use. Image from https://www.postgresql.org/media/img/about/press/elephant.png . Application compatibility deserves priority during upgrade planning, including dependencies introduced through custom web and mobile development . The five-year calendar supplies a deadline; migration safety depends on testing the chosen route and clearing PostgreSQL 19's documented incompatibilities and pg_upgrade refusals before the maintenance window. Originally published on the Netics blog .

  • DEV CommunityThe Giantsread at source

    I Built a Tiny Tool to Fix My Own RMD Confusion

    Every year, around December, I find myself staring at a spreadsheet trying to figure out how much money I need to pull out of my retirement accounts to avoid an IRS penalty. It’s a ritual that feels unnecessarily complex for something that should be straightforward: look up your age, find the divisor, divide your balance. That’s it. But finding the right table for the current year is surprisingly annoying. Search results are cluttered with outdated PDFs, confusing UI from major financial institutions, and ads for investment services I don’t need. I just wanted a clean, fast way to calculate my Required Minimum Distribution (RMD) using the official IRS Uniform Lifetime Table. So, I built a small tool called RMD Table. It’s not a platform. It’s not an app with a login system. It’s a single-page utility that does one thing well: calculates your RMD based on the latest regulation. The Problem with Existing Tools Most financial calculators are bloated. They ask for your entire financial history, your risk tolerance, and your email address before showing you a number. For a simple division problem, this feels like overkill. I wanted something that loads instantly. No JavaScript frameworks if possible, no heavy CSS frameworks, and no tracking scripts. Just HTML, CSS, and a tiny bit of vanilla JS. The goal was to make the tool feel invisible. You come in, you see your number, you leave. When I started building, I looked at a few existing options. Some were great but required an account. Others were simple but used outdated tables from previous years. The IRS updates the Uniform Lifetime Table occasionally, and ensuring the tool always reflects the current regulation is key. I wanted to avoid the common pitfall of hardcoding values that might become stale. How It Works The logic is surprisingly simple. The tool takes two inputs: your age and your December 31 account balance. It then looks up the corresponding divisor from the IRS Uniform Lifetime Table for the current year. For example, if you are 72 years old, the divisor might be 25.6. If your balance is $100,000, your RMD is $100,000 / 25.6 = $3,906.25. The challenge wasn’t the math; it was the UX. I wanted the interface to be responsive and accessible without sacrificing speed. I used CSS Grid for layout because it’s lightweight and handles different screen sizes gracefully. The font stack uses system fonts to avoid loading external resources. This keeps the First Contentful Paint under 1 second on most devices. I also added a small feature that many tools miss: context. Many calculators just spit out a number. But the IRS rules can be tricky. Did you know your first RMD has a different deadline than subsequent ones? Did you know if you miss the deadline, the penalty is 25% of the shortfall? I included brief, clear notes below the result to explain these nuances without overwhelming the user. Why Build Something So Small? In an era of mega-apps and complex SPAs, building a single-purpose tool feels refreshing. It forces you to prioritize clarity. Every element on the screen has to earn its place. There’s no room for decoration. I used a minimal CSS reset and kept the JavaScript under 5KB. No libraries. No build steps. Just a plain HTML file served statically. This makes it incredibly fast to deploy and easy to maintain. If the IRS updates the table next year, I just edit a JSON object in the script. Done. The tool is now live at rmdtable.com . It’s simple, fast, and does exactly what it says on the tin. I’m using it myself every December, and it saves me about five minutes of searching and comparing different websites. What’s Next? Right now, the tool only supports the Uniform Lifetime Table. There are other tables for beneficiaries and married couples with younger spouses, but those are edge cases. I’ll add them if enough people ask. For now, I’m focused on keeping it lean. No analytics beyond basic page views. No pop-ups. Just a clean calculation. If you’ve ever struggled with RMD calculations, I’d love to hear your feedback. What’s the most confusing part of the process for you? Is it finding the right table, understanding the penalties, or something else entirely?

  • DEV CommunityThe Giantsread at source

    Copilot code review never sets a blocking state. I checked 55 merged PRs.

    PR 337469 in microsoft/vscode got two Copilot reviews 52 minutes apart, on the same commit (352dd22d). The first said "Needs a closer look" and described four unresolved moderate issues. The second said three issues remained. Both bodies ended with the same structured line: Findings: None . So on one unchanged diff, the summary text changed, the issue count changed, and the structured findings field reported nothing either time. I went looking for how common that is, and the answer is that the verdict is not part of the review's state at all. A review object has a state, and Copilot's is always COMMENTED GitHub's REST docs for pull request reviews describe a review as comments "grouped together with a state and optional body comment". That state field is what branch protection reads. Copilot code review writes COMMENTED . I sampled merged PRs from microsoft/vscode in two slices of 30 ( state=closed&sort=created&direction=desc ), filtered to merged_at != null , then pulled every review object per PR and filtered to user.login == copilot-pull-request-reviewer[bot] . 28 PRs in the first slice and 27 in the second had a Copilot review. Every review object across all 55 was state COMMENTED . Zero APPROVED , zero CHANGES_REQUESTED . That matters because of what reacts to state. GitHub's protected branch docs describe the merge failure you get when a review sits in a blocking state ( GH006: Protected branch update failed... Changes have been requested ), and the optional setting that dismisses stale approvals when the diff changes after approval. Neither path touches a COMMENTED review. A review that is never blocking cannot block, and it cannot go stale either, because there was never an approval to invalidate. The heading is UI text. GitHub's code review concept docs say Copilot's reviews do not count toward required approvals by default, and that enabling Copilot approvals is a separate repository, organization, and enterprise setting. When that setting is on, a new push dismisses the approval. When it is off, the "Approval recommended" you are reading satisfies nothing. Half the merged PRs carried a "Changes recommended" at merge time Final Copilot review per PR, first slice, 28 merged PRs: 16 Approval recommended 9 Changes recommended 3 Needs a closer look 12 of those 28 merged with a non-approval verdict standing. The older slice (n=27) came out 14 Changes recommended, 12 Approval recommended, 1 Needs a closer look, with 15 merging on a non-approval verdict. Roughly half in both. None of that is a bug report. A reviewer that informs without gating is a defensible design, and teams treating it as advisory are getting what the product promises. The problem shows up when you turn it into a metric. Count "AI approvals" or "AI blocks" from the review state and you get 0 and 0, on every repo, forever. Count it from the heading text and you are counting a string the product edits on its own schedule. The verdict does not follow the findings list 19 of the 28 final reviews in the first slice reported zero open findings. Three of those still ended with "Needs a closer look". So the heading and the findings list are computed separately, and PR 337469 is the visible case: a summary describing unresolved issues sitting next to a findings field reading None. The second review in that pair is worth reading closely for another reason. It flagged a "previously missed issue" in code that had not changed since the first review. Same commit, same files, one new finding. Requesting a fresh assessment does not buy you a stable one. The verdict is usually rendered against a diff that no longer exists 13 of the 28 final reviews in the first slice, and 15 of 27 in the second, sat on a commit that was not the PR head at merge time. The reviewed commit is exposed as commit_id on the review object, and comparing it to head.sha costs one call. That combination is why the "stale verdict" reading misleads when you attribute it to the reviewer. For human approvals, GitHub records the approved diff state and can dismiss the approval when the diff moves. For a COMMENTED review, nothing is recorded and nothing is refreshed, so an off-head verdict is the normal end state. Report it as a coverage caveat next to any defect count, since the reviewer never saw the final version of the change. Re-review is rare, and the second review is a delta In an updated-sorted slice of 28 merged PRs, 24 had exactly one Copilot review. Across the two created-sorted slices, 3 of 55 had more than one. The review bodies invite you to request a fresh assessment, and almost nobody does. That matters more after the November 6 changelog , which says that on later reviews of the same PR, findings unrelated to your recent changes are collapsed and then hidden. A second review reports the delta against your last push. It does not re-read the whole change. Run a reviewer twice, diff the bodies, and a drop in findings reads like an improvement when it is a narrower scope. Two reviews, one diff, different output The same-commit pair is the artifact I would put in front of anyone building a quality score. One diff, one commit, two review requests, two different issue counts, one of them inconsistent with its own findings field. That is the failure mode of a flaky test: the instrument changes its answer while the input stays fixed. A single-run score from that instrument is a sample of one. The cheap version of the test costs nothing. Open a PR, request a review, request it again on the same commit, compare the two bodies line by line. If the counts move, no quality number you compute from one run survives a re-run. What to record instead Five fields, one row per review run: The reviewed commit id ( commit_id ) and the PR head sha at the time you read the result. The findings count from the structured part of the body, matched against every shape the body has used, not one string. The effort level in use, because Lite, Balanced and Max are different products. The verdict heading, stored as text, with the date you read it. Whether a second review happened, and what changed between the two. On effort, the concept docs publish estimated AI credit consumption per review at roughly $0.25 average for Lite, $1.50 for Balanced and $10 for Max, with a warning that consumption rises with PR size and repository custom instructions. The November changelog adds that Balanced now uses three reviewer models with another model combining their findings. A quality number with no effort level attached is not comparable to anything. On the counter itself, the body format keeps moving. The November 6 update moved the effort label into a compact footer, merged skipped and excluded files into one "Files not reviewed" section, and added explicit attribution fields to the REST and GraphQL APIs so you can identify Copilot's reviews and comments directly. Key your metrics on those fields. A grep for a heading string is a metric that breaks on a UI release. If you want a gate, buy a gate Advisory review and required review are separate purchases, and the tooling reflects that. CodeRabbit's configuration reference exposes reviews.auto_approvals.enabled (default false) and states that missing, failed, disabled or stale required assessments withhold approval, which gives you a documented condition set to test against your own PRs. Kodus sits at the other end: self-hosted, AGPLv3, bring your own model, with review rules scoped to an organization, a repository or a path, so the scope and the pass conditions live in your config and you can re-run them. Both are testable in the sense that matters here, which is that you can predict from the docs what happens to the verdict when the diff changes. With Copilot's default configuration the answer is nothing, and the docs say so. Before comparing tools, settle file scope first. I wrote about the documented skip list and what it does to a head-to-head . And if you are still building the baseline for review time, the fixed-slice protocol is the shape I would reuse. The number your branch protection sees Copilot code review produces a comment with a verdict in it. Your branch protection reads a review state, and that state was COMMENTED on all 55 merged PRs I sampled. Count the state, treat the heading as UI text, and record the reviewed commit id next to every result you keep. That is the difference between a metric and a screenshot.

  • DEV CommunityThe Giantsread at source

    Codifying scotthavird.com in Terraform

    Codifying scotthavird.com in Terraform For years this site lived in two places at once: the Docusaurus repo and a scattering of Cloudflare dashboard clicks. Pages project settings, custom domains, zone security toggles, and an email signup API that only existed in AWS — none of it was reviewable in a PR. That drift is fine until it isn't. I hit the www redirect trap on my own domain. Preview builds failed because Node 18 was still configured in the dashboard while Docusaurus 3.9 requires Node 20. Each fix was another manual change with no audit trail. So I codified the whole stack in iac/ and retired the GitHub Actions deploy workflow in favor of native Cloudflare Pages builds. What Moved Into Terraform The iac/ directory now owns four concerns that used to be split across dashboards: Concern Resource Why codify it Site hosting cloudflare_pages_project Build command, Node version, preview branch rules Zone security null_resource + Cloudflare API SSL strict, HSTS preload, TLS 1.3 Email signup API Gateway + Lambda + DynamoDB + SES Homepage newsletter form DNS for email cloudflare_record (SES verification, DKIM) Deliverability without manual TXT records The Docusaurus site itself stays in the same repo. Cloudflare Pages builds main on every push — no separate deploy workflow. Cloudflare Pages: The Important Details The Pages project resource looks straightforward, but a few fields matter in practice: build_config { build_command = "yarn build" destination_dir = "build" } deployment_configs { production { environment_variables = { NODE_VERSION = "20" SUBSCRIBE_API_URL = aws_apigatewayv2_api . main . api_endpoint } } } Node 20 — Docusaurus 3.9.2 requires it. The dashboard was still on 18, which meant every PR preview failed with yarn: command not found or engine warnings. Setting it in Terraform means preview and production stay aligned. SUBSCRIBE_API_URL — Docusaurus reads this at build time via process.env.SUBSCRIBE_API_URL and exposes it as siteConfig.customFields.apiUrl . The homepage EmailSignup component POSTs to ${apiUrl}/subscribe . Wiring the API Gateway endpoint into the Pages env var means the frontend always targets the right backend without hardcoding URLs. Preview branch excludes — gh-pages is excluded from preview builds. That branch is the old peaceiris deploy output; it has no package.json and will fail every build attempt. One gap: build_image_major_version (v1 → v3) isn't exposed in the Cloudflare provider v4. I set it via a one-time API PATCH and documented the workaround in iac/pages.tf . Provider v5 adds native support — revisit on upgrade. Zone Security Without the Provider Trap The cloudflare_zone_settings_override resource in provider v4 reads all zone settings during refresh, including Enterprise-only fields that return HTTP 999 on Free plans. That makes terraform apply fail even when your target settings are correct. The workaround: a null_resource with local-exec that PATCHes only the settings we care about: SSL mode: strict Always Use HTTPS: on Minimum TLS: 1.2 , TLS 1.3: on HSTS: 2-year max-age , include subdomains, preload-ready The triggers block hashes the target values so any change re-applies. It's not elegant, but it's reliable on a Free-plan zone. Email Subscribe API The homepage newsletter form needs a backend. The stack: API Gateway HTTP API — POST /subscribe , GET /confirm?token=... Lambda — validates email, stores pending token in DynamoDB, sends confirmation via SES DynamoDB — subscribers table + tokens table with TTL for expired confirmations SES — domain-verified sending from scotthavird.com All tagged with Project = scotthavird-com and environment-scoped naming ( scotthavird-prod-* ). The double opt-in flow matters: we don't add addresses to the list until the user clicks the confirmation link. Tokens expire via DynamoDB TTL. What I Deliberately Didn't Codify www → apex redirect — Cloudflare Pages owns the http_request_dynamic_redirect ruleset for custom domains. Zone-level Rulesets API tokens can't override it. The fix is documented in Your Cloudflare Pages Redirect Is Probably Backwards : set the apex as the primary custom domain in Pages, then add a Page Rule for belt-and-suspenders. GitHub Actions deploy — Retired. Cloudflare Pages builds directly from the repo. One less moving part, preview URLs on every PR, and no GITHUB_TOKEN deploy secret to rotate. Operating It cd iac terraform plan -var-file = prod.tfvars terraform apply -var-file = prod.tfvars Prod and dev workspaces share the pattern; DNS records for SES are prod-only ( local.is_prod ) to avoid conflicts. Site-side guardrails run in CI on every PR: yarn ci # lint:posts → update:llms → build → validate:schema Infrastructure changes and content changes can ship in the same PR when needed — but they're reviewed separately because they touch different directories. Lessons Codify the stuff that breaks silently. Redirect direction and Node version don't fail loudly until SEO or previews are already damaged. Build-time env vars for frontend API URLs. Hardcoding API Gateway endpoints in React is a deploy-order nightmare; Pages env vars solve it cleanly. Provider limitations are real. When the Terraform resource can't express what you need, document the out-of-band step and add a trigger or comment so the next person doesn't "fix" it back to broken. Keep the site and the infra in one repo. Docusaurus content and Cloudflare/AWS resources share a lifecycle. One PR can add a feature and the API it needs. Related Posts Your Cloudflare Pages Redirect Is Probably Backwards — The www/apex trap that motivated part of this work Ways of Working at the Speed of Thought — Why reducing deploy friction matters when engineers ship in hours This article was created with the help of AI. Originally published at scotthavird.com . Follow me on X and GitHub .

Articles belong to their publishers. This site only collects what their feeds provide.

Updated 14 November 2026 at 14:01