Skip to content

Dashboard Persistence

Drizzle Cube provides the UI components for creating and editing dashboards, but persistence is implemented by you. This guide covers how to implement the server-side API and connect it to the client components.

Dashboard persistence has two levels:

  1. Dashboard Config (Required) - Store and retrieve DashboardConfig objects
  2. Thumbnails (Optional) - Capture and display dashboard preview images

Here’s a recommended schema structure using Drizzle ORM:

import { pgTable, integer, text, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core'
import type { DashboardConfig } from 'drizzle-cube/client'
export const dashboards = pgTable('dashboards', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
name: text('name').notNull(),
description: text('description'),
organisationId: integer('organisation_id').notNull(), // Multi-tenant isolation
config: jsonb('config').notNull().$type<DashboardConfig>(),
order: integer('order').default(0),
isActive: boolean('is_active').default(true), // Soft delete support
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
})

The config field stores the complete dashboard configuration:

interface DashboardConfig {
portlets: PortletConfig[]
layoutMode?: 'grid' | 'rows'
rows?: RowLayout[]
colorPalette?: string
filters?: DashboardFilter[]
thumbnailData?: string // Transient: base64 for development
thumbnailUrl?: string // Permanent: CDN URL for production
}

Implement these endpoints to support dashboard persistence:

EndpointMethodDescription
/api/dashboardsGETList all dashboards
/api/dashboards/:idGETGet single dashboard
/api/dashboardsPOSTCreate dashboard
/api/dashboards/:idPUTUpdate dashboard
/api/dashboards/:idDELETEDelete dashboard
/api/dashboards/:id/thumbnailPOSTSave thumbnail (optional)

List Dashboards (GET /api/dashboards)

{
data: Dashboard[],
meta: { total: number }
}

Get Single Dashboard (GET /api/dashboards/:id)

{
data: {
id: number,
name: string,
description?: string,
config: DashboardConfig,
createdAt: string,
updatedAt: string
}
}

Create Dashboard (POST /api/dashboards)

// Request body
{
name: string, // Required
description?: string,
config: { // Required
portlets: PortletConfig[]
},
order?: number
}
// Response (201)
{ data: Dashboard }

Update Dashboard (PUT /api/dashboards/:id)

// Request body (all fields optional)
{
name?: string,
description?: string,
config?: DashboardConfig,
order?: number
}
// Response
{ data: Dashboard }

Delete Dashboard (DELETE /api/dashboards/:id)

// Response
{ message: 'Dashboard deleted successfully' }

Save Thumbnail (POST /api/dashboards/:id/thumbnail)

// Request body
{ thumbnailData: string } // base64 data URI
// Response
{ thumbnailUrl: string } // URL or data URI for storage

Here’s a complete implementation using Hono (works similarly with Express, Fastify, or Next.js API routes):

import { Hono } from 'hono'
import { eq, and, asc } from 'drizzle-orm'
import { dashboards } from './schema'
const app = new Hono()
// List dashboards
app.get('/api/dashboards', async (c) => {
const organisationId = getOrgFromAuth(c)
const pages = await db
.select()
.from(dashboards)
.where(and(
eq(dashboards.organisationId, organisationId),
eq(dashboards.isActive, true)
))
.orderBy(asc(dashboards.order))
return c.json({ data: pages, meta: { total: pages.length } })
})
// Get single dashboard
app.get('/api/dashboards/:id', async (c) => {
const id = parseInt(c.req.param('id'))
const organisationId = getOrgFromAuth(c)
const page = await db
.select()
.from(dashboards)
.where(and(
eq(dashboards.id, id),
eq(dashboards.organisationId, organisationId),
eq(dashboards.isActive, true)
))
.limit(1)
if (page.length === 0) {
return c.json({ error: 'Dashboard not found' }, 404)
}
return c.json({ data: page[0] })
})
// Create dashboard
app.post('/api/dashboards', async (c) => {
const organisationId = getOrgFromAuth(c)
const { name, description, config, order = 0 } = await c.req.json()
// Validation
if (!name || !config?.portlets) {
return c.json({ error: 'name and config.portlets required' }, 400)
}
// Recommended limits
const existing = await db
.select({ id: dashboards.id })
.from(dashboards)
.where(and(
eq(dashboards.organisationId, organisationId),
eq(dashboards.isActive, true)
))
if (existing.length >= 10) {
return c.json({ error: 'Maximum dashboards reached (10)' }, 400)
}
if (config.portlets.length > 50) {
return c.json({ error: 'Maximum 50 portlets allowed' }, 400)
}
const newPage = await db
.insert(dashboards)
.values({ name, description, order, organisationId, config })
.returning()
return c.json({ data: newPage[0] }, 201)
})
// Update dashboard
app.put('/api/dashboards/:id', async (c) => {
const id = parseInt(c.req.param('id'))
const organisationId = getOrgFromAuth(c)
const { name, description, config, order } = await c.req.json()
const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (name !== undefined) updateData.name = name
if (description !== undefined) updateData.description = description
if (order !== undefined) updateData.order = order
if (config !== undefined) {
if (config.portlets?.length > 50) {
return c.json({ error: 'Maximum 50 portlets allowed' }, 400)
}
updateData.config = config
}
const updated = await db
.update(dashboards)
.set(updateData)
.where(and(
eq(dashboards.id, id),
eq(dashboards.organisationId, organisationId)
))
.returning()
if (updated.length === 0) {
return c.json({ error: 'Dashboard not found' }, 404)
}
return c.json({ data: updated[0] })
})
// Delete dashboard (soft delete)
app.delete('/api/dashboards/:id', async (c) => {
const id = parseInt(c.req.param('id'))
const organisationId = getOrgFromAuth(c)
const deleted = await db
.update(dashboards)
.set({ isActive: false, updatedAt: new Date() })
.where(and(
eq(dashboards.id, id),
eq(dashboards.organisationId, organisationId)
))
.returning()
if (deleted.length === 0) {
return c.json({ error: 'Dashboard not found' }, 404)
}
return c.json({ message: 'Dashboard deleted successfully' })
})

