{
  "slug": "guidance",
  "title": "Application for a guidance centre",
  "label": "guidance centre",
  "kind": "client",
  "tagline": "Site, CMS and CRM for Polo Fabricamente in a single Next.js app with Payload.",
  "summary": "Site, CMS and CRM for Polo Fabricamente in a single Next.js app with Payload, with a cache that updates on every save rather than on a timer.",
  "stack": [
    "Next.js",
    "Payload CMS",
    "PostgreSQL"
  ],
  "status": "in-production",
  "lead": "The site, CMS and CRM of Polo Fabricamente, a guidance centre for online degrees. I designed and built it on my own.",
  "facts": [
    {
      "name": "client",
      "value": "Polo Fabricamente",
      "href": null
    },
    {
      "name": "site",
      "value": "orientamentostudenti.it",
      "href": "https://www.orientamentostudenti.it"
    },
    {
      "name": "stack",
      "value": "Next.js 16, Payload CMS 3, PostgreSQL on Neon, Vercel",
      "href": null
    }
  ],
  "caseStudy": {
    "sections": [
      {
        "id": "problem",
        "title": "The problem",
        "paragraphs": [
          "It started as a landing page and grew into a full management system: the site with the course catalogue, built to be found by search engines and AI assistants, the CMS for the content, and the CRM where the advisors work the leads, including the ones that come in from Meta ads.",
          "The constraint was no paid services, so everything runs on free plans. The database suspends after five minutes without connections and has a monthly budget of compute hours: what counts is how often it wakes up, not how many queries it gets. The site had to stay up to date and wake it as little as possible.",
          "The requirements, the architecture and the decisions are mine; I wrote the code with the help of Claude Code, one piece of work at a time, measuring before and after."
        ],
        "body": null,
        "note": null,
        "subsection": null
      },
      {
        "id": "architecture",
        "title": "How it’s built",
        "paragraphs": [
          "A single Next.js app with Payload mounted inside: site, CRM and CMS ship together and read the same database."
        ],
        "body": {
          "kind": "systemMap",
          "label": "The architecture: who comes in, Vercel’s proxy and CDN, one Next.js app with site, CRM and CMS on Payload, the database and the external services",
          "clients": {
            "label": "who comes in",
            "parts": [
              {
                "name": "Visitors",
                "text": null,
                "onPhone": null,
                "focus": false
              },
              {
                "name": "Search engines and AI assistants",
                "text": null,
                "onPhone": {
                  "name": "Engines and AI",
                  "text": null
                },
                "focus": false
              },
              {
                "name": "The Polo’s staff",
                "text": null,
                "onPhone": {
                  "name": "Staff",
                  "text": null
                },
                "focus": false
              }
            ]
          },
          "platform": {
            "name": "Vercel",
            "entry": [
              {
                "name": "proxy.ts",
                "text": "decides the 404 before rendering",
                "onPhone": null,
                "focus": false
              },
              {
                "name": "CDN",
                "text": "the public pages, prerendered",
                "onPhone": null,
                "focus": false
              }
            ],
            "beside": [
              {
                "name": "Vercel Blob",
                "text": "images and PDFs, served by the CDN",
                "onPhone": {
                  "name": null,
                  "text": "images and PDFs"
                },
                "focus": false
              },
              {
                "name": "nightly cron jobs",
                "text": "Meta leads, alerts, cleanup and warm-up",
                "onPhone": {
                  "name": null,
                  "text": "Meta, alerts, cleanup"
                },
                "focus": false
              }
            ],
            "app": {
              "name": "Next.js · one app, one deploy",
              "faces": [
                {
                  "name": "/",
                  "text": "public site",
                  "onPhone": {
                    "name": null,
                    "text": "the public site"
                  },
                  "focus": false
                },
                {
                  "name": "/dashboard",
                  "text": "CRM",
                  "onPhone": {
                    "name": null,
                    "text": "the CRM"
                  },
                  "focus": false
                },
                {
                  "name": "/admin",
                  "text": "CMS",
                  "onPhone": {
                    "name": null,
                    "text": "the CMS"
                  },
                  "focus": false
                }
              ],
              "layers": [
                {
                  "name": "Query layer",
                  "text": "the reads, cached with their tags",
                  "onPhone": null,
                  "focus": false
                },
                {
                  "name": "Payload CMS",
                  "text": "Local API, in the same process",
                  "onPhone": null,
                  "focus": true
                }
              ]
            }
          },
          "database": {
            "name": "PostgreSQL on Neon",
            "text": "in Frankfurt, like the functions",
            "onPhone": null,
            "focus": false
          },
          "services": {
            "label": "external services",
            "parts": [
              {
                "name": "Meta",
                "text": "leads, via webhook",
                "onPhone": {
                  "name": null,
                  "text": "leads"
                },
                "focus": false
              },
              {
                "name": "Resend",
                "text": "email",
                "onPhone": null,
                "focus": false
              },
              {
                "name": "IndexNow",
                "text": "changed pages to search engines",
                "onPhone": {
                  "name": null,
                  "text": "search engines"
                },
                "focus": false
              }
            ]
          }
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "request-path",
        "title": "Where a request stops",
        "paragraphs": [
          "Each tier answers on its own when it can and hands on to the next only when it has to. The database comes last."
        ],
        "body": {
          "kind": "request",
          "layers": [
            {
              "name": "Browser",
              "role": "the request",
              "note": {
                "status": null,
                "code": "GET /corsi/economia-aziendale",
                "text": null
              },
              "focus": false,
              "back": null
            },
            {
              "name": "proxy.ts",
              "role": "404 before rendering",
              "note": {
                "status": null,
                "code": null,
                "text": "only addresses that may not exist, like a course page"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "Vercel CDN",
              "role": "prerendered pages",
              "note": {
                "status": null,
                "code": null,
                "text": "answers on its own if it has the page"
              },
              "focus": false,
              "back": {
                "code": "HIT",
                "text": "no render, no query",
                "onPhone": "the page goes back to the browser from here"
              }
            },
            {
              "name": "Next.js function",
              "role": "renders the page",
              "note": {
                "status": null,
                "code": null,
                "text": "only if the page is missing or was invalidated"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "Next cache",
              "role": "reads, with their tags",
              "note": {
                "status": null,
                "code": null,
                "text": "only the reads that are missing"
              },
              "focus": false,
              "back": null
            },
            {
              "name": "Neon",
              "role": "PostgreSQL, free plan",
              "note": {
                "status": null,
                "code": null,
                "text": "suspends after 5 idle minutes; the request that wakes it waits"
              },
              "focus": false,
              "back": null
            }
          ],
          "label": null
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "save-to-page",
        "title": "From save to page",
        "paragraphs": [
          "The cache follows saves, not the clock. At first pages expired every hour, and every expiry could wake the database; now a page is regenerated when an editor changes something, and a nightly cron is the safety net."
        ],
        "body": {
          "kind": "steps",
          "flow": {
            "label": "on every save",
            "steps": [
              {
                "title": "The editor saves",
                "code": null,
                "text": "the Rome office, from the admin panel",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "Commit on Neon",
                "code": null,
                "text": "in one transaction",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "Payload hook",
                "code": null,
                "text": "the panel answers “saved” without waiting",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "After the response",
                "code": null,
                "text": "invalidates the office’s tags and the /roma page",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "IndexNow",
                "code": null,
                "text": "tells search engines which pages changed",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "The next visit",
                "code": "GET /roma",
                "text": "with the new data",
                "date": null,
                "outcome": null,
                "outside": null
              }
            ],
            "caption": "within a second, measured in production",
            "base": null
          },
          "quiet": {
            "label": "every night",
            "steps": [
              {
                "title": "The 3 a.m. cron",
                "code": null,
                "text": "wakes the database anyway for the cleanup",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "Purges and re-warms the pages",
                "code": null,
                "text": "in the window where the database is already awake",
                "date": null,
                "outcome": null,
                "outside": null
              },
              {
                "title": "The safety net",
                "code": null,
                "text": "a forgotten tag costs a day at most, not a week",
                "date": null,
                "outcome": null,
                "outside": null
              }
            ],
            "caption": null,
            "base": null
          }
        },
        "note": "One cache profile, at one week: a page expires with the shortest of its reads, so a shorter profile in the layout would shorten the whole site.",
        "subsection": null
      },
      {
        "id": "not-found",
        "title": "The 404 is decided before rendering",
        "paragraphs": [
          "A course page that doesn’t exist used to answer 200 with a “not found” page, a soft 404: with streaming, the status goes out with the first bytes, before the page finds out the slug isn’t there. Now the proxy decides, before rendering."
        ],
        "body": {
          "kind": "decisionFlow",
          "label": "The proxy asks the registry whether the slug exists; if not, it answers 404 without rendering the page",
          "start": {
            "name": "GET /corsi/{slug}",
            "text": "a course page"
          },
          "questions": [
            {
              "name": "proxy.ts",
              "question": "is it an address to check?",
              "on": "yes",
              "exit": {
                "answer": "no",
                "text": "goes on to the page"
              }
            },
            {
              "name": "Slug registry",
              "question": "does the slug exist, and is it public?",
              "on": "no",
              "exit": {
                "answer": "no answer",
                "text": "goes through anyway, fail-open"
              }
            }
          ],
          "end": {
            "status": 404,
            "text": "no render, no query",
            "onPhone": null
          },
          "branch": {
            "answer": "yes",
            "outcome": {
              "status": 200,
              "text": "the page, from the CDN",
              "onPhone": "the page from the CDN"
            }
          }
        },
        "note": "The registry reads from a cache shared across instances, so the check doesn’t wake the database. If it doesn’t answer, the request goes through: a rare soft 404 is better than a site that refuses everything.",
        "subsection": null
      },
      {
        "id": "decisions",
        "title": "Decisions",
        "paragraphs": [],
        "body": {
          "kind": "decisions",
          "records": [
            {
              "number": 1,
              "status": "accepted",
              "title": "Payload, inside the app",
              "context": "The project needed a CMS for the content and a panel for the staff, with no paid services.",
              "decision": "Payload CMS mounted in the same Next.js app, on the site’s own database. Pages read through the Local API, in the same process, with no HTTP calls.",
              "consequences": "Everything lives in free plans and in the repo: it ships with the site, can be customised end to end, and no external vendor can change its prices or disappear. One login covers the CMS and the CRM. In exchange, upgrading Payload means upgrading the app."
            },
            {
              "number": 2,
              "status": "accepted",
              "title": "Fully static pages",
              "context": "Search engines and AI assistants read the HTML, and many don’t run JavaScript. With streaming, the pages’ <main> held a loading placeholder and the content arrived later, hidden at the end of the document.",
              "decision": "Every public page is fully prerendered, with no page-level Suspense. The course page shows everything on one page with a table of contents, and the part that changes for staff is fetched by the browser after load.",
              "consequences": "People, search engines and assistants get the same HTML from the CDN, and none of them wakes the database. A test keeps page-level Suspense from coming back."
            },
            {
              "number": 3,
              "status": "accepted",
              "title": "Real transactions, locked rows",
              "context": "Several advisors work the same leads at the same time. The first database adapter, the serverless one, sent begin, write and commit as separate queries, possibly on different connections; and a Payload save rewrites all of a lead’s notes, even for a status change, so a colleague’s note could vanish.",
              "decision": "The standard Postgres adapter with a real connection pool, so a transaction stays on one connection. Inside it, the lead’s row is locked with SELECT ... FOR UPDATE before every status change.",
              "consequences": "Two status changes at once give one success and one explicit conflict, never a silent overwrite. Every write costs one more statement."
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "solved-problems",
        "title": "Problems solved",
        "paragraphs": [
          "The code belongs to the client, so no excerpts: what was wrong and how it works now."
        ],
        "body": {
          "kind": "beforeAfter",
          "problems": [
            {
              "title": "The CRM board read everything",
              "text": "It loaded every lead, closed ones included, with notes, relations and history, plus one notes query per lead: an N+1 that would have grown with the archive. Now the board reads only open leads, a lead’s details load when you open it, and the filters have their indexes.",
              "comparison": {
                "form": "bars",
                "measures": [
                  {
                    "label": "queries",
                    "note": "to load the page",
                    "before": 108,
                    "after": 12,
                    "unit": null
                  },
                  {
                    "label": "document",
                    "note": "the HTML, uncompressed",
                    "before": 1025,
                    "after": 389,
                    "unit": "KB"
                  },
                  {
                    "label": "time",
                    "note": "development server",
                    "before": 1031,
                    "after": 263,
                    "unit": "ms"
                  }
                ],
                "method": "On a copy of the database with 94 leads, same development server, first on the base commit and then on the branch; queries counted from Postgres’s statistics around a single page load."
              }
            },
            {
              "title": "A failure got cached",
              "text": "Some cached reads returned a fallback value when the database failed, and the cache stored it like a real answer: a single blip left the menu empty across the whole site. It had to be fixed before raising the expiry from an hour to a week.",
              "comparison": {
                "form": "sides",
                "before": {
                  "value": "an hour",
                  "text": "the fallback stayed cached like real data"
                },
                "after": {
                  "value": "one request",
                  "text": "the fallback lives outside the cache, and the next visit retries"
                }
              }
            },
            {
              "title": "A photo woke the database",
              "text": "Images went through Payload’s file proxy, which queries the database on every request and answers with a browser-only cache header: the CDN didn’t keep it, and one visitor with a cold cache was enough.",
              "comparison": {
                "form": "sides",
                "before": {
                  "value": "two queries",
                  "text": "for every image requested, from Payload’s proxy"
                },
                "after": {
                  "value": "no queries",
                  "text": "the URL points to the Blob store, served by the CDN"
                }
              }
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "verification",
        "title": "How I verify it",
        "paragraphs": [],
        "body": {
          "kind": "checks",
          "checks": [
            "A test reads the source and fails if a cached read declares a tag that no save invalidates",
            "Checks read the build output: every public page static, with valid metadata and structured data",
            "Every migration run up, down and up again on a throwaway database",
            "In production, a backup before and after every migration, with rows counted table by table",
            "Content loads in production run dry first and never overwrite what the client changed in the panel",
            "Every optimisation measured twice, on the base commit and on the branch, with the same server and data"
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "status",
        "title": "Status",
        "paragraphs": [
          "In production since 1 October 2026. The Polo’s old domains redirect to the new site with 301s, each old page to its counterpart, tested in production before the DNS switch."
        ],
        "body": null,
        "note": null,
        "subsection": null
      },
      {
        "id": "lessons",
        "title": "What I learned",
        "paragraphs": [
          "The client’s constraint, no paid services, shaped the architecture more than any technical preference of mine. A cache that follows saves moves the risk: a forgotten invalidation no longer shows for an hour but for a week, which is why the tag test and the nightly safety net came together with the long expiry. And I learned to measure on the platform, with real traffic: locally the 404 was right and on Vercel it came out as a 200, and my bursts of test requests always hit a warm instance, which a passing crawler never does."
        ],
        "body": null,
        "note": null,
        "subsection": null
      }
    ]
  },
  "_links": {
    "self": {
      "href": "/en/projects/guidance"
    },
    "alternate": {
      "href": "/it/projects/guidance",
      "hreflang": "it"
    },
    "up": {
      "href": "/en/projects"
    },
    "next": {
      "href": "/en/projects/driving-school",
      "title": "Driving school management system"
    }
  }
}
