Salta al contenuto

GET/it/projects/guidance

Applicazione per un centro di orientamento

Il sito, il CMS e il CRM di Polo Fabricamente, un centro di orientamento per le lauree online. L’ho progettato e sviluppato da solo.

cliente
Polo Fabricamente
stack
Next.js 16, Payload CMS 3, PostgreSQL su Neon, Vercel
stato
In produzione

Il problema

È nato come landing page ed è diventato un gestionale: il sito con il catalogo dei corsi, scritto per farsi trovare dai motori di ricerca e dagli assistenti AI, il CMS dei contenuti e il CRM in cui gli orientatori lavorano le richieste, comprese quelle che arrivano dalle inserzioni di Meta.

Il vincolo era nessun servizio a pagamento, quindi tutto sta nei piani gratuiti. Il database si sospende dopo cinque minuti senza connessioni e ha un budget di ore al mese: conta quante volte si sveglia, non quante query riceve. Il sito doveva restare sempre aggiornato e svegliarlo il meno possibile.

Requisiti, architettura e scelte sono miei; il codice l’ho scritto con l’aiuto di Claude Code, un lavoro alla volta, misurando prima e dopo.

Com’è fatto

Un’app Next.js sola, con Payload montato dentro: sito, CRM e CMS vanno in produzione insieme e leggono lo stesso database.

  • chi entra

    • Visitatori

    • Motori e AI

    • Staff

  • Vercel

      • proxy.ts decide il 404 prima del render

      • CDN le pagine pubbliche, statiche

    • Next.js · un’app, un deploy

      • / il sito pubblico
      • /dashboard il CRM
      • /admin il CMS
      • Strato delle query le letture, in cache con i loro tag

      • Payload CMS Local API, nello stesso processo

    • Vercel Blob immagini e PDF

    • cron notturni Meta, avvisi, pulizia

  • PostgreSQL su Neon a Francoforte, come le funzioni

  • servizi esterni

    • Meta lead

    • Resend email

    • IndexNow motori

Dove si ferma una richiesta

Ogni livello risponde da solo quando può, e passa al successivo solo se deve. Il database si tocca per ultimo.

  1. Browser la richiesta

    GET /corsi/economia-aziendale

  2. proxy.ts 404 prima del render

    solo gli indirizzi che possono non esistere, come una scheda corso

  3. CDN Vercel pagine prerenderizzate

    risponde da sola se ha la pagina

    HIT la pagina torna al browser da qui

  4. Funzione Next.js rende la pagina

    solo se la pagina manca o è stata invalidata

  5. Cache di Next le letture, con i loro tag

    solo le letture che mancano

  6. Neon PostgreSQL, piano gratuito

    si sospende dopo 5 minuti; la richiesta che lo sveglia aspetta

Dal salvataggio alla pagina

La cache segue i salvataggi, non l’orologio. All’inizio le pagine scadevano ogni ora, e ogni scadenza poteva svegliare il database; ora una pagina si rigenera quando un redattore cambia qualcosa, e di notte un cron fa da rete.

a ogni salvataggio

  1. Il redattore salva la sede di Roma, dal pannello
  2. Commit su Neon in una transazione
  3. Hook di Payload il pannello risponde «salvato» senza aspettare
  4. Dopo la risposta invalida i tag della sede e la pagina /roma
  5. IndexNow avvisa i motori delle pagine cambiate
  6. La visita dopo GET /roma con il dato nuovo

entro un secondo, misurato in produzione

ogni notte

  1. Il cron delle 3 sveglia comunque il database per la pulizia
  2. Purga e riscalda le pagine nella finestra in cui il database è già sveglio
  3. La rete di sicurezza un tag dimenticato costa al più un giorno, non una settimana

Un solo profilo di cache, a una settimana: una pagina scade con la più corta delle sue letture, e un profilo più corto nel layout accorcerebbe tutto il sito.

Il 404 si decide prima del render

Una scheda corso che non esiste rispondeva 200 con una pagina «non trovata», un soft 404: con lo streaming lo status parte con i primi byte, prima che la pagina scopra che lo slug non c’è. Ora lo decide il proxy, prima del render.

  1. GET /corsi/{slug} una scheda corso

  2. proxy.ts è un indirizzo da verificare? sì

    no: passa alla pagina

  3. Registro degli slug lo slug esiste ed è pubblico? no

    sì: 200, la pagina dalla CDN

    non risponde: passa lo stesso, fail-open

  4. 404 nessun render, nessuna query

Il registro legge da una cache condivisa fra le istanze, quindi il controllo non sveglia il database. Se non risponde la richiesta passa: meglio un soft 404 raro che un sito che rifiuta tutto.

