Dashboard from Your TypeScript API (Typed End to End)

A TypeScript route handler typed against the dashboard widget contract, beside the line chart it renders.

The dashboard contract is a fixed JSON shape. Which means in TypeScript you can type it once, and from then on the compiler tells you whether an endpoint will render — before you deploy it.

That's the whole pitch on this page. Everything else is a consequence of it.

The reality

Building a dashboard in a TypeScript stack has a specific failure mode, and it isn't the charts. It's the boundary.

You define a response type on the server. You define a props type on the client. A chart library defines a third shape, usually with its own generics and its own opinions about what a data point is. Then someone renames a field and you find out at runtime, in a browser, on a Tuesday.

The usual fixes are real work in themselves: share types through a monorepo package, generate a client from an OpenAPI spec, or adopt an RPC layer. All fine. All infrastructure you're now maintaining so that a bar chart can be sure what a bar is.

A fixed external contract removes the boundary rather than typing across it. There is one shape, it doesn't change under you, and you can encode it in about forty lines.

Your data stays in your TypeScript services

dashboardbase never asks for database credentials and never stores your data. It calls your endpoint over HTTPS and renders the response.

Your Prisma or Drizzle client, your connection string, your service tokens — all of it stays inside your own infrastructure. Nothing on our side can query your database, because nothing on our side knows how.

Your existing types keep applying too. The dashboard route is an ordinary route: same middleware, same tenant scoping, same inferred model types you already trust.

Define the contract once

Here's the envelope, typed. The generic parameter is the per-widget data payload:

type Color =
  | 'Success' | 'Warning' | 'Danger' | 'Blue' | 'Green'
  | 'Red' | 'Yellow' | 'Orange' | 'Light' | 'Dark'

interface Badge {
  text: string
  icon?: 'ArrowUp' | 'ArrowDown'
  color?: Color
  fill?: 'Solid' | 'Outline'
}

interface Header {
  title: string
  subtitle?: string
  badge?: Badge
}

interface Widget<Data> {
  title: string
  actions?: { title: string; type: 'link'; url: string }[]
  data: Data
  alert?: {
    active: boolean
    level: 'info' | 'success' | 'warning' | 'critical'
    message: string
  }
}

interface DataPoint {
  value: number
  postfix?: string
}

type LineChart = Widget<{
  header?: Header
  labels: string[]
  datasets: { data: DataPoint[]; label: string }[]
  fill?: boolean
}>

type Kpi = Widget<{ header: Header; progress?: { value: number; max: number; label: string } }

type Color =
  | 'Success' | 'Warning' | 'Danger' | 'Blue' | 'Green'
  | 'Red' | 'Yellow' | 'Orange' | 'Light' | 'Dark'

interface Badge {
  text: string
  icon?: 'ArrowUp' | 'ArrowDown'
  color?: Color
  fill?: 'Solid' | 'Outline'
}

interface Header {
  title: string
  subtitle?: string
  badge?: Badge
}

interface Widget<Data> {
  title: string
  actions?: { title: string; type: 'link'; url: string }[]
  data: Data
  alert?: {
    active: boolean
    level: 'info' | 'success' | 'warning' | 'critical'
    message: string
  }
}

interface DataPoint {
  value: number
  postfix?: string
}

type LineChart = Widget<{
  header?: Header
  labels: string[]
  datasets: { data: DataPoint[]; label: string }[]
  fill?: boolean
}>

type Kpi = Widget<{ header: Header; progress?: { value: number; max: number; label: string } }

type Color =
  | 'Success' | 'Warning' | 'Danger' | 'Blue' | 'Green'
  | 'Red' | 'Yellow' | 'Orange' | 'Light' | 'Dark'

interface Badge {
  text: string
  icon?: 'ArrowUp' | 'ArrowDown'
  color?: Color
  fill?: 'Solid' | 'Outline'
}

interface Header {
  title: string
  subtitle?: string
  badge?: Badge
}

interface Widget<Data> {
  title: string
  actions?: { title: string; type: 'link'; url: string }[]
  data: Data
  alert?: {
    active: boolean
    level: 'info' | 'success' | 'warning' | 'critical'
    message: string
  }
}

