Skip to content
D
Documentation

Qwik: custom content and kits

how-to
4 min readUpdated

Keep custom nodes and widgets self-contained, pass their content through typed data props, and build function-bearing kits in the browser with spec$. Use this pattern when a node needs application-specific HTML or a kit needs to survive server rendering and resumption.

Your custom component paints the inside of an engine-positioned box; the engine and renderer still own dragging, selection and connections. Start in a Qwik 1.x project using @builder.io/qwik ^1.5.0 and its optimizer. See the Qwik quick start for setup.

bash
npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik

1. Pass node content through typed props

Unlike React, registering a component in GrafloriaFlow's NodeTypes map automatically opts matching nodes into HTML rendering unless they specify custom: false; see React: custom content for the shared type-to-component mapping.

The Qwik sample uses NodeProps with a ServiceData payload to carry owner and status across the container boundary, alongside typed NodeSpec and EdgeSpec inputs; see React: custom content for the shared props contract.

Custom nodes mount in separate Qwik containers. They cannot read contexts from the surrounding application, including router contexts. Pass the values they need through node.data rather than calling a parent-context hook inside the card. This is Qwik's container behavior, not a wrapper defect.

tsx
import { component$ } from '@builder.io/qwik';
import {
  GrafloriaFlow,
  type NodeProps,
  type NodeTypes,
  type NodeSpec,
  type EdgeSpec,
} from '@grafloria/qwik';

interface ServiceData {
  name: string;
  owner: string;
  status: 'healthy' | 'degraded';
}

const ServiceNode = component$((props: NodeProps<ServiceData>) => (
  <div style={{
    boxSizing: 'border-box', width: '100%', height: '100%',
    padding: '12px', borderRadius: '10px', background: '#ffffff',
    border: `2px solid ${props.data.status === 'healthy' ? '#059669' : '#d97706'}`,
    color: '#232a3d', font: '13px/1.4 system-ui, sans-serif',
  }}>
    <strong>{props.data.name}</strong>
    <div>{props.data.owner}</div>
    <div>{props.data.status}</div>
  </div>
));

const nodeTypes = { service: ServiceNode } satisfies NodeTypes;
const gateway: ServiceData = {
  name: 'api-gateway', owner: 'platform', status: 'healthy',
};
const orders: ServiceData = {
  name: 'orders-svc', owner: 'commerce', status: 'degraded',
};
const nodes: NodeSpec[] = [
  { id: 'gateway', type: 'service', position: { x: 60, y: 80 },
    size: { width: 190, height: 96 }, data: gateway },
  { id: 'orders', type: 'service', position: { x: 370, y: 80 },
    size: { width: 190, height: 96 }, data: orders },
];
const edges: EdgeSpec[] = [
  { id: 'gateway-orders', source: 'gateway', target: 'orders',
    sourceHandle: 'right', targetHandle: 'left' },
];

export default component$(() => (
  <GrafloriaFlow
    defaultNodes={nodes} defaultEdges={edges} nodeTypes={nodeTypes}
    fitView style={{ height: '400px' }}
  />
));
Look at the green-bordered api-gateway and amber-bordered orders-svc cards: each displays its owner and status, and an arrow connects them.

The canvas contains two connected service cards: a green-bordered healthy gateway and an amber-bordered degraded orders service. Each card fills the size declared on its node. The defaults seed an uncontrolled instance; this example does not mirror edits into application state.

2. Mix a custom widget with a shipped painter

GrafloriaDashboard uses the same container boundary. Map a widget's kind through WidgetTypes, and receive its full spec and data through WidgetProps. Feed it through widget.data; do not rely on surrounding application contexts.

Declare the board with DashboardWidgetSpec. Only the note kind below has a custom component. The kpi kind uses the shipped painter, so you do not need to write a KPI renderer.

tsx
import { component$ } from '@builder.io/qwik';
import {
  GrafloriaDashboard, type WidgetProps, type WidgetTypes,
} from '@grafloria/qwik';
import type { DashboardWidgetSpec } from '@grafloria/element';

interface NoteData {
  title: string;
  text: string;
}

const NoteWidget = component$((props: WidgetProps<NoteData>) => (
  <article style={{
    boxSizing: 'border-box', width: '100%', height: '100%',
    padding: '16px', background: '#fffbeb', color: '#78350f',
    font: '14px/1.5 system-ui, sans-serif',
  }}>
    <strong>{props.data.title}</strong>
    <p>{props.data.text}</p>
  </article>
));