The AnalyticsDashboard component accepts these persistence-related props:

interface AnalyticsDashboardProps {
config: DashboardConfig
editable?: boolean
onConfigChange?: (config: DashboardConfig) => void // Local state update
onSave?: (config: DashboardConfig) => Promise<void> | void // Persist to server
onSaveThumbnail?: (thumbnailData: string) => Promise<string | void> // Optional
onDirtyStateChange?: (isDirty: boolean) => void // Track unsaved changes
}
import { useState, useCallback, useEffect } from 'react'
import { CubeProvider, AnalyticsDashboard } from 'drizzle-cube/client'
import type { DashboardConfig } from 'drizzle-cube/client'
function DashboardPage({ dashboardId }: { dashboardId: string }) {
const [config, setConfig] = useState<DashboardConfig | null>(null)
// Fetch dashboard on mount
useEffect(() => {
fetch(`/api/dashboards/${dashboardId}`)
.then(res => res.json())
.then(data => setConfig(data.data.config))
}, [dashboardId])
// Handle local config changes (before save)
const handleConfigChange = useCallback((newConfig: DashboardConfig) => {
setConfig(newConfig)
}, [])
// Persist to server
const handleSave = useCallback(async (configToSave: DashboardConfig) => {
const response = await fetch(`/api/dashboards/${dashboardId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: configToSave })
})
if (!response.ok) {
throw new Error('Failed to save dashboard')
}
}, [dashboardId])
// Optional: Save thumbnail
const handleSaveThumbnail = useCallback(async (thumbnailData: string) => {
const response = await fetch(`/api/dashboards/${dashboardId}/thumbnail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ thumbnailData })
})
if (response.ok) {
const result = await response.json()
return result.thumbnailUrl
}
}, [dashboardId])
if (!config) return <div>Loading...</div>
return (
<CubeProvider
apiOptions={{ apiUrl: '/api/cubejs-api/v1' }}
features={{
thumbnail: { enabled: true, width: 400, height: 300, format: 'png' }
}}
>
<AnalyticsDashboard
config={config}
editable={true}
onConfigChange={handleConfigChange}
onSave={handleSave}
onSaveThumbnail={handleSaveThumbnail}
/>
</CubeProvider>
)
}

The thumbnail feature captures dashboard screenshots for list views and previews.

Install the optional peer dependency:

Terminal window
npm install modern-screenshot

Note: The feature gracefully degrades if modern-screenshot is not installed. You’ll see a development warning but the dashboard will work normally.

Enable thumbnails via the CubeProvider features config:

interface ThumbnailFeatureConfig {
enabled: boolean
width?: number // default: 800 (capture width in pixels)
height?: number // default: 600 (capture height in pixels)
format?: 'png' | 'jpeg'
quality?: number // 0-1, jpeg only (default: 0.9)
}
<CubeProvider
apiOptions={{ apiUrl: '/api/cubejs-api/v1' }}
features={{
thumbnail: {
enabled: true,
width: 400,
height: 300,
format: 'png'
}
}}
>
<App />
</CubeProvider>
User edits dashboard → Exits edit mode
thumbnailDirty flag checked
captureThumbnail(gridContentRef)
onSaveThumbnail(base64DataUri) called
Server processes and returns URL
config.thumbnailUrl updated

Thumbnails are captured automatically when the user exits edit mode after making changes. The onSaveThumbnail callback receives a base64 data URI that you can upload to your server or cloud storage.

Development Pattern (store in database):

app.post('/api/dashboards/:id/thumbnail', async (c) => {
const id = parseInt(c.req.param('id'))
const organisationId = getOrgFromAuth(c)
const { thumbnailData } = await c.req.json()
if (!thumbnailData || typeof thumbnailData !== 'string') {
return c.json({ error: 'thumbnailData required' }, 400)
}
const existingPage = await db
.select()
.from(dashboards)
.where(and(
eq(dashboards.id, id),
eq(dashboards.organisationId, organisationId)
))
.limit(1)
if (existingPage.length === 0) {
return c.json({ error: 'Dashboard not found' }, 404)
}
const updatedConfig = {
...existingPage[0].config,
thumbnailData, // Store base64 directly for development
thumbnailUrl: undefined
}
await db
.update(dashboards)
.set({ config: updatedConfig, updatedAt: new Date() })
.where(eq(dashboards.id, id))
return c.json({ thumbnailUrl: thumbnailData })
})

Production Pattern (upload to S3/R2):

app.post('/api/dashboards/:id/thumbnail', async (c) => {
const id = parseInt(c.req.param('id'))
const { thumbnailData } = await c.req.json()
// Convert base64 to buffer
const base64Data = thumbnailData.split(',')[1]
const buffer = Buffer.from(base64Data, 'base64')
// Upload to cloud storage
const key = `dashboards/${id}/thumbnail-${Date.now()}.png`
await s3.putObject({
Bucket: BUCKET,
Key: key,
Body: buffer,
ContentType: 'image/png'
})
const url = `https://cdn.example.com/${key}`
// Store URL only (not base64)
const existingPage = await db.select().from(dashboards).where(eq(dashboards.id, id)).limit(1)
await db
.update(dashboards)
.set({
config: { ...existingPage[0].config, thumbnailUrl: url, thumbnailData: undefined },
updatedAt: new Date()
})
.where(eq(dashboards.id, id))
return c.json({ thumbnailUrl: url })
})

Use the DashboardThumbnailPlaceholder component for dashboards without thumbnails:

import { DashboardThumbnailPlaceholder } from 'drizzle-cube/client'
function DashboardCard({ dashboard }) {
const thumbnailSrc = dashboard.config.thumbnailUrl || dashboard.config.thumbnailData
return (
<div className="dashboard-card">
<div className="aspect-video bg-dc-bg-secondary">
{thumbnailSrc ? (
<img
src={thumbnailSrc}
alt={`${dashboard.name} preview`}
className="w-full h-full object-cover object-top"
/>
) : (
<DashboardThumbnailPlaceholder className="w-full h-full" />
)}
</div>
<h3>{dashboard.name}</h3>
</div>
)
}

For advanced use cases, these utility functions are available:

import {
captureThumbnail,
isThumbnailCaptureAvailable,
warnIfScreenshotLibMissing
} from 'drizzle-cube/client'
// Check if thumbnail capture is available
const available = await isThumbnailCaptureAvailable(thumbnailConfig)
// Manual capture (if needed outside the normal flow)
const dataUri = await captureThumbnail(elementRef, thumbnailConfig)
// Show dev warning if library missing
warnIfScreenshotLibMissing(thumbnailConfig)

The drizzle-cube development site includes a complete working example. View the source code on GitHub:

FileDescription
dev/server/analytics-routes.tsComplete REST API with all CRUD endpoints
dev/server/schema.tsDatabase schema with analyticsPages table
dev/client/src/pages/DashboardViewPage.tsxClient integration with onSave and onSaveThumbnail
dev/client/src/pages/DashboardListPage.tsxDashboard list with thumbnail display
dev/client/src/App.tsxCubeProvider with features.thumbnail config
src/client/utils/thumbnail.tsThumbnail capture utility functions
src/client/components/DashboardThumbnailPlaceholder.tsxPlaceholder component for missing thumbnails
  • Always validate that config.portlets is an array
  • Implement limits (10 dashboards per org, 50 portlets per dashboard recommended)
  • Use soft deletes (isActive flag) for data recovery
  • Include organisationId filtering for multi-tenant isolation
  • Return the updated entity after mutations for client sync
  • Use smaller dimensions (400x300) for list view thumbnails
  • Use PNG format for dashboards with text/charts (sharper edges)
  • Use JPEG format for image-heavy dashboards (smaller file size)
  • Handle capture failures gracefully (keep existing thumbnail)
  • Clean up old thumbnails when uploading new ones (S3/R2)
IssueSolution
Changes not persistingEnsure onSave callback returns a Promise and completes successfully
Thumbnails not capturingInstall modern-screenshot: npm install modern-screenshot
Console warning about missing libraryInstall the optional dependency (warning is dev-mode only)
Captures blank/white imageEnsure charts are fully rendered before exiting edit mode
Large thumbnail file sizesReduce width/height or use JPEG format
CORS issues with chart imagesEnsure all images in charts are from the same origin
404 on dashboard fetchCheck that organisationId matches and isActive is true