interface DataPoint {
  value: number
  postfix?: string
}

type LineChart = Widget<{
  header?: Header
  labels: string[]
  datasets: { data: DataPoint[]; label: string }[]
  fill?: boolean
}>

type Kpi = Widget<{ header: Header; progress?: { value: number; max: number; label: string } }

Note DataPoint: a data point is an object with a value, never an x/y pair. Typing it is how you stop guessing at that.

Now the handler is checked by the compiler:

import type { RequestHandler } from 'express'

const SECRET = process.env.DASHBOARDBASE_ENDPOINT_SECRET

// Fail at boot rather than serving an unprotected endpoint.
if (!SECRET) throw new Error('DASHBOARDBASE_ENDPOINT_SECRET is not set')

export const requireDashboardbase: RequestHandler = (req, res, next) => {
  if (req.get('x-dashboardbase-secret') !== SECRET) {
    res.status(401).json({ error: 'unauthorized' })
    return
  }
  next()
}

export const traffic: RequestHandler = async (_req, res) => {
  const days = await pageViewsByDay() // { day: string; views: number }[]

  const body: LineChart = {
    title: 'Website Traffic',
    actions: [
      {
        title: 'Analyze Traffic',
        type: 'link',
        url: 'https://example.com/traffic-analysis',
      },
    ],
    data: {
      header: {
        title: String(days.reduce((sum, d) => sum + d.views, 0)),
        subtitle: 'Last 7 days',
        badge: { text: '+70%', icon: 'ArrowUp', color: 'Success' },
      },
      labels: days.map((d) => d.day),
      datasets: [
        {
          data: days.map((d) => ({ value: d.views })),
          label: 'Page Views',
        },
      ],
    },
  }

  res.json(body)
}
import type { RequestHandler } from 'express'

const SECRET = process.env.DASHBOARDBASE_ENDPOINT_SECRET

// Fail at boot rather than serving an unprotected endpoint.
if (!SECRET) throw new Error('DASHBOARDBASE_ENDPOINT_SECRET is not set')

export const requireDashboardbase: RequestHandler = (req, res, next) => {
  if (req.get('x-dashboardbase-secret') !== SECRET) {
    res.status(401).json({ error: 'unauthorized' })
    return
  }
  next()
}

export const traffic: RequestHandler = async (_req, res) => {
  const days = await pageViewsByDay() // { day: string; views: number }[]

  const body: LineChart = {
    title: 'Website Traffic',
    actions: [
      {
        title: 'Analyze Traffic',
        type: 'link',
        url: 'https://example.com/traffic-analysis',
      },
    ],
    data: {
      header: {
        title: String(days.reduce((sum, d) => sum + d.views, 0)),
        subtitle: 'Last 7 days',
        badge: { text: '+70%', icon: 'ArrowUp', color: 'Success' },
      },
      labels: days.map((d) => d.day),
      datasets: [
        {
          data: days.map((d) => ({ value: d.views })),
          label: 'Page Views',
        },
      ],
    },
  }

  res.json(body)
}
import type { RequestHandler } from 'express'

const SECRET = process.env.DASHBOARDBASE_ENDPOINT_SECRET

// Fail at boot rather than serving an unprotected endpoint.
if (!SECRET) throw new Error('DASHBOARDBASE_ENDPOINT_SECRET is not set')

export const requireDashboardbase: RequestHandler = (req, res, next) => {
  if (req.get('x-dashboardbase-secret') !== SECRET) {
    res.status(401).json({ error: 'unauthorized' })
    return
  }
  next()
}

export const traffic: RequestHandler = async (_req, res) => {
  const days = await pageViewsByDay() // { day: string; views: number }[]

  const body: LineChart = {
    title: 'Website Traffic',
    actions: [
      {
        title: 'Analyze Traffic',
        type: 'link',
        url: 'https://example.com/traffic-analysis',
      },
    ],
    data: {
      header: {
        title: String(days.reduce((sum, d) => sum + d.views, 0)),
        subtitle: 'Last 7 days',
        badge: { text: '+70%', icon: 'ArrowUp', color: 'Success' },
      },
      labels: days.map((d) => d.day),
      datasets: [
        {
          data: days.map((d) => ({ value: d.views })),
          label: 'Page Views',
        },
      ],
    },
  }

  res.json(body)
}

Which produces exactly this:

{
  "title": "Website Traffic",
  "actions": [
    {
      "title": "Analyze Traffic",
      "type": "link",
      "url": "https://example.com/traffic-analysis"
    }
  ],
  "data": {
    "header": {
      "title": "1010",
      "subtitle": "Last 7 days",
      "badge": {
        "text": "+70%",
        "icon": "ArrowUp",
        "color": "Success"
      }
    },
    "labels": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
    "datasets": [
      {
        "data": [
          { "value": 100 },
          { "value": 120 },
          { "value": 150 },
          { "value": 130 },
          { "value": 160 },
          { "value": 180 },
          { "value": 170 }
        ],
        "label": "Page Views"
      }
    ]
  }
}
{
  "title": "Website Traffic",
  "actions": [
    {
      "title": "Analyze Traffic",
      "type": "link",
      "url": "https://example.com/traffic-analysis"
    }
  ],
  "data": {
    "header": {
      "title": "1010",
      "subtitle": "Last 7 days",
      "badge": {
        "text": "+70%",
        "icon": "ArrowUp",
        "color": "Success"
      }
    },
    "labels": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
    "datasets": [
      {
        "data": [
          { "value": 100 },
          { "value": 120 },
          { "value": 150 },
          { "value": 130 },
          { "value": 160 },
          { "value": 180 },
          { "value": 170 }
        ],
        "label": "Page Views"
      }
    ]
  }
}
{
  "title": "Website Traffic",
  "actions": [
    {
      "title": "Analyze Traffic",
      "type": "link",
      "url": "https://example.com/traffic-analysis"
    }
  ],
  "data": {
    "header": {
      "title": "1010",
      "subtitle": "Last 7 days",
      "badge": {
        "text": "+70%",
        "icon": "ArrowUp",
        "color": "Success"
      }
    },
    "labels": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
    "datasets": [
      {
        "data": [
          { "value": 100 },
          { "value": 120 },
          { "value": 150 },
          { "value": 130 },
          { "value": 160 },
          { "value": 180 },
          { "value": 170 }
        ],
        "label": "Page Views"
      }
    ]
  }
}

The const body: LineChart annotation is the point. Misspell datasets, return a bare number where a DataPoint belongs, or invent a colour that isn't in the palette, and it fails at compile time rather than rendering an empty widget.

A Zod schema works just as well if you'd rather validate at the edge and infer the type from it. Either way you write the contract down once.

Generate TypeScript endpoints faster

  • The dashboardbase Skill is free and open source. It teaches Claude Code, Cursor or any skills-capable agent the full contract, so generated handlers land on the right shape instead of near it — and with the types above in the repo, anything that drifts fails your typecheck immediately.

  • In-app prompt generation produces a copy-pasteable prompt for whatever AI tool you already use, per widget or for a whole dashboard.

Both give you a reviewable draft you run yourself. The prompt is built in your browser and nothing executes on our side.

Then paste a response into the endpoint validator — no account needed — for a second opinion the compiler can't give you: whether the widget actually renders it.

Where this isn't the right fit

  • The dashboard is a product surface. Customer-facing analytics belongs in your own app, with your own components.

  • Heavy interaction. Filters, drilldowns, anything where the viewer changes the query — that's an app, not a board.

  • You want a semantic layer. Modelling metrics once and querying them many ways is a warehouse-and-BI job, and types don't change that.

  • Nobody can deploy a new route. The whole approach assumes shipping a handler is a small thing.

Type it once and see

Copy the types above into your project, annotate one handler, and paste the URL into a widget. If the compiler is happy and the widget renders, that's the whole loop — and every endpoint after it is checked for free. The board reads on your phone in the native iOS and Android apps.

Worth reading next: if your API returns structured JSON, you're one step away from a dashboard, and API-first dashboards for why the contract lives on your side of the boundary.