Decisioni

  • ADR 001 Accettata

    Payload, dentro l’app

    contesto
    Servivano un CMS per i contenuti e un pannello per lo staff, senza servizi a pagamento.
    decisione
    Payload CMS montato nella stessa app Next.js, sullo stesso database del sito. Le pagine leggono con la Local API, nello stesso processo, senza chiamate HTTP.
    conseguenze
    Tutto sta nei piani gratuiti e nel repo: va in produzione con il sito, si personalizza in tutto, e nessun fornitore esterno può cambiare prezzi o sparire. Lo stesso login vale per CMS e CRM. In cambio, aggiornare Payload vuol dire aggiornare l’app.
  • ADR 002 Accettata

    Pagine statiche per intero

    contesto
    Motori di ricerca e assistenti AI leggono l’HTML, e molti non eseguono JavaScript. Con lo streaming, nel <main> delle pagine c’era «Caricamento in corso» e il contenuto arrivava dopo, nascosto in fondo al documento.
    decisione
    Ogni pagina pubblica è prerenderizzata per intero, senza Suspense di pagina. La scheda corso mostra tutto in una pagina con un indice, e la parte che cambia per lo staff la chiede il browser dopo il caricamento.
    conseguenze
    Persone, motori e assistenti ricevono lo stesso HTML dalla CDN, e nessuno di loro sveglia il database. Un test impedisce ai Suspense di pagina di tornare.
  • ADR 003 Accettata

    Transazioni vere e righe bloccate

    contesto
    Più orientatori lavorano le stesse richieste nello stesso momento. Il primo adattatore del database, quello serverless, mandava inizio, scrittura e commit come query separate, anche su connessioni diverse; e un salvataggio di Payload riscrive tutte le note della richiesta, anche per un cambio di stato, quindi la nota di un collega poteva sparire.
    decisione
    L’adattatore Postgres standard, con un pool di connessioni vero, così una transazione resta su una connessione. Dentro la transazione la riga della richiesta si blocca con SELECT ... FOR UPDATE prima di ogni cambio di stato.
    conseguenze
    Due cambi di stato insieme danno un successo e un conflitto esplicito, mai una sovrascrittura silenziosa. Ogni scrittura costa un’istruzione in più.

Problemi risolti

Il codice è del cliente, quindi niente estratti: cosa non andava e com’è adesso.

  • La bacheca del CRM leggeva tutto

    Caricava tutte le richieste, chiuse comprese, con note, relazioni e storico, e faceva una query delle note per ogni richiesta: un N+1 che sarebbe cresciuto con l’archivio. Ora la bacheca legge solo le aperte, il dettaglio di una richiesta si legge quando lo si apre, e i filtri hanno i loro indici.

    query per caricare la pagina

    prima
    108
    dopo
    12

    documento l’HTML, non compresso

    prima
    1.025 KB
    dopo
    389 KB

    tempo server di sviluppo

    prima
    1.031 ms
    dopo
    263 ms

    Su una copia del database con 94 richieste, con lo stesso server di sviluppo prima sul commit di partenza e poi sul ramo; le query contate dalle statistiche di Postgres, prima e dopo un solo caricamento.

  • Un errore finiva in cache

    Alcune letture in cache, se il database non rispondeva, restituivano un valore di ripiego, e la cache lo salvava come una risposta vera: un solo intoppo lasciava il menu vuoto in tutto il sito. Andava risolto prima di portare la scadenza da un’ora a una settimana.

    prima
    un’ora il ripiego restava in cache come un dato vero
    dopo
    una richiesta il ripiego sta fuori dalla cache, e la visita dopo riprova
  • Una foto svegliava il database

    Le immagini passavano dal proxy dei file di Payload, che interroga il database a ogni richiesta e risponde con una cache valida solo per il browser: la CDN non la teneva, e bastava un visitatore con la cache vuota.

    prima
    due query per ogni immagine chiesta, dal proxy di Payload
    dopo
    nessuna query l’indirizzo punta al Blob, servito dalla CDN

Come lo verifico

  • Un test legge il codice e fallisce se una lettura in cache dichiara un tag che nessun salvataggio invalida
  • Dei controlli leggono la build: ogni pagina pubblica statica, con metadati e dati strutturati validi
  • Ogni migration provata su, giù e di nuovo su, in un database usa e getta
  • In produzione, un backup prima e uno dopo ogni migration, con le righe contate tabella per tabella
  • I caricamenti di contenuti in produzione partono a secco e non sovrascrivono ciò che il cliente ha cambiato dal pannello
  • Ogni ottimizzazione misurata due volte, sul commit di partenza e sul ramo, con lo stesso server e gli stessi dati

Lo stato

In produzione dal 1 ottobre 2026. I domini storici del Polo portano al sito nuovo con redirect 301, ogni vecchia pagina verso quella che le corrisponde, provati in produzione prima del cambio dei DNS.

Cosa ho imparato

Il vincolo del cliente, nessun servizio a pagamento, ha deciso l’architettura più di qualunque mia preferenza tecnica. Una cache che segue i salvataggi sposta il rischio: un’invalidazione dimenticata non si vede più per un’ora ma per una settimana, e per questo il test sui tag e la rete notturna sono arrivati insieme alla scadenza lunga. E ho imparato a misurare sulla piattaforma, con il traffico vero: in locale il 404 era giusto e su Vercel usciva 200, e le mie prove a raffica trovavano sempre un’istanza calda, che un crawler di passaggio non trova.