const widgetTypes = { note: NoteWidget } satisfies WidgetTypes;
const note: NoteData = {
  title: 'Operations note', text: 'Check the orders service before the release.',
};
const widgets: DashboardWidgetSpec[] = [
  { id: 'services', kind: 'kpi', span: 3, rows: 1,
    data: { label: 'Healthy services', value: '2 / 3' } },
  { id: 'release-note', kind: 'note', span: 3, rows: 1, data: { ...note } },
];

export default component$(() => (
  <GrafloriaDashboard
    widgets={widgets} widgetTypes={widgetTypes}
    options={{ columns: 6, width: 800, height: 400, gap: 8 }}
    style={{ height: '400px' }}
  />
));

The board displays a Healthy services KPI beside an Operations note card. Qwik builds and mounts the dashboard kit in the browser; see React: custom content for the shared mount-once input behavior. For runtime widget edits, see Build a dashboard.

3. Build a kit through spec$ and remount for new data

Use GrafloriaDiagram for a kit rather than rebuilding its cards yourself. The shipped erDiagram builder returns table-card specs and a finalize function. Its entities become HTML tables with typed columns and PK/FK badges; its relationships become orthogonal edges with crow's-foot cardinality.

Pass the builder through spec$, not spec. The QRL runs in the browser, so the function-bearing result never enters server-rendered resumable state. Plain-data specs and JSON/DSL strings can use spec instead. See Documents and kits for live-object serialization rules.

A $ prop stays fixed for the life of its component. Capture a data snapshot and change the component's key when you want a new diagram. This example starts with Customer and Order tables; Add email rebuilds them with an extra Customer column. The onReady$ callback receives the mounted DiagramInstance, and fitView(40) frames its content.

Type the schema data with ErEntitySpec and ErRelationshipSpec.

tsx
import { component$, useSignal } from '@builder.io/qwik';
import { GrafloriaDiagram, type DiagramInstance } from '@grafloria/qwik';
import { erDiagram, type ErEntitySpec, type ErRelationshipSpec } from '@grafloria/element';

export default component$(() => {
  const schemaVersion = useSignal(0);
  const version = schemaVersion.value;
  const entities: ErEntitySpec[] = [
    { id: 'CUSTOMER', name: 'Customer', position: { x: 60, y: 80 },
      columns: [
        { name: 'id', type: 'int', pk: true },
        { name: 'name', type: 'varchar' },
        ...(version > 0 ? [{ name: 'email', type: 'varchar' }] : []),
      ] },
    { id: 'ORDER', name: 'Order', position: { x: 420, y: 80 },
      columns: [
        { name: 'id', type: 'int', pk: true },
        { name: 'customer_id', type: 'int', fk: true },
      ] },
  ];
  const relationships: ErRelationshipSpec[] = [
    { from: 'CUSTOMER', to: 'ORDER', label: 'places' },
  ];

  return (
    <section>
      <button disabled={version > 0} onClick$={() => { schemaVersion.value += 1; }}>
        Add email
      </button>
      <GrafloriaDiagram
        key={version}
        spec$={() => erDiagram({ entities, relationships })}
        onReady$={(instance: DiagramInstance) => { instance.fitView(40); }}
        style={{ height: '400px' }}
      />
    </section>
  );
});
Look at the CUSTOMER and ORDER tables, their PK/FK badges, and the places relationship with a crow's-foot at ORDER. The Add email button sits above the initial diagram.

The initial diagram contains two tables joined by a relationship labelled places. Add email replaces the mounted diagram; it is a rebuild, not an undoable edit of the old instance. The wrapper disposes the old instance on unmount, so do not retain it for later calls.

For editing the existing document instead of replacing it, see Edit database models.

Options that matter

RenderSpec is the shared render-input type used by spec and spec$.

OptionTypeDefaultWhat it does
nodeTypesNodeTypesNo registrationsMaps node types to Qwik components and opts matching specs into custom rendering.
NodeSpec.custombooleanInferred for registered types by the bindingAn explicit value overrides the inferred custom flag.
widgetTypesWidgetTypesNo registrationsMaps widget kinds to Qwik components; unmatched kinds use shipped painters.
specRenderSpecNot setSupplies plain data or text to the generic host.
spec$QRL<() => RenderSpec | Promise<RenderSpec>>Not setBuilds the spec in the browser; takes precedence over spec.

Give a canvas a resolved height as shown above; see Theme a canvas for sizing and appearance.

Was this page helpful?