G.STANCUTA
Pubblicato · 2026 · 02 · 167 min di lettura

Costruisci la Dashboard di cui Hai Davvero Bisogno

  • next.js
  • prisma
  • data
  • architecture

Gli strumenti di amministrazione preconfezionati costringono la tua azienda nel loro modello di dati. Un livello sottile e onesto che legge il tuo database e mostra le poche decisioni che contano richiede giorni, non mesi.

Ogni prodotto SaaS include una dashboard. La maggior parte di esse sono sbagliate. Mostrano le metriche che il fornitore ha deciso fossero importanti, nel layout che il fornitore ha trovato più facile da costruire, con la cadenza che il fornitore ha trovato meno costosa da calcolare. La tua azienda è diversa. Le tue decisioni sono diverse. Nel momento in cui accetti un pannello di amministrazione di terze parti come vista operativa, inizi a prendere decisioni attraverso la prospettiva di qualcun altro.

La buona notizia: una dashboard personalizzata non è un progetto da sei mesi. Con i server component di Next.js e Prisma, puoi pubblicare una vista operativa in sola lettura del tuo database in due o tre giorni di lavoro concentrato. Sarà più veloce da caricare, costerà meno e ti dirà esattamente quello che devi sapere.

Diagramma isometrico di un livello dati sottile che interroga un database e alimenta un pannello di metriche minimale
Un livello di lettura diretto: il tuo database, il tuo modello, i tuoi numeri.

La Trappola degli Strumenti Preconfezionati

Strumenti come Retool, Metabase e Forest Admin sono genuinamente utili per certi team. Sono fantastici se il tuo modello di dati corrisponde alle loro ipotesi e il tuo team è contento di operare nel loro modello di permessi. Ma la maggior parte delle aziende di prodotto raggiunge il limite molto in fretta. Non puoi facilmente unire tre tabelle interne con logica di business personalizzata. Non puoi incorporare dati websocket in tempo reale accanto a una query di aggregazione lenta. Non puoi applicare le stesse regole di autenticazione che usi nel tuo prodotto. Ogni soluzione alternativa aggiunge un ulteriore livello di indirezione tra una decisione e i dati che la guidano.

Il problema più profondo è la proprietà. Quando la tua vista operativa vive in uno strumento di terze parti, il tuo team impara quello strumento invece del tuo sistema. I nuovi ingegneri passano il tempo a configurare widget invece di capire il dominio. La dashboard diventa una scatola nera di cui nessuno si fida completamente.

Una dashboard è utile solo se chi la legge si fida di ogni numero che vi appare.

Come Appare un Livello Sottile e Onesto

L'alternativa non è un framework di amministrazione completo. È una piccola applicazione Next.js orientata alla lettura che si trova nel tuo monorepo, condivide il tuo client Prisma e mostra solo la manciata di metriche e tabelle su cui il tuo team operativo agisce effettivamente. Nessun widget builder drag-and-drop. Nessun marketplace di plugin. Solo server component che eseguono query e restituiscono dati.

L'architettura è deliberatamente semplice. Un server component esegue una query Prisma. Il risultato è tipizzato. Il component lo renderizza. Non c'è nessuna rotta API in mezzo perché non ne hai bisogno. I server component sono l'API. Questo significa che i dati non vengono mai serializzati attraverso un confine client a meno che tu non scelga esplicitamente di rendere qualcosa interattivo.

  • Nessun recupero dati lato client per i percorsi di lettura. I server component eliminano lo spinner di caricamento per la maggior parte delle viste.
  • La tua autenticazione, non la loro. La dashboard si trova dietro lo stesso middleware del tuo prodotto.
  • Query tipizzate. Il client generato da Prisma significa che una modifica allo schema rompe la dashboard in fase di compilazione, non in produzione.
  • Incrementale. Inizia con un pannello di metriche. Aggiungi sezioni quando qualcuno le richiede.

Un Vero Server Component: Pannello di Metriche e Tabella

Ecco come appare una pagina pratica di panoramica dei ricavi. Il component esegue due query Prisma, calcola un semplice delta e renderizza inline. La tabella sottostante è paginata a livello di database, non in memoria.

tsx
// src/app/dashboard/page.tsx
import { prisma } from '@/lib/prisma';
import { formatCurrency, formatPercent } from '@/lib/format';

async function getRevenueStats() {
  const now = new Date();
  const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
  const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);

  const [thisMonth, lastMonth] = await Promise.all([
    prisma.order.aggregate({
      where: {
        createdAt: { gte: startOfMonth },
        status: 'completed',
      },
      _sum: { amountCents: true },
      _count: true,
    }),
    prisma.order.aggregate({
      where: {
        createdAt: { gte: startOfLastMonth, lt: startOfMonth },
        status: 'completed',
      },
      _sum: { amountCents: true },
      _count: true,
    }),
  ]);

  const thisTotal = thisMonth._sum.amountCents ?? 0;
  const lastTotal = lastMonth._sum.amountCents ?? 0;
  const delta = lastTotal === 0 ? null : (thisTotal - lastTotal) / lastTotal;

  return {
    thisMonthRevenue: thisTotal,
    thisMonthOrders: thisMonth._count,
    revenueDelta: delta,
  };
}

