Skip to content

Prerender content pages and drop client-side zod#30

Merged
pedropaulovc merged 2 commits into
mainfrom
perf/prerender-static-content
May 29, 2026
Merged

Prerender content pages and drop client-side zod#30
pedropaulovc merged 2 commits into
mainfrom
perf/prerender-static-content

Conversation

@pedropaulovc

Copy link
Copy Markdown
Contributor

Why

Follow-up to #28/#29. A mobile PageSpeed run reported LCP 5.1 s, yet local Lighthouse with the identical throttling (Moto G Power, Slow 4G, 4× CPU) scored 100 / LCP 1.5 s, and real TTFB is 40–170 ms. The gap is environmental: the content pages were SSR on a Worker, so a slow/cold root-document response from a distant test location cascades (HTML late → fonts late → Playfair lands ~5 s → heading LCP 5.1 s). And for a content site, the client JS was carrying weight it didn't need.

Changes

1. Prerender content pages (index, about, work, briefs/*, legal, privacy, 404) → static HTML served from the CDN edge, no per-request Worker render, no cold-start TTFB variance. API + OIDC routes stay SSR.

To decouple from SSR, client telemetry self-configures: the App Insights iKey + ingestion endpoint (already public — they ship in every page today) move to a shared telemetry-config.ts; environment is resolved from the hostname on each side. The connection-string secret parse and the __telemetryConfig injection are removed.

build.format: "file" so pages emit as about.html (served at /about) rather than about/index.html (which 307-redirects /about → /about/) — keeps URLs identical to the existing canonical/sitemap/nav scheme, no redirect hop.

2. Drop client-side zod. The server validation (server/briefing.ts) is hand-rolled, not zod — so the ~13 KiB client zod bundle was pure duplication. Replaced with a small validate() mirroring the same rules. With zod gone, the form script falls under Astro's inline threshold and is inlined into the page (one fewer request). zod removed from deps.

Net effect on client JS (a content site now ships analytics-only JS)

Before After
Form (BriefingForm) 13.79 KiB gz request inlined, 0 requests
Telemetry 6.37 KiB, deferred (idle, beacon) unchanged
Content page TTFB per-request Worker (variable) static edge (consistent)

Verification (local wrangler preview)

  • All content routes → 200, no redirect; /about, /work, etc. served directly
  • SSR routes intact: /sitemap.xml, /llms.txt, /.well-known/openid-configuration → 200
  • Telemetry beacon fires on the static page (self-config works without SSR injection)
  • Form validation: empty → 5 correct per-field errors (role optional); valid → clears
  • npm run build + eslint clean

Note: /.well-known/jwks.json 500s on local preview (missing OIDC_PRIVATE_KEY secret locally); live prod is 200 and this PR touches no OIDC code.

🤖 Generated with Claude Code

Two changes that take the perf work from "fast when warm" to "fast and
consistent", and cut the client JS for a content site down to analytics only.

Prerender the content pages (index, about, work, briefs, legal, privacy,
404) to static HTML served from the Cloudflare CDN edge instead of an
on-demand Worker render. This removes per-request Worker execution (and
cold-start TTFB variance) from the content pages — the likely cause of the
5.1s LCP a mobile PageSpeed run reported while local Lighthouse with the
same throttling scored 100. API + OIDC routes stay SSR.

To make that possible, client telemetry no longer depends on per-request
SSR config injection: the App Insights iKey + ingestion endpoint (already
public — they ship in every page) move to a shared telemetry-config module,
and the `environment` dimension is resolved from the hostname on each side
(client from location, server from the request). The connection-string
secret parse and the `__telemetryConfig` <script> injection are gone.

build.format is set to "file" so prerendered pages emit as `about.html`
(served at /about) rather than `about/index.html` (which 307-redirects
/about -> /about/), keeping URLs identical to the existing canonical,
sitemap, and nav scheme — no redirect hop.

Drop client-side zod: the server validation in server/briefing.ts is
hand-rolled (not zod), so the ~13 KiB client zod bundle was pure
duplication. Replaced with a small validate() mirroring the same rules.
With zod gone the form script falls under Astro's inline threshold and is
inlined into the page — one fewer render-blocking-adjacent request. zod is
removed from dependencies.

Verified on a local wrangler preview: all content routes 200 with no
redirect; SSR routes (sitemap, llms, OIDC) intact; telemetry beacon fires
on the static page (self-config works); form validation produces the
correct per-field errors and clears on valid input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 29, 2026 17:45
@pedropaulovc
pedropaulovc enabled auto-merge May 29, 2026 17:46
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 29, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
decipher-ms c6ed36e Commit Preview URL

Branch Preview URL
May 29 2026, 05:57 PM

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR shifts key content routes from per-request SSR on Cloudflare Workers to prerendered static HTML (to stabilize TTFB/LCP globally), and removes client-side zod to reduce shipped JS by replacing it with a small hand-rolled validator. It also updates telemetry so prerendered pages can self-configure without SSR injecting config into the HTML.

Changes:

  • Mark content pages (/, /about, /work, /legal, /privacy, /briefs/*, /404) as prerender = true and configure Astro build output for “file” format to preserve canonical no-trailing-slash URLs.
  • Remove SSR-injected telemetry config and centralize App Insights identifiers + environment resolution in a shared module.
  • Drop zod from the client bundle and replace with lightweight client-side form validation.

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/pages/index.astro Enable prerendering for the homepage.
src/pages/about.astro Enable prerendering for the about page.
src/pages/work.astro Enable prerendering for the work page.
src/pages/legal.astro Enable prerendering for the legal page.
src/pages/privacy.astro Enable prerendering for the privacy page.
src/pages/404.astro Enable prerendering for the 404 page.
src/pages/briefs/advanced-dataverse-and-dynamics-365-security-model.astro Enable prerendering for the brief content page.
astro.config.mjs Set build.format = "file" alongside existing inline stylesheet config.
src/layouts/Layout.astro Remove SSR injection of window.__telemetryConfig.
src/middleware.ts Update server telemetry initialization to no longer depend on Worker env bindings.
src/lib/telemetry-config.ts New shared telemetry constants + environment resolution.
src/lib/telemetry.ts Client telemetry now self-configures from shared config (no injected config).
src/lib/server/telemetry.ts Server telemetry uses shared config constants instead of parsing a secret connection string.
src/lib/server/env.ts Remove APPLICATIONINSIGHTS_CONNECTION_STRING from typed bindings.
src/components/BriefingForm.astro Remove zod import and replace with hand-rolled client validation.
package.json Remove zod dependency.
package-lock.json Remove zod package from lockfile.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/telemetry.ts
Comment on lines 250 to +255
export function initTelemetry(): void {
if (initialized) return;
initialized = true;
config = window.__telemetryConfig ?? null;
if (!config) return;
// Identifiers are public constants; the environment is derived client-side so
// prerendered static pages need no per-request server-injected config.
config = {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed during preview testing (a beacon actually fired from localhost). Fixed in c6ed36e: initTelemetry() returns early when resolveEnvironment(location.hostname) === "development", restoring the prior 'no injected config => no emit' behavior. Preview/prod still send, tagged by environment. 🤖 Claude Code

Comment on lines 160 to 165
async flushNow(): Promise<void> {
if (!this.conn || this.envelopes.length === 0) return;
if (this.envelopes.length === 0) return;
const payload = this.envelopes.map((e) => JSON.stringify(e)).join("\n");
this.envelopes = [];
const url = `${this.conn.ingestionEndpoint}/v2.1/track`;
const url = `${AI_INGESTION_ENDPOINT}/v2.1/track`;
try {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — same fix server-side in c6ed36e: push() short-circuits when this.environment === "development", so no envelopes accumulate and flushNow() never POSTs from local preview. Deployed prod/preview hostnames are unaffected. 🤖 Claude Code

Comment on lines +130 to +146
const validate = (v: FormValues): Partial<Record<FieldName, string>> => {
const e: Partial<Record<FieldName, string>> = {};
if (!v.name.trim()) e.name = "Required";
else if (v.name.length > 120) e.name = "Keep this under 120 characters";
if (!v.email.trim()) e.email = "Required";
else if (v.email.length > 255) e.email = "Keep this under 255 characters";
else if (!EMAIL_RE.test(v.email.trim())) e.email = "Invalid email";
if (v.role.length > 120) e.role = "Keep this under 120 characters";
if (!(ENGAGEMENT_KEYS as readonly string[]).includes(v.engagementType))
e.engagementType = "Pick an engagement type";
if (!v.topic.trim()) e.topic = "Required";
else if (v.topic.length > 200) e.topic = "Keep this under 200 characters";
if (!v.details.trim()) e.details = "Required";
else if (v.details.trim().length < 10) e.details = "Add a bit more detail";
else if (v.details.length > 4000) e.details = "Keep this under 4000 characters";
return e;
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — the old zod schema was .trim().max() (validated the trimmed value) and we submit trimmed, so untrimmed length checks were a behavior change. Fixed in c6ed36e: validate() now checks trimmed values for all required/length rules (and reuses the trimmed string for the email regex). Verified: ' Joe Smith ' now passes. 🤖 Claude Code

Comment thread src/middleware.ts
}

const tel = new Telemetry(env, ctx, url.hostname);
const tel = new Telemetry(ctx, url.hostname);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the comment in c6ed36elocals.telemetry is now only used for server-side request/exception spans; the per-request client config injection is gone (the browser RUM client self-configures from public constants). 🤖 Claude Code

…nput

- Client + server telemetry now stay silent when the resolved environment is
  "development" (localhost), restoring the prior "no config => no emit" behavior
  so local work doesn't POST to the shared App Insights resource.
- Briefing form validates trimmed values for all length/required checks (the
  submitted payload is trimmed and the old zod schema used .trim().max()), so
  leading/trailing whitespace can't block an otherwise-valid submit.
- Update the middleware comment: the per-request client telemetry config
  injection is gone; the browser RUM client self-configures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@pedropaulovc pedropaulovc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@pedropaulovc
pedropaulovc merged commit 313e0eb into main May 29, 2026
2 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants