G.STANCUTA
Veröffentlicht · 2026 · 02 · 167 Min. Lesezeit

Baue das Dashboard, das du wirklich brauchst

  • next.js
  • prisma
  • data
  • architecture

Vorgefertigte Admin-Tools zwingen dein Unternehmen in ihr Datenmodell. Eine schlanke, ehrliche Schicht, die deine eigene Datenbank liest und die wenigen Entscheidungen sichtbar macht, die wirklich zählen, braucht Tage, keine Monate.

Jedes SaaS-Produkt liefert ein Dashboard. Die meisten davon sind falsch. Sie zeigen die Metriken, die der Anbieter für wichtig hielt, im Layout, das der Anbieter am einfachsten zu bauen fand, im Takt, den der Anbieter am günstigsten berechnen konnte. Dein Unternehmen ist anders. Deine Entscheidungen sind anders. In dem Moment, in dem du ein Admin-Panel eines Drittanbieters als deine operative Ansicht akzeptierst, beginnst du, Entscheidungen durch die Brille von jemand anderem zu treffen.

Die gute Nachricht: Ein benutzerdefiniertes Dashboard ist kein Sechs-Monats-Projekt. Mit Next.js Server Components und Prisma kannst du in zwei bis drei konzentrierten Tagen eine schreibgeschützte operative Ansicht deiner eigenen Datenbank veröffentlichen. Sie wird schneller laden, weniger kosten und dir genau das sagen, was du wissen musst.

Isometrisches Diagramm einer dünnen Datenschicht, die eine Datenbank abfragt und ein minimales Metrik-Panel speist
Eine direkte Leseschicht: deine Datenbank, dein Modell, deine Zahlen.

Die Falle der vorgefertigten Tools

Tools wie Retool, Metabase und Forest Admin sind für bestimmte Teams durchaus nützlich. Sie sind hervorragend, wenn dein Datenmodell zufällig zu ihren Annahmen passt und dein Team gerne in ihrem Berechtigungsmodell arbeitet. Aber die meisten Produktunternehmen stoßen schnell an die Grenzen. Du kannst nicht einfach drei interne Tabellen mit benutzerdefinierter Geschäftslogik verknüpfen. Du kannst keine Echtzeit-Websocket-Daten neben einer langsamen Aggregationsabfrage einbetten. Du kannst nicht dieselben Auth-Regeln durchsetzen, die du in deinem Produkt verwendest. Jede Umgehungslösung fügt eine weitere Indirektionsebene zwischen einer Entscheidung und den sie treibenden Daten hinzu.

Das tiefere Problem ist die Verantwortung. Wenn deine operative Ansicht in einem Drittanbieter-Tool lebt, lernt dein Team dieses Tool statt deines Systems. Neue Ingenieure verbringen Zeit damit, Widgets zu konfigurieren, anstatt die Domäne zu verstehen. Das Dashboard wird zu einer Black Box, der niemand vollständig vertraut.

Ein Dashboard ist nur dann nützlich, wenn die Person, die es liest, jeder Zahl darauf vertraut.

Wie eine schlanke, ehrliche Schicht aussieht

Die Alternative ist kein vollständiges Admin-Framework. Es ist eine kleine, leseorientierte Next.js-Anwendung, die in deinem Monorepo sitzt, deinen Prisma-Client teilt und nur die Handvoll Metriken und Tabellen rendert, auf die dein Betriebsteam tatsächlich reagiert. Kein Drag-and-Drop-Widget-Builder. Kein Plugin-Marktplatz. Nur Server Components, die Abfragen ausführen und Daten zurückgeben.

Die Architektur ist bewusst langweilig. Ein Server Component führt eine Prisma-Abfrage aus. Das Ergebnis ist typisiert. Der Component rendert es. Es gibt keine API-Route dazwischen, weil du keine brauchst. Server Components sind die API. Das bedeutet, dass die Daten nie durch eine Client-Grenze serialisiert werden, es sei denn, du entscheidest dich ausdrücklich dafür, etwas interaktiv zu machen.

  • Kein clientseitiges Datenabrufen für Lesepfade. Server Components eliminieren den Ladespinner für die meisten Ansichten.
  • Deine Auth, nicht ihre. Das Dashboard lebt hinter derselben Middleware wie dein Produkt.
  • Typisierte Abfragen. Prismas generierter Client bedeutet, dass eine Schema-Änderung das Dashboard zur Kompilierzeit abbricht, nicht in der Produktion.
  • Inkrementell. Beginne mit einem Metrik-Panel. Füge Abschnitte hinzu, wenn jemand danach fragt.

Ein echter Server Component: Metrik-Panel und Tabelle

So sieht eine praktische Umsatzübersichtsseite aus. Der Component führt zwei Prisma-Abfragen aus, berechnet ein einfaches Delta und rendert inline. Die Tabelle darunter ist auf Datenbankebene paginiert, nicht im Speicher.

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>
  );
}

Zwei parallele Abfragen, eine typisierte Ergebnisform und ein Render. Das ist alles. Kein Saga, kein Store, kein useEffect. Der Component ist eine reine Funktion seiner Daten.

Leistung ohne Infrastruktur

Dashboard-Seiten können sich langsam anfühlen, wenn jede Navigation einen vollständigen Datenbank-Round-Trip auslöst. Next.js gibt dir zwei Tools, die nichts extra kosten. Erstens dedupliziert die cache-Funktion von React identische Prisma-Aufrufe innerhalb desselben Render-Zyklus, sodass zwei Components, die dasselbe Aggregat anfordern, keine zwei Abfragen auslösen. Zweitens ermöglicht das Route-Segment-Caching von Next.js, einen revalidate-Export auf der Seite zu setzen. Ein Wert von 60 bedeutet, dass die Seite höchstens einmal pro Minute am Edge neu erstellt wird, nicht bei jeder Anfrage.

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
});

Wähle revalidate = 60 für die meisten operativen Seiten. Die Zahlen müssen nicht live sein, um nützlich zu sein. Reserviere force-dynamic für die eine Ansicht, bei der eine Verzögerung von 60 Sekunden wirklich wichtig ist, wie eine Support-Warteschlange oder ein Betrugsalarm-Feed.

Einem KI-Agenten ein Langzeitgedächtnis für dein Dashboard geben

Sobald dein Dashboard über eine Handvoll Seiten hinauswächst, wird ein KI-Coding-Agent zu einem nützlichen Mitarbeiter beim Hinzufügen neuer Metrik-Panels, beim Anpassen von Abfragen oder beim Beheben von Grenzfällen. Das Problem mit KI-Agenten ist der Zustand: Jede Sitzung beginnt von vorne, und der Agent erinnert sich nicht an dein Prisma-Schema, deine Namenskonventionen oder warum du eine bestimmte Abfragestruktur gewählt hast.

Die Lösung ist eine Markdown-Gedächtnisdatei, die in das Repository eingecheckt ist. Wenn der Agent diese Datei zu Beginn einer Sitzung liest, hat er den vollständigen operativen Kontext deines Dashboards, ohne dass du ihn neu erklären musst. Tools wie Claude Code lesen eine AGENTS.md-Datei automatisch. Andere können auf eine Datei in ihrem System-Prompt oder Kontextfenster verwiesen werden.

Schematisches Diagramm, das einen KI-Agenten zeigt, der eine Markdown-Gedächtnisdatei liest und sich mit einer Codebase verbindet
Der Agent liest die Projektgedächtnisdatei, bevor er eine einzige Zeile Code anfasst.

Hier ist ein minimales, aber vollständiges Beispiel dafür, wie diese Gedächtnisdatei für ein Dashboard-Projekt aussieht. Der Schlüssel ist, konkret und spezifisch zu sein: Schema-Feldnamen, Dateipfade, die genauen Befehle, die den Dev-Server starten, und die Probleme, auf die du bereits gestoßen bist.

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`.

Wenn du das Schema aktualisierst, ein Problem hinzufügst oder eine neue Konvention einführst, aktualisierst du diese Datei. Der Agent nimmt sie in der nächsten Sitzung auf. Das ist kein kompliziertes Tooling: Es ist eine Textdatei, die du genauso pflegst wie eine README. Der Unterschied ist, dass ein Agent zuverlässig darauf reagieren kann, während ein Mensch es möglicherweise überspringt.

Wann mehr hinzugefügt werden sollte

Beginne minimal. Ein Metrik-Panel und eine paginierte Tabelle decken 80% der operativen Entscheidungsfindung für die meisten frühen Produkte ab. Die Fragen, die man sich stellen muss, bevor man einen neuen Abschnitt hinzufügt, sind: Wer wird auf diese Zahl reagieren, und welche Maßnahme werden sie ergreifen? Wenn die Antwort vage ist, gehört die Metrik noch nicht in das Dashboard.

Die natürlichen Ergänzungen, grob in der Reihenfolge des Wertes: ein Sucheingabefeld über der Bestelltabelle (eine einzelne Server Action mit einem Query-Param), ein Datumsbereichswähler zur Eingrenzung von Aggregationen und ein Exportknopf, der eine CSV direkt aus Prisma streamt. Jede davon ist ein paar Stunden Arbeit, nicht ein paar Tage, weil das Fundament bereits richtig ist.

  • Suche: Übergebe einen q-Query-Param an die Seite, füge eine where: { customer: { email: { contains: q } } }-Klausel zur Prisma-Abfrage hinzu.
  • Datumsbereich: Zwei <input type="date">-Felder, die Query-Params auf dem Server Component steuern.
  • CSV-Export: Ein Route Handler unter GET /dashboard/orders.csv, der Prisma-Ergebnisse durch einen einfachen Serialisierer streamt.
  • Diagramme: recharts mit einem Client-Component-Wrapper ist die Option mit der geringsten Hürde; halte das Datenabrufen auf dem Server und übergebe es als Props.

Das Ziel ist nicht, ein Produkt zu bauen. Das Ziel ist, ein Tool zu bauen, dem dein Betriebsteam vertraut, das deine Ingenieure an einem Nachmittag ändern können und das genau die Informationen zeigt, die für die nächste gute Entscheidung benötigt werden. Das ist in Tagen erreichbar. Es erfordert nur, dem Sog eines Frameworks zu widerstehen, das alles verspricht und Lärm liefert.

Portfolio · Schriftfeld
Gezeichnet von
G. STANCUTA
Disziplin
AI & AUTOMATION
Standort
MORTER · SÜDTIROL
Status
Verfügbar
Sprachen
IT · EN · RO · DE+
Stack
PLOI · HETZNER
Revision
REV 2026.A
2026

© 2026 Gabriel Stancuta · jumpinotech.com — Mit KI entworfen, gebaut, um sich selbst zu betreiben.