Sito vetrina e blog di una consulente di carriera
Sito pubblico prerenderizzato, blog scritto dentro il gestionale e pubblicato da una build che si ferma se un contenuto non è valido. 99 di Performance su mobile.
- Ruolo
- Analisi, contenuti tecnici, sviluppo, SEO, infrastruttura — autore unico
- Periodo
- luglio 2026 → oggi
- Stato
- Online da luglio 2026
- Online
- elisabettacarrara.com
- Angular 22
- Prerender SSG
- Cloudflare Pages
- Pages Functions
- Turnstile
- Resend
In breve
elisabettacarrara.com è il sito pubblico di una consulente di carriera: presentazione,
servizi, prenotazione della prima chiamata, form di contatto e blog. È online da luglio
2026 su Cloudflare Pages.
Su mobile, PageSpeed Insights dà 99 di Performance, 100 di Accessibilità, 100 di Best
Practice, 100 di SEO, con CLS a 0. Il CLS era 0,201: si è azzerato con il preload dei
font critici e font-display: optional sui subset latini, tenendo swap su quelli
estesi.
È il gemello pubblico del gestionale: il blog che si legge qui viene scritto là.
Il vincolo che decide tutto: nessun server
Ogni rotta è prerenderizzata, articoli compresi. Non c'è un server a runtime: il sito è HTML statico su CDN, e le uniche parti dinamiche sono tre funzioni serverless (contatti, health check, webhook delle notifiche di consegna email).
Il dettaglio che vale la pena raccontare è la trappola: la build è configurata in modalità server, ma tutte le rotte sono in prerender. Se una rotta si dimenticasse di essere prerenderizzata, verrebbe passata a un server che su un hosting statico non esiste — e il sintomo non sarebbe un errore in build, sarebbe un 404 in produzione. L'avviso è scritto nel file delle rotte, accanto alla configurazione che lo rende possibile.
Stessa logica sull'ottimizzazione del CSS critico: è disattivata di proposito. Angular
la implementa con un onload inline sul foglio di stile, cioè uno script inline, che la
Content Security Policy del sito blocca — in silenzio. Il risultato non è un errore di
rete: è una pagina senza stile, con il CSS rimasto per sempre in media="print". Ora c'è
un commento che dice di non riattivarla senza rifare il giro della CSP.
Il blog non si scrive qui
I contenuti non sono file nel repository: si scrivono nel gestionale, in un editor Markdown, e la pubblicazione chiama un deploy hook che fa ripartire la build del sito. La build interroga l'API, scarica gli articoli e le immagini, rende il Markdown in HTML e prerenderizza ogni pagina.
Il principio di tutta la pipeline è fail-closed: se qualcosa non torna, la build muore invece di pubblicare una pagina rotta. Si fermano, con codice di uscita 1: slug duplicato, chiave di front matter sconosciuta, corpo vuoto, un titolo di livello 1 dentro l'articolo (spaccherebbe la gerarchia dei titoli della pagina), data non ISO, campo oltre i limiti che l'API stessa impone, copertina mancante, immagine che dopo il rendering punta fuori origine, download di un'immagine fallito, API irraggiungibile o che risponde qualcosa che non è JSON.
Una sola eccezione, voluta: una meta description troppo lunga è un avviso, non un errore. Un titolo lungo scritto nel CMS non deve poter bloccare la pubblicazione di tutti gli altri articoli.
Il pezzo che mi ha insegnato più di tutti è il contratto di forma della risposta API. Un
campo rinominato lato server non dà un errore: dà undefined.
// ⚠ I nomi sono in camelCase: ASP.NET Core serializza coi default «web», quindi
// `PublishedAt` esce `publishedAt`. Un contratto in PascalCase troverebbe *tutti* i
// campi mancanti.
export const CAMPI_API = [
{ nome: 'slug', tipo: 'string', nullable: false },
{ nome: 'excerpt', tipo: 'string', nullable: true },
{ nome: 'coverImageUrl', tipo: 'string', nullable: false },
{ nome: 'publishedAt', tipo: 'data', nullable: false },
// …
];
Lo stesso elenco ha due consumatori in due momenti diversi: la build lo verifica sui dati
veri (ed è l'unica cosa che impedisce davvero un deploy sbagliato), e un test lo verifica
su una risposta registrata, senza rete. Il test simula il rename di ogni campo del DTO,
uno per uno. Prima che esistesse, il primo sintomo di un rename era un Invalid time value
sulla data — oppure, peggio, un articolo online senza estratto e senza meta description,
con la build verde.
Il form di contatto, gate per gate
L'endpoint dei contatti è una funzione serverless. L'ordine dei controlli è la parte progettata:
| # | Controllo | Esito |
|---|---|---|
| 1 | variabili d'ambiente incomplete, o nessun destinatario valido | 500, fail closed |
| 2 | origine non ammessa (Origin, con Referer come ripiego) |
403 |
| 3 | Content-Type diverso da JSON |
415 |
| 4 | corpo oltre il limite, dichiarato e misurato | 413 |
| 5 | JSON non valido | 400 |
| 6 | honeypot compilato | 200 finto, niente invio |
| 7 | token Turnstile assente, troppo lungo, o non valido lato server | 403 |
| 8 | oltre 5 invii nell'ora dallo stesso IP | 429 |
| 9 | nome, email o messaggio non validi | 422 |
Tre scelte dentro quella tabella. Il honeypot risponde 200: un bot che riceve un errore capisce di essere stato scartato e riprova cambiando strategia. Il rate limit sta dopo Turnstile, per non spendere scritture sullo store distribuito sul traffico dei bot. E la risposta di successo è volutamente generica, uguale per ogni fallimento, così nessuno può mappare le difese sondando l'endpoint.
Il form manda anche la pagina di provenienza: da dove è partito l'invio. È letta dalla navigazione interna, con il referrer come ripiego solo se è dello stesso sito, e rivalidata lato server; nessun cookie, nessun tracciamento. Serve a sapere quale pagina converte, senza analytics che profilino.
SEO: i numeri devono venire da una sola fonte
I dati strutturati della home sono un @graph con il servizio professionale, la persona e
il sito. Il pezzo che conta: il catalogo delle offerte è derivato dal listino che la
pagina già mostra, quindi prezzi a schermo e prezzi dichiarati a Google non possono
divergere. Ogni articolo emette il proprio BlogPosting con dateModified preso dalla
data di aggiornamento vera, non dalla data di pubblicazione ricopiata.
Due dettagli imparati con la Search Console in mano: il canonical forza la barra finale,
perché è la forma che l'hosting serve con 200 (l'altra è un 308, e un canonical che punta a
un redirect è uno spreco); e il blocco degli articoli nella sitemap è rigenerato dalla
build solo dentro i suoi marcatori, così le quattro pagine fisse mantengono il loro
lastmod reale e non prendono la data di build.
Accessibilità decisa sui rapporti, non sul gusto
La palette viene da colori di brand, e due colori non passavano: il corallo dei pulsanti è
stato scurito perché il corallo puro non arriva a 4,5:1, e il testo attenuato non è
un'opacità. Lo stesso opacity: .75 misurava 4,3:1 su crema e 4,7:1 su bianco: lo stesso
grigio passava o non passava a seconda dello sfondo. È diventato un colore proprio, con il
rapporto misurato scritto accanto.
L'anello di focus è 3 px di colore CTA più un alone bianco di 5 px, perché il verde salvia dei bordi stava a 2,6:1 — sotto il minimo di 3:1 per i componenti — e il solo CTA sarebbe crollato a 1,8:1 sui fondi navy.