diff --git a/apps/blocks/package.json b/apps/blocks/package.json
index 3236f2c..bff5be5 100644
--- a/apps/blocks/package.json
+++ b/apps/blocks/package.json
@@ -52,6 +52,7 @@
"json-schema-to-blocks": "workspace:*",
"lucide-react": "^0.525.0",
"marked": "^16.4.2",
+ "meta-to-blocks": "workspace:*",
"motion": "^12.40.0",
"next": "^16.1.1",
"next-themes": "^0.4.6",
diff --git a/apps/blocks/src/app/blocks/documents/page.tsx b/apps/blocks/src/app/blocks/documents/page.tsx
index 8c31011..2823f9b 100644
--- a/apps/blocks/src/app/blocks/documents/page.tsx
+++ b/apps/blocks/src/app/blocks/documents/page.tsx
@@ -3,13 +3,14 @@ import type { Metadata } from 'next';
import { CodeBlock } from '@/components/docs/code-block';
import { DocSection } from '@/components/docs/doc-section';
import { DocumentFormDemo } from '@/components/documents-showcase/document-form-demo';
+import { DocumentNavDemo } from '@/components/documents-showcase/document-nav-demo';
import { OG_IMAGE, withBase } from '@/lib/site';
const TITLE = 'JSON documents';
const DESCRIPTION =
'Render a declarative JSON UI document with the default widget registry: JSON Schema, database metadata, or an agent tool produces the document, and no page hand-writes the form.';
-const INSTALL = `pnpm add blocks-schema blocks-renderer json-schema-to-blocks @constructive-io/blocks-ui`;
+const INSTALL = `pnpm add blocks-schema blocks-renderer json-schema-to-blocks meta-to-blocks @constructive-io/blocks-ui`;
const USAGE = `'use client';
@@ -37,6 +38,25 @@ export function PostForm() {
);
}`;
+const NAV = `import { DocumentRenderer } from 'blocks-renderer';
+import { defaultBlockRegistry } from '@constructive-io/blocks-ui';
+import { metaToNavDocument } from 'meta-to-blocks';
+
+// One group per schema, one link per table, join tables dropped.
+const nav = metaToNavDocument(meta.tables, {
+ href: (table) => \`/admin/\${table.schemaName}/\${table.name}\`
+});
+
+export function ConsoleSidebar({ pathname }: { pathname: string }) {
+ return (
+
+ );
+}`;
+
const OVERRIDE = `import { composeRegistry } from 'blocks-renderer';
import { defaultBlockRegistry } from '@constructive-io/blocks-ui';
@@ -95,6 +115,17 @@ export default function DocumentsPage() {
+
+
+
+ {NAV}
+
+
+
`#${table.schemaName}/${table.name}`;
+
+export function DocumentNavDemo() {
+ const [pathname, setPathname] = useState('#app_public/posts');
+ const document = useMemo(
+ () => metaToNavDocument(META, { label: 'Console', href }),
+ [],
+ );
+
+ return (
+
+
+
+ {/* The scope decides which link is current, so highlighting stays declarative. */}
+
+
+
+
+
+
+
+
+
Generated document
+
+ {JSON.stringify(document, null, 2)}
+
+
+
+ );
+}
diff --git a/packages/blocks-schema/src/node.ts b/packages/blocks-schema/src/node.ts
index 7117761..9804942 100644
--- a/packages/blocks-schema/src/node.ts
+++ b/packages/blocks-schema/src/node.ts
@@ -58,6 +58,9 @@ export const BLOCK_NODE_TYPES = [
'ActionBar',
'Markdown',
'AgentChat',
+ 'Nav',
+ 'NavGroup',
+ 'NavLink',
'Button',
'Slot',
'Fragment',
diff --git a/packages/blocks-ui/README.md b/packages/blocks-ui/README.md
index a945f3a..09fbe2d 100644
--- a/packages/blocks-ui/README.md
+++ b/packages/blocks-ui/README.md
@@ -69,8 +69,8 @@ const registry = composeRegistry(defaultBlockRegistry, {
Take a subset if the page chrome is yours: `widgetRegistry` (the form
controls), `containerRegistry` (`Page`, `Form`, `Section`, `Grid`, `Tabs`), and
-`blockRegistry` (`Button`, `ActionBar`, `Markdown`, `StatCard`) are exported
-separately.
+`blockRegistry` (`Button`, `ActionBar`, `Markdown`, `StatCard`, `Nav`,
+`NavGroup`, `NavLink`) are exported separately.
Writing an adapter from scratch needs nothing from this package — a registry is
`Record>`. `useNodeField` and `FieldShell` are
@@ -89,6 +89,24 @@ a real editor is a heavy dependency, so it belongs in the host that wants it.
`FileUpload` records the selected file name only — the byte upload needs your
storage adapter.
+## Navigation
+
+`Nav`, `NavGroup`, and `NavLink` render the navigation documents
+`meta-to-blocks`' `metaToNavDocument` lowers from `_meta`, so a console sidebar
+follows the database rather than a hand-maintained route list:
+
+```tsx
+
+```
+
+A link is a plain anchor, and the current one is whichever `href` matches
+`scope.pathname`. Give a node a `click` action (or override `NavLink` with your
+framework's `Link`) to keep client-side routing.
+
## Form state
Widgets own no state. Each one reads and writes the `DocumentRenderer` context
diff --git a/packages/blocks-ui/package.json b/packages/blocks-ui/package.json
index 3e3e0c5..69bf51a 100644
--- a/packages/blocks-ui/package.json
+++ b/packages/blocks-ui/package.json
@@ -68,6 +68,7 @@
"blocks-schema": "workspace:^",
"jsdom": "^26.1.0",
"json-schema-to-blocks": "workspace:^",
+ "meta-to-blocks": "workspace:^",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tsup": "^8.5.1",
diff --git a/packages/blocks-ui/src/__tests__/nav.test.tsx b/packages/blocks-ui/src/__tests__/nav.test.tsx
new file mode 100644
index 0000000..9d54756
--- /dev/null
+++ b/packages/blocks-ui/src/__tests__/nav.test.tsx
@@ -0,0 +1,47 @@
+import { DocumentRenderer } from 'blocks-renderer';
+import { metaToNavDocument } from 'meta-to-blocks';
+import type { MetaTable } from 'meta-to-blocks';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { defaultBlockRegistry } from '../registry';
+
+const tables: MetaTable[] = [
+ { name: 'posts', schemaName: 'app_public' },
+ { name: 'categories', schemaName: 'app_public' },
+];
+
+describe('nav blocks', () => {
+ it('renders a _meta navigation document as links, with no data source', () => {
+ render();
+
+ expect(screen.getByRole('navigation')).toBeDefined();
+ expect(screen.getByText('App public')).toBeDefined();
+ expect((screen.getByRole('link', { name: 'Posts' }) as HTMLAnchorElement).getAttribute('href')).toBe('/posts');
+ });
+
+ it('marks the link matching the scope pathname as the current page', () => {
+ render(
+
+ );
+
+ expect(screen.getByRole('link', { name: 'Categories' }).getAttribute('aria-current')).toBe('page');
+ expect(screen.getByRole('link', { name: 'Posts' }).getAttribute('aria-current')).toBeNull();
+ });
+
+ it('defers to the node action when a host owns routing', () => {
+ const onAction = vi.fn();
+ const document = metaToNavDocument([tables[0]]);
+ const link = document.page.children[0].children[0].children[0];
+ link.actions = { click: { type: 'handler', handler: 'navigate' } };
+
+ render();
+ fireEvent.click(screen.getByRole('link', { name: 'Posts' }));
+
+ expect(onAction).toHaveBeenCalledWith({ type: 'handler', handler: 'navigate' }, 'click');
+ });
+});
diff --git a/packages/blocks-ui/src/index.ts b/packages/blocks-ui/src/index.ts
index 4cb110b..6294cae 100644
--- a/packages/blocks-ui/src/index.ts
+++ b/packages/blocks-ui/src/index.ts
@@ -11,6 +11,7 @@ export {
TabBlock,
TabsBlock,
} from './containers';
+export { NavBlock, NavGroupBlock, NavLinkBlock } from './nav';
export {
CheckboxBlock,
CodeBlock,
diff --git a/packages/blocks-ui/src/nav.tsx b/packages/blocks-ui/src/nav.tsx
new file mode 100644
index 0000000..2733dd7
--- /dev/null
+++ b/packages/blocks-ui/src/nav.tsx
@@ -0,0 +1,85 @@
+'use client';
+
+/**
+ * Navigation blocks, as generated from `_meta` by `meta-to-blocks`.
+ *
+ * A link renders as a plain anchor so a document navigates without a router; a
+ * host on client-side routing gives the node a `click` action (or overrides
+ * `NavLink` with its own framework `Link`) and the anchor defers to it. The
+ * active link is whichever `href` matches `scope.pathname`, so highlighting is
+ * declarative rather than a second source of truth.
+ */
+
+import { useRenderer } from 'blocks-renderer';
+import type { BlockProps } from 'blocks-renderer';
+import type { UINodeProps } from 'blocks-schema';
+import type { MouseEvent } from 'react';
+
+function text(props: UINodeProps, ...keys: string[]): string | undefined {
+ for (const key of keys) {
+ const value = props[key];
+ if (typeof value === 'string') return value;
+ }
+ return undefined;
+}
+
+export function NavBlock({ props, children }: BlockProps) {
+ const label = text(props, 'label');
+
+ return (
+
+ );
+}
+
+export function NavGroupBlock({ props, children }: BlockProps) {
+ const label = text(props, 'label', 'title');
+ const count = typeof props.count === 'number' ? props.count : undefined;
+
+ return (
+