{
  "slug": "guidance",
  "title": "Applicazione per un centro di orientamento",
  "label": "centro di orientamento",
  "kind": "client",
  "tagline": "Sito, CMS e CRM di Polo Fabricamente in un’unica app Next.js con Payload.",
  "summary": "Sito, CMS e CRM di Polo Fabricamente in un’unica app Next.js con Payload, con una cache che si aggiorna a ogni salvataggio invece che a tempo.",
  "stack": [
    "Next.js",
    "Payload CMS",
    "PostgreSQL"
  ],
  "status": "in-production",
  "lead": "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.",
  "facts": [
    {
      "name": "cliente",
      "value": "Polo Fabricamente",
      "href": null
    },
    {
      "name": "sito",
      "value": "orientamentostudenti.it",
      "href": "https://www.orientamentostudenti.it"
    },
    {
      "name": "stack",
      "value": "Next.js 16, Payload CMS 3, PostgreSQL su Neon, Vercel",
      "href": null
    }
  ],
  "caseStudy": {
    "sections": [
      {
        "id": "problem",
        "title": "Il problema",
        "paragraphs": [
          "È 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."
        ],
        "body": null,
        "note": null,
        "subsection": null
      },
      {
        "id": "architecture",
        "title": "Com’è fatto",
        "paragraphs": [
          "Un’app Next.js sola, con Payload montato dentro: sito, CRM e CMS vanno in produzione insieme e leggono lo stesso database."
        ],
        "body": {
          "kind": "systemMap",
          "label": "L’architettura: chi entra, il proxy e la CDN di Vercel, un’app Next.js con sito, CRM e CMS su Payload, il database e i servizi esterni",
          "clients": {
            "label": "chi entra",
            "parts": [
              {
                "name": "Visitatori",
                "text": null,
                "onPhone": null,
                "focus": false
              },
              {
                "name": "Motori e assistenti AI",
                "text": null,
                "onPhone": {
                  "name": "Motori e AI",
                  "text": null
                },
                "focus": false
              },
              {
                "name": "Staff del Polo",
                "text": null,
                "onPhone": {
                  "name": "Staff",
                  "text": null
                },
                "focus": false
              }
            ]
          },
          "platform": {
            "name": "Vercel",
            "entry": [
              {
                "name": "proxy.ts",
                "text": "decide il 404 prima del render",
                "onPhone": null,
                "focus": false
              },
              {
                "name": "CDN",
                "text": "le pagine pubbliche, statiche",
                "onPhone": null,
                "focus": false
              }
            ],
            "beside": [
              {
                "name": "Vercel Blob",
                "text": "immagini e PDF, servite dalla CDN",
                "onPhone": {
                  "name": null,
                  "text": "immagini e PDF"
                },
                "focus": false
              },
              {
                "name": "cron notturni",
                "text": "lead di Meta, avvisi, pulizia e riscaldamento",
                "onPhone": {
                  "name": null,
                  "text": "Meta, avvisi, pulizia"
                },
                "focus": false
              }
            ],
            "app": {
              "name": "Next.js · un’app, un deploy",
              "faces": [
                {
                  "name": "/",
                  "text": "sito pubblico",
                  "onPhone": {
                    "name": null,
                    "text": "il sito pubblico"
                  },
                  "focus": false
                },
                {
                  "name": "/dashboard",
                  "text": "CRM",
                  "onPhone": {
                    "name": null,
                    "text": "il CRM"
                  },
                  "focus": false
                },
                {
                  "name": "/admin",
                  "text": "CMS",
                  "onPhone": {
                    "name": null,
                    "text": "il CMS"
                  },
                  "focus": false
                }
              ],
              "layers": [
                {
                  "name": "Strato delle query",
                  "text": "le letture, in cache con i loro tag",
                  "onPhone": null,
                  "focus": false
                },
                {
                  "name": "Payload CMS",
                  "text": "Local API, nello stesso processo",
                  "onPhone": null,
                  "focus": true
                }
              ]
            }
          },
          "database": {
            "name": "PostgreSQL su Neon",
            "text": "a Francoforte, come le funzioni",
            "onPhone": null,
            "focus": false
          },
          "services": {
            "label": "servizi esterni",
            "parts": [
              {
                "name": "Meta",
                "text": "lead, via webhook",
                "onPhone": {
                  "name": null,
                  "text": "lead"
                },
                "focus": false
              },
              {
                "name": "Resend",
                "text": "email",
                "onPhone": null,
                "focus": false
              },
              {
                "name": "IndexNow",
                "text": "pagine cambiate ai motori",
                "onPhone": {
                  "name": null,
                  "text": "motori"
                },
                "focus": false
              }
            ]
          }
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "request-path",
        "title": "Dove si ferma una richiesta",
        "paragraphs": [
          "Ogni livello risponde da solo quando può, e passa al successivo solo se deve. Il database si tocca per ultimo."
        ],
        "body": {
          "kind": "request",
          "layers": [
            {
              "name": "Browser",
              "role": "la richiesta",
              "note": {
                "status": null,
                "code": "GET /corsi/economia-aziendale",
                "text": null
              },
              "focus": false,
              "back": null
            },
            {
              "name": "proxy.ts",
              "role": "404 prima del render",
              "note": {
                "status": null,
                "code": null,
                "text": "solo gli indirizzi che possono non esistere, come una scheda corso"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "CDN Vercel",
              "role": "pagine prerenderizzate",
              "note": {
                "status": null,
                "code": null,
                "text": "risponde da sola se ha la pagina"
              },
              "focus": false,
              "back": {
                "code": "HIT",
                "text": "senza render né query",
                "onPhone": "la pagina torna al browser da qui"
              }
            },
            {
              "name": "Funzione Next.js",
              "role": "rende la pagina",
              "note": {
                "status": null,
                "code": null,
                "text": "solo se la pagina manca o è stata invalidata"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "Cache di Next",
              "role": "le letture, con i loro tag",
              "note": {
                "status": null,
                "code": null,
                "text": "solo le letture che mancano"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "Neon",
              "role": "PostgreSQL, piano gratuito",
              "note": {
                "status": null,
                "code": null,
                "text": "si sospende dopo 5 minuti; la richiesta che lo sveglia aspetta"
              },
              "focus": false,
              "back": null
            }
          ],
          "label": null
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "save-to-page",
        "title": "Dal salvataggio alla pagina",
        "paragraphs": [
          "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."
        ],
        "body": {
          "kind": "steps",
          "flow": {
            "label": "a ogni salvataggio",
            "steps": [
              {
                "title": "Il redattore salva",
                "code": null,
                "text": "la sede di Roma, dal pannello",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "Commit su Neon",
                "code": null,
                "text": "in una transazione",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "Hook di Payload",
                "code": null,
                "text": "il pannello risponde «salvato» senza aspettare",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "Dopo la risposta",
                "code": null,
                "text": "invalida i tag della sede e la pagina /roma",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "IndexNow",
                "code": null,
                "text": "avvisa i motori delle pagine cambiate",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "La visita dopo",
                "code": "GET /roma",
                "text": "con il dato nuovo",
                "date": null,
                "outcome": null,
                "outside": null
              }
            ],
            "caption": "entro un secondo, misurato in produzione",
            "base": null
          },
          "quiet": {
            "label": "ogni notte",
            "steps": [
              {
                "title": "Il cron delle 3",
                "code": null,
                "text": "sveglia comunque il database per la pulizia",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "Purga e riscalda le pagine",
                "code": null,
                "text": "nella finestra in cui il database è già sveglio",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "La rete di sicurezza",
                "code": null,
                "text": "un tag dimenticato costa al più un giorno, non una settimana",
                "date": null,
                "outcome": null,
                "outside": null
              }
            ],
            "caption": null,
            "base": null
          }
        },
        "note": "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.",
        "subsection": null
      },
      {
        "id": "not-found",
        "title": "Il 404 si decide prima del render",
        "paragraphs": [
          "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."
        ],
        "body": {
          "kind": "decisionFlow",
          "label": "Il proxy chiede al registro se lo slug esiste; se no risponde 404 senza rendere la pagina",
          "start": {
            "name": "GET /corsi/{slug}",
            "text": "una scheda corso"
          },
          "questions": [
            {
              "name": "proxy.ts",
              "question": "è un indirizzo da verificare?",
              "on": "sì",
              "exit": {
                "answer": "no",
                "text": "passa alla pagina"
              }
            },
            {
              "name": "Registro degli slug",
              "question": "lo slug esiste ed è pubblico?",
              "on": "no",
              "exit": {
                "answer": "non risponde",
                "text": "passa lo stesso, fail-open"
              }
            }
          ],
          "end": {
            "status": 404,
            "text": "nessun render, nessuna query",
            "onPhone": null
          },
          "branch": {
            "answer": "sì",
            "outcome": {
              "status": 200,
              "text": "la pagina, dalla CDN",
              "onPhone": "la pagina dalla CDN"
            }
          }
        },
        "note": "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.",
        "subsection": null
      },
      {
        "id": "decisions",
        "title": "Decisioni",
        "paragraphs": [],
        "body": {
          "kind": "decisions",
          "records": [
            {
              "number": 1,
              "status": "accepted",
              "title": "Payload, dentro l’app",
              "context": "Servivano un CMS per i contenuti e un pannello per lo staff, senza servizi a pagamento.",
              "decision": "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.",
              "consequences": "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."
            },
            {
              "number": 2,
              "status": "accepted",
              "title": "Pagine statiche per intero",
              "context": "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.",
              "decision": "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.",
              "consequences": "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."
            },
            {
              "number": 3,
              "status": "accepted",
              "title": "Transazioni vere e righe bloccate",
              "context": "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.",
              "decision": "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.",
              "consequences": "Due cambi di stato insieme danno un successo e un conflitto esplicito, mai una sovrascrittura silenziosa. Ogni scrittura costa un’istruzione in più."
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "solved-problems",
        "title": "Problemi risolti",
        "paragraphs": [
          "Il codice è del cliente, quindi niente estratti: cosa non andava e com’è adesso."
        ],
        "body": {
          "kind": "beforeAfter",
          "problems": [
            {
              "title": "La bacheca del CRM leggeva tutto",
              "text": "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.",
              "comparison": {
                "form": "bars",
                "measures": [
                  {
                    "label": "query",
                    "note": "per caricare la pagina",
                    "before": 108,
                    "after": 12,
                    "unit": null
                  },
                  {
                    "label": "documento",
                    "note": "l’HTML, non compresso",
                    "before": 1025,
                    "after": 389,
                    "unit": "KB"
                  },
                  {
                    "label": "tempo",
                    "note": "server di sviluppo",
                    "before": 1031,
                    "after": 263,
                    "unit": "ms"
                  }
                ],
                "method": "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."
              }
            },
            {
              "title": "Un errore finiva in cache",
              "text": "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.",
              "comparison": {
                "form": "sides",
                "before": {
                  "value": "un’ora",
                  "text": "il ripiego restava in cache come un dato vero"
                },
                "after": {
                  "value": "una richiesta",
                  "text": "il ripiego sta fuori dalla cache, e la visita dopo riprova"
                }
              }
            },
            {
              "title": "Una foto svegliava il database",
              "text": "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.",
              "comparison": {
                "form": "sides",
                "before": {
                  "value": "due query",
                  "text": "per ogni immagine chiesta, dal proxy di Payload"
                },
                "after": {
                  "value": "nessuna query",
                  "text": "l’indirizzo punta al Blob, servito dalla CDN"
                }
              }
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "verification",
        "title": "Come lo verifico",
        "paragraphs": [],
        "body": {
          "kind": "checks",
          "checks": [
            "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"
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "status",
        "title": "Lo stato",
        "paragraphs": [
          "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."
        ],
        "body": null,
        "note": null,
        "subsection": null
      },
      {
        "id": "lessons",
        "title": "Cosa ho imparato",
        "paragraphs": [
          "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."
        ],
        "body": null,
        "note": null,
        "subsection": null
      }
    ]
  },
  "_links": {
    "self": {
      "href": "/it/projects/guidance"
    },
    "alternate": {
      "href": "/en/projects/guidance",
      "hreflang": "en"
    },
    "up": {
      "href": "/it/projects"
    },
    "next": {
      "href": "/it/projects/driving-school",
      "title": "Gestionale per autoscuole"
    }
  }
}
