Skip to content

GET/en/projects/guidance

Application for a guidance centre

The site, CMS and CRM of Polo Fabricamente, a guidance centre for online degrees. I designed and built it on my own.

client
Polo Fabricamente
stack
Next.js 16, Payload CMS 3, PostgreSQL on Neon, Vercel
status
In production

The problem

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.

How it’s built

A single Next.js app with Payload mounted inside: site, CRM and CMS ship together and read the same database.

  • who comes in

    • Visitors

    • Engines and AI

    • Staff

  • Vercel

      • proxy.ts decides the 404 before rendering

      • CDN the public pages, prerendered

    • Next.js · one app, one deploy

      • / the public site
      • /dashboard the CRM
      • /admin the CMS
      • Query layer the reads, cached with their tags

      • Payload CMS Local API, in the same process

    • Vercel Blob images and PDFs

    • nightly cron jobs Meta, alerts, cleanup

  • PostgreSQL on Neon in Frankfurt, like the functions

  • external services

    • Meta leads

    • Resend email

    • IndexNow search engines

Where a request stops

Each tier answers on its own when it can and hands on to the next only when it has to. The database comes last.

  1. Browser the request

    GET /corsi/economia-aziendale

  2. proxy.ts 404 before rendering

    only addresses that may not exist, like a course page

  3. Vercel CDN prerendered pages

    answers on its own if it has the page

    HIT the page goes back to the browser from here

  4. Next.js function renders the page

    only if the page is missing or was invalidated

  5. Next cache reads, with their tags

    only the reads that are missing

  6. Neon PostgreSQL, free plan

    suspends after 5 idle minutes; the request that wakes it waits

From save to page

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.

on every save

  1. The editor saves the Rome office, from the admin panel
  2. Commit on Neon in one transaction
  3. Payload hook the panel answers “saved” without waiting
  4. After the response invalidates the office’s tags and the /roma page
  5. IndexNow tells search engines which pages changed
  6. The next visit GET /roma with the new data

within a second, measured in production

every night

  1. The 3 a.m. cron wakes the database anyway for the cleanup
  2. Purges and re-warms the pages in the window where the database is already awake
  3. The safety net a forgotten tag costs a day at most, not a week

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.

The 404 is decided before rendering

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.

  1. GET /corsi/{slug} a course page

  2. proxy.ts is it an address to check? yes

    no: goes on to the page

  3. Slug registry does the slug exist, and is it public? no

    yes: 200, the page from the CDN

    no answer: goes through anyway, fail-open

  4. 404 no render, no query

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.

Decisions

  • ADR 001 Accepted

    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.
  • ADR 002 Accepted

    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.
  • ADR 003 Accepted

    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.

Problems solved

The code belongs to the client, so no excerpts: what was wrong and how it works now.

  • The CRM board read everything

    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.

    queries to load the page

    before
    108
    after
    12

    document the HTML, uncompressed

    before
    1,025 KB
    after
    389 KB

    time development server

    before
    1,031 ms
    after
    263 ms

    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.

  • A failure got cached

    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.

    before
    an hour the fallback stayed cached like real data
    after
    one request the fallback lives outside the cache, and the next visit retries
  • A photo woke the database

    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.

    before
    two queries for every image requested, from Payload’s proxy
    after
    no queries the URL points to the Blob store, served by the CDN

How I verify it

  • 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

Status

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.

What I learned

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.