async function getRecentOrders(page = 0, pageSize = 20) {
  const [orders, total] = await Promise.all([
    prisma.order.findMany({
      where: { status: 'completed' },
      orderBy: { createdAt: 'desc' },
      skip: page * pageSize,
      take: pageSize,
      select: {
        id: true,
        createdAt: true,
        amountCents: true,
        customer: { select: { email: true } },
      },
    }),
    prisma.order.count({ where: { status: 'completed' } }),
  ]);
  return { orders, total };
}

export default async function DashboardPage() {
  const [stats, { orders, total }] = await Promise.all([
    getRevenueStats(),
    getRecentOrders(),
  ]);

  return (
    <main className="p-8 max-w-5xl mx-auto space-y-8">
      <h1 className="text-2xl font-semibold">Operations</h1>

      {/* Metric panel */}
      <div className="grid grid-cols-3 gap-4">
        <MetricCard
          label="Revenue (this month)"
          value={formatCurrency(stats.thisMonthRevenue)}
          delta={stats.revenueDelta}
        />
        <MetricCard
          label="Orders (this month)"
          value={String(stats.thisMonthOrders)}
        />
        <MetricCard
          label="Total completed orders"
          value={String(total)}
        />
      </div>

      {/* Orders table */}
      <table className="w-full text-sm border-collapse">
        <thead>
          <tr className="border-b text-left text-neutral-500">
            <th className="py-2 pr-4">Date</th>
            <th className="py-2 pr-4">Customer</th>
            <th className="py-2 text-right">Amount</th>
          </tr>
        </thead>
        <tbody>
          {orders.map((o) => (
            <tr key={o.id} className="border-b last:border-0">
              <td className="py-2 pr-4 text-neutral-400">
                {o.createdAt.toLocaleDateString()}
              </td>
              <td className="py-2 pr-4">{o.customer.email}</td>
              <td className="py-2 text-right font-mono">
                {formatCurrency(o.amountCents)}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </main>
  );
}

function MetricCard({
  label,
  value,
  delta,
}: {
  label: string;
  value: string;
  delta?: number | null;
}) {
  return (
    <div className="rounded-lg border p-4 space-y-1">
      <p className="text-xs text-neutral-500">{label}</p>
      <p className="text-2xl font-semibold">{value}</p>
      {delta != null && (
        <p className={delta >= 0 ? 'text-green-600 text-xs' : 'text-red-500 text-xs'}>
          {delta >= 0 ? '+' : ''}{formatPercent(delta)} vs last month
        </p>
      )}
    </div>
  );
}

Due query parallele, una forma del risultato tipizzata e un render. È tutto qui. Nessun saga, nessun store, nessun useEffect. Il component è una funzione pura dei suoi dati.

Prestazioni Senza Infrastruttura

Le pagine della dashboard possono sembrare lente se ogni navigazione attiva un round-trip completo al database. Next.js ti offre due strumenti che non costano nulla di extra. Prima di tutto, la funzione cache di React deduplica le chiamate Prisma identiche all'interno dello stesso ciclo di render, così due component che richiedono lo stesso aggregato non eseguono due query. In secondo luogo, il caching dei segmenti di rotta di Next.js ti permette di impostare un export revalidate sulla pagina. Un valore di 60 significa che la pagina si ricostruisce al massimo una volta al minuto all'edge, non a ogni richiesta.

ts
// At the top of any dashboard page file:
export const revalidate = 60; // rebuild at most once per minute

// For a truly real-time view (operations room style), use:
export const dynamic = 'force-dynamic'; // always fresh, no caching

// Deduplicate expensive aggregations across components in the same render:
import { cache } from 'react';

export const getRevenueStats = cache(async () => {
  // ... Prisma queries
});

Scegli revalidate = 60 per la maggior parte delle pagine operative. I numeri non devono essere in tempo reale per essere utili. Riserva force-dynamic per la vista in cui un ritardo di 60 secondi conta davvero, come una coda di supporto o un feed di avvisi antifrode.

Dare a un Agente AI una Memoria a Lungo Termine della Tua Dashboard

Una volta che la tua dashboard cresce oltre una manciata di pagine, un agente AI di coding diventa un collaboratore utile per aggiungere nuovi pannelli di metriche, regolare le query o risolvere casi limite. Il problema con gli agenti AI è lo stato: ogni sessione inizia da zero e l'agente non ricorda il tuo schema Prisma, le tue convenzioni di denominazione o perché hai scelto una particolare struttura di query.

La soluzione è un file di memoria markdown incluso nel repository. Quando l'agente legge questo file all'inizio di una sessione, ha il contesto operativo completo della tua dashboard senza che tu debba rispiegarlo. Strumenti come Claude Code leggono automaticamente un file AGENTS.md. Altri possono essere indirizzati a un file nel loro prompt di sistema o nella finestra di contesto.

Diagramma schematico che mostra un agente AI che legge un file di memoria markdown e si connette a una codebase
L'agente legge il file di memoria del progetto prima di toccare una singola riga di codice.

Ecco un esempio minimo ma completo di come appare quel file di memoria per un progetto di dashboard. La chiave è essere concreti e specifici: nomi dei campi dello schema, percorsi dei file, i comandi esatti che avviano il server di sviluppo e i problemi che hai già incontrato.

md
# AGENTS.md — Dashboard Project Memory

## What this repo is
Internal operations dashboard for Jumpino. Read-only views over the production
Postgres database via Prisma. Next.js 15 App Router, TypeScript strict mode,
Tailwind CSS for layout only (no component library).

## Database
- Prisma schema lives at `prisma/schema.prisma`.
- The `Order` model uses `amountCents: Int` (not a float). Always format with
  `formatCurrency(cents)` from `src/lib/format.ts`.
- `customer` is a relation on `Order`, not a field. Use a `select` or `include`
  in every query that needs the email.
- Never run migrations from this repo. Schema changes happen in the main API
  repo and are pulled via `npx prisma db pull`.

## File structure
- Dashboard pages: `src/app/dashboard/**`
- Shared queries (used by 2+ pages): `src/lib/queries/`
- One-off page queries: keep inline in the page component
- Formatting utils: `src/lib/format.ts`

## Commands
- Dev server: `pnpm dev` (port 3001, main product runs on 3000)
- Type check: `pnpm tsc --noEmit`
- No tests yet; manual verification only

## Conventions
- All dashboard pages export `revalidate = 60` unless they need live data.
- Use `Promise.all` for parallel queries; never await in sequence inside a loop.
- MetricCard component lives in `src/components/MetricCard.tsx`; import it,
  do not rewrite it.

## Known gotchas
- `createdAt` on Order is stored in UTC; display in the user's local timezone
  via `toLocaleDateString()` — the server does not know the browser timezone.
- Prisma `aggregate` returns `null` for `_sum` when there are no rows. Always
  use the nullish coalescing pattern: `result._sum.amountCents ?? 0`.

Quando aggiorni lo schema, aggiungi un problema noto o introduci una nuova convenzione, aggiorni questo file. L'agente lo raccoglie nella sessione successiva. Non si tratta di strumenti complicati: è un file di testo che mantieni allo stesso modo in cui mantieni un README. La differenza è che un agente può agire su di esso in modo affidabile, mentre un essere umano potrebbe saltarne la lettura.

Quando Aggiungere di Più

Inizia in modo minimale. Un pannello di metriche e una tabella paginata coprono l'80% del processo decisionale operativo per la maggior parte dei prodotti in fase iniziale. Le domande da porsi prima di aggiungere una nuova sezione sono: chi agirà su questo numero e quale azione intraprenderà? Se la risposta è vaga, la metrica non appartiene ancora alla dashboard.

Le aggiunte naturali, approssimativamente in ordine di valore: un campo di ricerca sulla tabella degli ordini (una singola server action con un parametro di query), un selettore di intervallo di date per definire le aggregazioni e un pulsante di esportazione che trasmette un CSV direttamente da Prisma. Ognuna di queste richiede qualche ora di lavoro, non qualche giorno, perché le fondamenta sono già corrette.

  • Ricerca: Passa un parametro di query q alla pagina, aggiungi una clausola where: { customer: { email: { contains: q } } } alla query Prisma.
  • Intervallo di date: Due campi <input type="date"> che guidano i parametri di query sul server component.
  • Esportazione CSV: Un route handler su GET /dashboard/orders.csv che trasmette i risultati Prisma attraverso un semplice serializzatore.
  • Grafici: recharts con un wrapper di client component è l'opzione a minor attrito; tieni il recupero dei dati sul server e passali come props.

L'obiettivo non è costruire un prodotto. L'obiettivo è costruire uno strumento di cui il tuo team operativo si fida, che i tuoi ingegneri possano modificare in un pomeriggio e che mostri esattamente le informazioni necessarie per prendere la prossima buona decisione. È raggiungibile in giorni. Richiede solo di resistere all'attrazione verso un framework che promette tutto e consegna rumore.

Portfolio · Cartiglio
Disegnato da
G. STANCUTA
Disciplina
AI & AUTOMATION
Luogo
MORTER · SÜDTIROL
Stato
Disponibile
Lingue
IT · EN · RO · DE+
Stack
PLOI · HETZNER
Revisione
REV 2026.A
2026

© 2026 Gabriel Stancuta · jumpinotech.com — Progettato con l'AI, costruito per funzionare da solo.