Compare commits

...
Author SHA1 Message Date
Chris Tate ded969effd fixes 2026-03-27 16:06:16 -07:00
Chris Tate c2701e203d fixes 2026-03-25 07:50:29 -07:00
Chris Tate 96350af573 component catalog 2026-03-25 07:30:50 -07:00
Chris Tate 874146ac8d next example 2026-03-25 06:57:48 -07:00
Chris Tate 3b1d25486e next 2026-03-25 00:07:37 -07:00
52 changed files with 4141 additions and 26 deletions
+2 -1
View File
@@ -24,7 +24,8 @@
"@json-render/react-three-fiber",
"@json-render/yaml",
"@json-render/shadcn-svelte",
"@json-render/ink"
"@json-render/ink",
"@json-render/next"
]
],
"linked": [],
+3
View File
@@ -25,6 +25,8 @@ npm install @json-render/core @json-render/svelte
npm install @json-render/core @json-render/solid
# or for terminal UIs
npm install @json-render/core @json-render/ink ink react
# or for full Next.js apps (routes, layouts, SSR, metadata)
npm install @json-render/core @json-render/react @json-render/next
# or for 3D scenes
npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three
```
@@ -127,6 +129,7 @@ function Dashboard({ spec }) {
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (19 built-in components) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/next` | Next.js renderer — JSON becomes full apps with routes, layouts, SSR |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
+280
View File
@@ -0,0 +1,280 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/next")
# @json-render/next
Next.js renderer. JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.
## Installation
```bash
npm install @json-render/core @json-render/react @json-render/next
```
## schema
The Next.js app schema for multi-page specs. Use with `defineCatalog` from core.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/next/server';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
description: 'Card container',
},
NavBar: {
props: z.object({ links: z.array(z.object({ href: z.string(), label: z.string() })) }),
description: 'Navigation bar',
},
},
actions: {},
});
```
## createNextApp
Create all exports needed for a Next.js `[[...slug]]` catch-all route.
```typescript
import { createNextApp } from '@json-render/next/server';
const { Page, generateMetadata, generateStaticParams } = createNextApp({
spec: myAppSpec,
loaders: {
loadPost: async ({ slug }) => {
const post = await db.post.findUnique({ where: { slug } });
return { post };
},
},
});
```
### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>spec</code></td>
<td><code>{'NextAppSpec | (() => NextAppSpec | Promise<NextAppSpec>)'}</code></td>
<td>The application spec (static or dynamic)</td>
</tr>
<tr>
<td><code>loaders</code></td>
<td><code>{'Record<string, LoaderFn>'}</code></td>
<td>Server-side data loaders keyed by name</td>
</tr>
</tbody>
</table>
### Returns
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Page</code></td>
<td>Async Server Component for <code>page.tsx</code></td>
</tr>
<tr>
<td><code>generateMetadata</code></td>
<td>Metadata generator for Next.js SEO</td>
</tr>
<tr>
<td><code>generateStaticParams</code></td>
<td>Static params for pre-rendering at build time</td>
</tr>
</tbody>
</table>
## NextAppSpec
The top-level spec defining an entire Next.js application.
```typescript
interface NextAppSpec {
metadata?: NextMetadata;
routes: Record<string, NextRouteSpec>;
layouts?: Record<string, Spec>;
state?: Record<string, unknown>;
}
```
### Route Patterns
Routes use Next.js URL conventions:
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Example Match</th>
<th>Params</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/</code></td>
<td><code>/</code></td>
<td><code>{'{}'}</code></td>
</tr>
<tr>
<td><code>/about</code></td>
<td><code>/about</code></td>
<td><code>{'{}'}</code></td>
</tr>
<tr>
<td><code>/blog/[slug]</code></td>
<td><code>/blog/hello</code></td>
<td><code>{'{ slug: "hello" }'}</code></td>
</tr>
<tr>
<td><code>/docs/[...path]</code></td>
<td><code>/docs/a/b/c</code></td>
<td><code>{'{ path: ["a","b","c"] }'}</code></td>
</tr>
<tr>
<td><code>/app/[[...path]]</code></td>
<td><code>/app</code> or <code>/app/x/y</code></td>
<td><code>{'{ path: [] }'}</code> or <code>{'{ path: ["x","y"] }'}</code></td>
</tr>
</tbody>
</table>
## NextAppProvider
Client component that provides the component registry and action handlers to all pages.
```tsx
import { NextAppProvider } from '@json-render/next';
export default function Layout({ children }) {
return (
<NextAppProvider registry={registry} handlers={handlers}>
{children}
</NextAppProvider>
);
}
```
## Built-in Components
### Slot
Placeholder in layouts where page content is rendered. Every layout MUST include a Slot.
```json
{ "type": "Slot", "props": {}, "children": [] }
```
### Link
Client-side navigation wrapping `next/link`.
```json
{ "type": "Link", "props": { "href": "/about" }, "children": ["link-text"] }
```
## Built-in Actions
<table>
<thead>
<tr>
<th>Action</th>
<th>Params</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>setState</code></td>
<td><code>{'{ statePath, value }'}</code></td>
<td>Update a value in state</td>
</tr>
<tr>
<td><code>pushState</code></td>
<td><code>{'{ statePath, value, clearStatePath? }'}</code></td>
<td>Append to array in state</td>
</tr>
<tr>
<td><code>removeState</code></td>
<td><code>{'{ statePath, index }'}</code></td>
<td>Remove from array by index</td>
</tr>
<tr>
<td><code>navigate</code></td>
<td><code>{'{ href }'}</code></td>
<td>Client-side navigation</td>
</tr>
</tbody>
</table>
## Server Utilities
### matchRoute
Match a pathname against a spec's routes.
```typescript
import { matchRoute } from '@json-render/next/server';
const matched = matchRoute(spec, '/blog/hello-world');
// { route: NextRouteSpec, pattern: '/blog/[slug]', params: { slug: 'hello-world' } }
```
### resolveMetadata
Resolve merged metadata for a route.
```typescript
import { resolveMetadata } from '@json-render/next/server';
const metadata = resolveMetadata(spec, matchedRoute?.route);
```
### slugToPath
Convert catch-all slug array to pathname.
```typescript
import { slugToPath } from '@json-render/next/server';
slugToPath(undefined); // "/"
slugToPath(['blog', 'hello']); // "/blog/hello"
```
## Entry Points
<table>
<thead>
<tr>
<th>Import</th>
<th>Contents</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@json-render/next</code></td>
<td>Client components (NextAppProvider, PageRenderer, Link)</td>
</tr>
<tr>
<td><code>@json-render/next/server</code></td>
<td>Server utilities (createNextApp, matchRoute, schema)</td>
</tr>
</tbody>
</table>
+2 -2
View File
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, ink, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, ink, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
+1
View File
@@ -69,6 +69,7 @@ export const docsNavigation: NavSection[] = [
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/next", href: "/docs/api/next" },
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
{ title: "@json-render/react-email", href: "/docs/api/react-email" },
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
+1
View File
@@ -43,6 +43,7 @@ export const PAGE_TITLES: Record<string, string> = {
// API references
"docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API",
"docs/api/next": "@json-render/next API",
"docs/api/vue": "@json-render/vue API",
"docs/api/solid": "@json-render/solid API",
"docs/api/react-pdf": "@json-render/react-pdf API",
@@ -0,0 +1,12 @@
"use client";
import { NextAppProvider } from "@json-render/next";
import { registry } from "@/lib/registry";
export default function WebsiteLayout({
children,
}: {
children: React.ReactNode;
}) {
return <NextAppProvider registry={registry}>{children}</NextAppProvider>;
}
@@ -0,0 +1,15 @@
import { notFound } from "next/navigation";
import { getPageData, generateMetadata, generateStaticParams } from "@/lib/app";
import { WebsiteRenderer } from "./renderer";
export { generateMetadata, generateStaticParams };
export default async function Page({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const data = await getPageData({ params });
if (!data) notFound();
return <WebsiteRenderer {...data} />;
}
@@ -0,0 +1,7 @@
"use client";
import { PageRenderer, type PageRendererProps } from "@json-render/next";
export function WebsiteRenderer(props: PageRendererProps) {
return <PageRenderer {...props} />;
}
@@ -0,0 +1,11 @@
import type { NextAppSpec } from "@json-render/next";
import { getSpec, setSpec } from "@/lib/spec-store";
export async function GET() {
return Response.json(getSpec());
}
export async function PUT(req: Request) {
setSpec((await req.json()) as NextAppSpec);
return Response.json({ ok: true });
}
@@ -0,0 +1,5 @@
import { Editor } from "@/components/editor";
export default function BuilderPage() {
return <Editor />;
}
@@ -0,0 +1,101 @@
@import "tailwindcss";
@import "tw-animate-css";
@source "../../../packages/shadcn/src/**/*.tsx";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import "./globals.css";
export const metadata: Metadata = {
title: "Next Website Builder | json-render",
description:
"Build entire Next.js websites from JSON specs with @json-render/next",
icons: { icon: "/icon.svg" },
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
<body className="font-sans antialiased">{children}</body>
</html>
);
}
@@ -0,0 +1,180 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent,
} from "react";
import { JsonEditor, type JsonValue } from "@visual-json/react";
import type { NextAppSpec } from "@json-render/next";
import { NextAppProvider, PageRenderer } from "@json-render/next";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { AddressBar } from "@/components/route-tabs";
import { registry } from "@/lib/registry";
export function Editor() {
const [spec, setSpec] = useState<NextAppSpec | null>(null);
const [activeRoute, setActiveRoute] = useState("/");
const [sidebarOpen, setSidebarOpen] = useState(true);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
fetch("/api/spec")
.then((r) => r.json())
.then((data: NextAppSpec) => setSpec(data));
}, []);
const handleChange = useCallback((value: JsonValue) => {
const updated = value as unknown as NextAppSpec;
setSpec(updated);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
fetch("/api/spec", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updated),
});
}, 500);
}, []);
const handlePreviewClick = useCallback((e: MouseEvent<HTMLDivElement>) => {
const anchor = (e.target as HTMLElement).closest("a");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (!href || href.startsWith("http") || href.startsWith("mailto:")) return;
e.preventDefault();
setActiveRoute(href);
}, []);
const currentRoute = useMemo(() => {
if (!spec) return null;
return spec.routes[activeRoute] ?? null;
}, [spec, activeRoute]);
const layoutSpec = useMemo(() => {
if (!spec || !currentRoute?.layout || !spec.layouts) return null;
return spec.layouts[currentRoute.layout] ?? null;
}, [spec, currentRoute]);
const initialState = useMemo(() => {
if (!spec || !currentRoute) return undefined;
const merged: Record<string, unknown> = {};
if (spec.state) Object.assign(merged, spec.state);
if (currentRoute.page.state) Object.assign(merged, currentRoute.page.state);
return Object.keys(merged).length > 0 ? merged : undefined;
}, [spec, currentRoute]);
if (!spec) {
return (
<div className="flex items-center justify-center h-screen text-muted-foreground">
Loading...
</div>
);
}
return (
<div className="h-screen flex flex-col">
<div className="flex items-center justify-between px-4 h-12 border-b border-border bg-background shrink-0">
<span className="text-sm font-semibold">Next Website Builder</span>
<a
href="/"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
View Website
</a>
</div>
<ResizablePanelGroup orientation="horizontal" className="flex-1">
<ResizablePanel defaultSize={45} minSize={25}>
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-3 h-10 border-b border-border bg-muted/30">
<span className="text-xs font-mono text-muted-foreground">
spec.json
</span>
<button
onClick={() => setSidebarOpen((v) => !v)}
className="flex items-center justify-center w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
</button>
</div>
<div className="flex-1 overflow-auto">
<JsonEditor
value={spec as unknown as JsonValue}
onChange={handleChange}
sidebarOpen={sidebarOpen}
height="100%"
className="h-full"
style={
{
"--vj-bg": "var(--background)",
"--vj-bg-panel": "var(--background)",
"--vj-bg-hover": "var(--muted)",
"--vj-bg-selected": "var(--primary)",
"--vj-bg-selected-muted": "var(--muted)",
"--vj-text": "var(--foreground)",
"--vj-text-selected": "var(--primary-foreground)",
"--vj-text-muted": "var(--muted-foreground)",
"--vj-text-dim": "var(--muted-foreground)",
"--vj-border": "var(--border)",
"--vj-border-subtle": "var(--border)",
"--vj-accent": "var(--primary)",
"--vj-accent-muted": "var(--muted)",
"--vj-input-bg": "var(--secondary)",
"--vj-input-border": "var(--border)",
} as React.CSSProperties
}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={55} minSize={30}>
<div className="h-full flex flex-col">
<AddressBar route={activeRoute} onNavigate={setActiveRoute} />
<div
className="flex-1 overflow-auto bg-background"
onClick={handlePreviewClick}
>
{currentRoute ? (
<NextAppProvider registry={registry}>
<PageRenderer
spec={currentRoute.page}
initialState={initialState}
layoutSpec={layoutSpec}
/>
</NextAppProvider>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Route not found
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
@@ -0,0 +1,71 @@
"use client";
import { useCallback, useState, type KeyboardEvent } from "react";
interface AddressBarProps {
route: string;
onNavigate: (route: string) => void;
}
export function AddressBar({ route, onNavigate }: AddressBarProps) {
const [value, setValue] = useState(route);
const [focused, setFocused] = useState(false);
const commit = useCallback(() => {
let normalized = value.trim();
if (!normalized.startsWith("/")) normalized = "/" + normalized;
onNavigate(normalized);
setValue(normalized);
}, [value, onNavigate]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
commit();
(e.target as HTMLInputElement).blur();
}
},
[commit],
);
const displayValue = focused ? value : route;
return (
<div className="flex items-center h-10 px-2 border-b border-border bg-muted/30">
<div className="flex items-center flex-1 h-7 rounded-md border border-border bg-background px-2 gap-1.5">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground shrink-0"
>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
<input
type="text"
value={displayValue}
onChange={(e) => setValue(e.target.value)}
onFocus={() => {
setFocused(true);
setValue(route);
}}
onBlur={() => {
setFocused(false);
commit();
}}
onKeyDown={handleKeyDown}
spellCheck={false}
className="flex-1 bg-transparent text-xs font-mono text-foreground outline-none placeholder:text-muted-foreground"
placeholder="/"
/>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
);
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
+7
View File
@@ -0,0 +1,7 @@
import { createNextApp } from "@json-render/next/server";
import { getSpec } from "./spec-store";
export const { getPageData, generateMetadata, generateStaticParams } =
createNextApp({
spec: () => getSpec(),
});
@@ -0,0 +1,12 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/next/server";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { websiteComponentDefinitions } from "./website-catalog";
export const catalog = defineCatalog(schema, {
components: {
...shadcnComponentDefinitions,
...websiteComponentDefinitions,
},
actions: {},
});
@@ -0,0 +1,307 @@
import type { NextAppSpec } from "@json-render/next";
export const defaultSpec: NextAppSpec = {
metadata: {
title: {
default: "Acme Inc",
template: "%s | Acme Inc",
},
description: "We build the future of software.",
icons: "/icon.svg",
},
state: {},
layouts: {
main: {
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {
direction: "vertical",
gap: "none",
align: "stretch",
className: "min-h-screen",
},
children: ["header", "content", "footer"],
},
header: {
type: "Header",
props: {
brand: "Acme Inc",
links: [
{ label: "Home", href: "/" },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
],
variant: "simple",
},
},
content: {
type: "Slot",
props: {},
},
footer: {
type: "Footer",
props: {
brand: "Acme Inc",
links: [
{ label: "Home", href: "/" },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
],
copyright: "2026 Acme Inc. All rights reserved.",
variant: "simple",
},
},
},
},
},
routes: {
"/": {
layout: "main",
metadata: {
title: "Home",
description: "Welcome to Acme Inc - we build the future of software.",
},
page: {
root: "page",
elements: {
page: {
type: "Stack",
props: {
direction: "vertical",
gap: "none",
align: "stretch",
},
children: ["hero", "features", "testimonials", "cta"],
},
hero: {
type: "Hero",
props: {
headline: "Build the future with Acme",
description:
"We help companies ship better software, faster. Our platform provides the tools, infrastructure, and insights you need to build world-class products.",
primaryCta: { label: "Get Started", href: "/contact" },
secondaryCta: { label: "Learn More", href: "/about" },
badge: "Now in Beta",
variant: "centered",
},
},
features: {
type: "Features",
props: {
headline: "Why Acme?",
description:
"Everything you need to build, deploy, and scale your applications.",
items: [
{
title: "Lightning Fast",
description:
"Optimized for speed with edge-first architecture and smart caching.",
},
{
title: "Secure by Default",
description:
"Enterprise-grade security with end-to-end encryption and SOC 2 compliance.",
},
{
title: "Developer First",
description:
"APIs, SDKs, and CLI tools designed to fit your workflow perfectly.",
},
],
columns: 3,
variant: "cards",
},
},
testimonials: {
type: "Testimonials",
props: {
headline: "Trusted by industry leaders",
items: [
{
quote:
"Acme has completely transformed how we ship products. Our deployment time went from hours to minutes.",
author: "Jordan Lee",
role: "VP Engineering at Globex",
},
{
quote:
"The developer experience is unmatched. Our team onboarded in a single afternoon.",
author: "Priya Patel",
role: "CTO at Initech",
},
{
quote:
"We cut our infrastructure costs by 40% within the first quarter of switching to Acme.",
author: "Sam Ortiz",
role: "Head of Platform at Hooli",
},
],
},
},
cta: {
type: "CTA",
props: {
headline: "Ready to get started?",
description:
"Join thousands of teams building better software with Acme.",
buttonLabel: "Contact Us",
buttonHref: "/contact",
variant: "banner",
},
},
},
},
},
"/about": {
layout: "main",
metadata: {
title: "About",
description:
"Learn about our mission, values, and the team behind Acme.",
},
page: {
root: "page",
elements: {
page: {
type: "Stack",
props: {
direction: "vertical",
gap: "none",
align: "stretch",
},
children: ["hero", "features", "team", "cta"],
},
hero: {
type: "Hero",
props: {
headline: "About Acme Inc",
description:
"Founded in 2024, we are on a mission to make software development accessible to everyone. We believe the best tools should be simple, powerful, and delightful to use.",
variant: "centered",
primaryCta: null,
secondaryCta: null,
badge: null,
},
},
features: {
type: "Features",
props: {
headline: "Our Values",
items: [
{
title: "Simplicity",
description:
"We remove complexity so you can focus on building great products.",
},
{
title: "Transparency",
description:
"Open communication, honest pricing, and clear documentation.",
},
{
title: "Craftsmanship",
description:
"Every detail matters. We sweat the small stuff so you don't have to.",
},
],
columns: 3,
variant: "simple",
description: null,
},
},
team: {
type: "Team",
props: {
headline: "Meet the Team",
description:
"The people behind Acme who are passionate about building great developer tools.",
members: [
{
name: "Alex Chen",
role: "CEO & Co-founder",
bio: "Previously at Vercel and Google. Passionate about developer experience.",
},
{
name: "Sarah Kim",
role: "CTO & Co-founder",
bio: "Built distributed systems at AWS. Loves solving hard infrastructure problems.",
},
{
name: "Marcus Rivera",
role: "Head of Design",
bio: "Former design lead at Figma and Stripe. Obsessed with clarity and craft.",
},
],
variant: "grid",
},
},
cta: {
type: "CTA",
props: {
headline: "Want to join us?",
description:
"We are always looking for talented people to join our team.",
buttonLabel: "Get in Touch",
buttonHref: "/contact",
variant: "centered",
},
},
},
},
},
"/contact": {
layout: "main",
metadata: {
title: "Contact",
description: "Get in touch with the Acme team.",
},
page: {
root: "page",
elements: {
page: {
type: "Stack",
props: {
direction: "vertical",
gap: "none",
align: "stretch",
},
children: ["form"],
},
form: {
type: "ContactForm",
props: {
headline: "Get in Touch",
description:
"Have a question or want to work with us? Fill out the form below and we'll get back to you within 24 hours.",
fields: [
{
label: "Name",
type: "text" as const,
placeholder: "Your name",
},
{
label: "Email",
type: "email" as const,
placeholder: "you@example.com",
},
{
label: "Message",
type: "textarea" as const,
placeholder: "Tell us about your project...",
},
],
submitLabel: "Send Message",
},
},
},
},
},
},
};
@@ -0,0 +1,14 @@
"use client";
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog";
import { websiteComponents } from "./website-components";
export const { registry } = defineRegistry(catalog, {
components: {
...shadcnComponents,
...websiteComponents,
},
actions: {},
});
@@ -0,0 +1,12 @@
import type { NextAppSpec } from "@json-render/next";
import { defaultSpec } from "./default-spec";
let currentSpec: NextAppSpec = defaultSpec;
export function getSpec(): NextAppSpec {
return currentSpec;
}
export function setSpec(spec: NextAppSpec): void {
currentSpec = spec;
}
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,188 @@
import { z } from "zod";
const linkSchema = z.object({
label: z.string(),
href: z.string(),
});
const ctaSchema = z.object({
label: z.string(),
href: z.string(),
});
export const websiteComponentDefinitions = {
Header: {
props: z.object({
brand: z.string(),
links: z.array(linkSchema),
variant: z.enum(["simple", "centered"]).nullable(),
}),
description:
"Site header / navigation bar. Simple: brand left, links right. Centered: brand centered above links.",
example: {
brand: "Acme Inc",
links: [
{ label: "Home", href: "/" },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
],
variant: "simple",
},
},
Hero: {
props: z.object({
headline: z.string(),
description: z.string(),
primaryCta: ctaSchema.nullable(),
secondaryCta: ctaSchema.nullable(),
badge: z.string().nullable(),
variant: z.enum(["centered", "left-aligned"]).nullable(),
}),
description:
"Landing hero section with headline, description, and call-to-action buttons.",
example: {
headline: "Build something great",
description: "A modern platform for modern teams.",
primaryCta: { label: "Get Started", href: "/contact" },
variant: "centered",
},
},
Features: {
props: z.object({
headline: z.string().nullable(),
description: z.string().nullable(),
items: z.array(
z.object({
title: z.string(),
description: z.string(),
}),
),
columns: z.number().nullable(),
variant: z.enum(["cards", "simple"]).nullable(),
}),
description:
"Feature highlights section. Cards: bordered cards per item. Simple: clean text layout.",
example: {
headline: "Why choose us",
items: [
{ title: "Fast", description: "Blazing performance." },
{ title: "Secure", description: "Enterprise-grade security." },
{ title: "Simple", description: "Easy to use." },
],
columns: 3,
variant: "cards",
},
},
Team: {
props: z.object({
headline: z.string().nullable(),
description: z.string().nullable(),
members: z.array(
z.object({
name: z.string(),
role: z.string(),
bio: z.string().nullable(),
}),
),
variant: z.enum(["grid", "list"]).nullable(),
}),
description: "Team members section. Grid: card grid. List: vertical list.",
example: {
headline: "Our Team",
members: [
{ name: "Alice", role: "CEO", bio: null },
{ name: "Bob", role: "CTO", bio: null },
],
variant: "grid",
},
},
Testimonials: {
props: z.object({
headline: z.string().nullable(),
items: z.array(
z.object({
quote: z.string(),
author: z.string(),
role: z.string().nullable(),
}),
),
}),
description: "Customer testimonials / quotes section.",
example: {
headline: "What people say",
items: [
{
quote: "Amazing product!",
author: "Jane",
role: "CEO at Startup",
},
],
},
},
ContactForm: {
props: z.object({
headline: z.string().nullable(),
description: z.string().nullable(),
fields: z.array(
z.object({
label: z.string(),
type: z.enum(["text", "email", "textarea"]),
placeholder: z.string().nullable(),
}),
),
submitLabel: z.string(),
}),
description: "Contact form section with configurable fields.",
example: {
headline: "Get in Touch",
fields: [
{ label: "Name", type: "text", placeholder: "Your name" },
{ label: "Email", type: "email", placeholder: "you@example.com" },
{ label: "Message", type: "textarea", placeholder: "Your message" },
],
submitLabel: "Send",
},
},
Footer: {
props: z.object({
brand: z.string().nullable(),
links: z.array(linkSchema).nullable(),
copyright: z.string().nullable(),
variant: z.enum(["simple", "columns"]).nullable(),
}),
description:
"Site footer. Simple: centered single row. Columns: multi-column layout.",
example: {
brand: "Acme Inc",
copyright: "2026 Acme Inc.",
variant: "simple",
},
},
CTA: {
props: z.object({
headline: z.string(),
description: z.string().nullable(),
buttonLabel: z.string(),
buttonHref: z.string(),
variant: z.enum(["banner", "centered"]).nullable(),
}),
description:
"Call-to-action section. Banner: full-width colored background. Centered: clean centered layout.",
example: {
headline: "Ready to get started?",
buttonLabel: "Contact Us",
buttonHref: "/contact",
variant: "centered",
},
},
};
export type WebsiteProps<K extends keyof typeof websiteComponentDefinitions> =
z.output<(typeof websiteComponentDefinitions)[K]["props"]>;
@@ -0,0 +1,501 @@
"use client";
import type { BaseComponentProps } from "@json-render/react";
import type { WebsiteProps } from "./website-catalog";
// =============================================================================
// Header
// =============================================================================
function HeaderComponent({
props,
}: BaseComponentProps<WebsiteProps<"Header">>) {
const variant = props.variant ?? "simple";
const links = props.links ?? [];
if (variant === "centered") {
return (
<header className="border-b border-border bg-background">
<div className="mx-auto max-w-6xl px-6 py-6 flex flex-col items-center gap-4">
<span className="text-xl font-bold tracking-tight">
{props.brand}
</span>
<nav className="flex items-center gap-6">
{links.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
</div>
</header>
);
}
return (
<header className="border-b border-border bg-background sticky top-0 z-50">
<div className="mx-auto max-w-6xl px-6 h-16 flex items-center justify-between">
<span className="text-lg font-bold tracking-tight">{props.brand}</span>
<nav className="flex items-center gap-6">
{links.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
</div>
</header>
);
}
// =============================================================================
// Hero
// =============================================================================
function HeroComponent({ props }: BaseComponentProps<WebsiteProps<"Hero">>) {
const variant = props.variant ?? "centered";
const isLeft = variant === "left-aligned";
return (
<section className="bg-background">
<div
className={`mx-auto max-w-4xl px-6 py-24 md:py-32 ${isLeft ? "text-left" : "text-center"}`}
>
<div
className={`flex flex-col gap-6 ${isLeft ? "items-start" : "items-center"}`}
>
{props.badge && (
<span className="inline-flex items-center rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{props.badge}
</span>
)}
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl text-foreground">
{props.headline}
</h1>
<p
className={`text-lg text-muted-foreground ${isLeft ? "max-w-xl" : "max-w-2xl"}`}
>
{props.description}
</p>
{(props.primaryCta || props.secondaryCta) && (
<div className="flex flex-wrap gap-3 pt-2">
{props.primaryCta && (
<a
href={props.primaryCta.href}
className="inline-flex h-11 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
>
{props.primaryCta.label}
</a>
)}
{props.secondaryCta && (
<a
href={props.secondaryCta.href}
className="inline-flex h-11 items-center justify-center rounded-md border border-border bg-background px-6 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
{props.secondaryCta.label}
</a>
)}
</div>
)}
</div>
</div>
</section>
);
}
// =============================================================================
// Features
// =============================================================================
function FeaturesComponent({
props,
}: BaseComponentProps<WebsiteProps<"Features">>) {
const variant = props.variant ?? "cards";
const columns = props.columns ?? 3;
const items = props.items ?? [];
const colClass =
columns === 2
? "sm:grid-cols-2"
: columns === 4
? "sm:grid-cols-2 lg:grid-cols-4"
: "sm:grid-cols-2 lg:grid-cols-3";
return (
<section className="bg-background">
<div className="mx-auto max-w-6xl px-6 py-20">
{(props.headline || props.description) && (
<div className="mb-12 text-center">
{props.headline && (
<h2 className="text-3xl font-bold tracking-tight text-foreground">
{props.headline}
</h2>
)}
{props.description && (
<p className="mt-3 text-lg text-muted-foreground max-w-2xl mx-auto">
{props.description}
</p>
)}
</div>
)}
<div className={`grid grid-cols-1 gap-6 ${colClass}`}>
{items.map((item, i) =>
variant === "cards" ? (
<div
key={i}
className="rounded-lg border border-border bg-card p-6 shadow-sm"
>
<h3 className="text-lg font-semibold text-card-foreground">
{item.title}
</h3>
<p className="mt-2 text-sm text-muted-foreground">
{item.description}
</p>
</div>
) : (
<div key={i} className="py-2">
<h3 className="text-lg font-semibold text-foreground">
{item.title}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{item.description}
</p>
</div>
),
)}
</div>
</div>
</section>
);
}
// =============================================================================
// Team
// =============================================================================
function TeamComponent({ props }: BaseComponentProps<WebsiteProps<"Team">>) {
const variant = props.variant ?? "grid";
const members = props.members ?? [];
return (
<section className="bg-background">
<div className="mx-auto max-w-6xl px-6 py-20">
{(props.headline || props.description) && (
<div className="mb-12 text-center">
{props.headline && (
<h2 className="text-3xl font-bold tracking-tight text-foreground">
{props.headline}
</h2>
)}
{props.description && (
<p className="mt-3 text-lg text-muted-foreground max-w-2xl mx-auto">
{props.description}
</p>
)}
</div>
)}
{variant === "list" ? (
<div className="space-y-6 max-w-2xl mx-auto">
{members.map((m, i) => (
<div key={i} className="flex flex-col gap-1">
<h3 className="text-lg font-semibold text-foreground">
{m.name}
</h3>
<p className="text-sm text-muted-foreground">{m.role}</p>
{m.bio && (
<p className="mt-1 text-sm text-muted-foreground">{m.bio}</p>
)}
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{members.map((m, i) => (
<div
key={i}
className="rounded-lg border border-border bg-card p-6 text-center"
>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted text-lg font-bold text-muted-foreground">
{m.name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase()}
</div>
<h3 className="text-base font-semibold text-card-foreground">
{m.name}
</h3>
<p className="text-sm text-muted-foreground">{m.role}</p>
{m.bio && (
<p className="mt-2 text-sm text-muted-foreground">{m.bio}</p>
)}
</div>
))}
</div>
)}
</div>
</section>
);
}
// =============================================================================
// Testimonials
// =============================================================================
function TestimonialsComponent({
props,
}: BaseComponentProps<WebsiteProps<"Testimonials">>) {
const items = props.items ?? [];
return (
<section className="bg-muted/30">
<div className="mx-auto max-w-6xl px-6 py-20">
{props.headline && (
<h2 className="mb-12 text-center text-3xl font-bold tracking-tight text-foreground">
{props.headline}
</h2>
)}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item, i) => (
<blockquote
key={i}
className="rounded-lg border border-border bg-background p-6"
>
<p className="text-sm text-foreground leading-relaxed">
&ldquo;{item.quote}&rdquo;
</p>
<footer className="mt-4 flex flex-col">
<span className="text-sm font-semibold text-foreground">
{item.author}
</span>
{item.role && (
<span className="text-xs text-muted-foreground">
{item.role}
</span>
)}
</footer>
</blockquote>
))}
</div>
</div>
</section>
);
}
// =============================================================================
// ContactForm
// =============================================================================
function ContactFormComponent({
props,
}: BaseComponentProps<WebsiteProps<"ContactForm">>) {
const fields = props.fields ?? [];
return (
<section className="bg-background">
<div className="mx-auto max-w-xl px-6 py-20">
{(props.headline || props.description) && (
<div className="mb-8 text-center">
{props.headline && (
<h2 className="text-3xl font-bold tracking-tight text-foreground">
{props.headline}
</h2>
)}
{props.description && (
<p className="mt-3 text-muted-foreground">{props.description}</p>
)}
</div>
)}
<form
className="rounded-lg border border-border bg-card p-6 shadow-sm"
onSubmit={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-4">
{fields.map((field, i) =>
field.type === "textarea" ? (
<div key={i} className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">
{field.label}
</label>
<textarea
placeholder={field.placeholder ?? ""}
rows={4}
className="rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
) : (
<div key={i} className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">
{field.label}
</label>
<input
type={field.type}
placeholder={field.placeholder ?? ""}
className="h-10 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
),
)}
<button
type="submit"
className="mt-2 h-11 rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
>
{props.submitLabel}
</button>
</div>
</form>
</div>
</section>
);
}
// =============================================================================
// Footer
// =============================================================================
function FooterComponent({
props,
}: BaseComponentProps<WebsiteProps<"Footer">>) {
const variant = props.variant ?? "simple";
const links = props.links ?? [];
if (variant === "columns") {
return (
<footer className="border-t border-border bg-muted/30">
<div className="mx-auto max-w-6xl px-6 py-12">
<div className="flex flex-col gap-8 sm:flex-row sm:justify-between">
<div>
{props.brand && (
<span className="text-lg font-bold text-foreground">
{props.brand}
</span>
)}
</div>
{links.length > 0 && (
<nav className="flex flex-col gap-2 sm:flex-row sm:gap-6">
{links.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
)}
</div>
{props.copyright && (
<p className="mt-8 text-xs text-muted-foreground">
{props.copyright}
</p>
)}
</div>
</footer>
);
}
return (
<footer className="border-t border-border bg-muted/30">
<div className="mx-auto max-w-6xl px-6 py-10 flex flex-col items-center gap-4">
{props.brand && (
<span className="text-sm font-semibold text-foreground">
{props.brand}
</span>
)}
{links.length > 0 && (
<nav className="flex items-center gap-4">
{links.map((link) => (
<a
key={link.href}
href={link.href}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
)}
{props.copyright && (
<p className="text-xs text-muted-foreground">{props.copyright}</p>
)}
</div>
</footer>
);
}
// =============================================================================
// CTA
// =============================================================================
function CTAComponent({ props }: BaseComponentProps<WebsiteProps<"CTA">>) {
const variant = props.variant ?? "centered";
if (variant === "banner") {
return (
<section className="bg-primary text-primary-foreground">
<div className="mx-auto max-w-6xl px-6 py-16 flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
<div>
<h2 className="text-2xl font-bold">{props.headline}</h2>
{props.description && (
<p className="mt-1 text-sm opacity-90">{props.description}</p>
)}
</div>
<a
href={props.buttonHref}
className="inline-flex h-11 items-center justify-center rounded-md bg-background px-6 text-sm font-medium text-foreground shadow-sm hover:bg-background/90 transition-colors shrink-0"
>
{props.buttonLabel}
</a>
</div>
</section>
);
}
return (
<section className="bg-muted/30">
<div className="mx-auto max-w-3xl px-6 py-20 text-center">
<h2 className="text-3xl font-bold tracking-tight text-foreground">
{props.headline}
</h2>
{props.description && (
<p className="mt-3 text-lg text-muted-foreground">
{props.description}
</p>
)}
<div className="mt-8">
<a
href={props.buttonHref}
className="inline-flex h-11 items-center justify-center rounded-md bg-primary px-8 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
>
{props.buttonLabel}
</a>
</div>
</div>
</section>
);
}
// =============================================================================
// Export map
// =============================================================================
export const websiteComponents = {
Header: HeaderComponent,
Hero: HeroComponent,
Features: FeaturesComponent,
Team: TeamComponent,
Testimonials: TestimonialsComponent,
ContactForm: ContactFormComponent,
Footer: FooterComponent,
CTA: CTAComponent,
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: ["next-website-builder-demo.json-render.localhost"],
transpilePackages: [
"@json-render/core",
"@json-render/react",
"@json-render/shadcn",
],
};
export default nextConfig;
@@ -0,0 +1,41 @@
{
"name": "example-next-website-builder",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless next-website-builder-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/next": "workspace:*",
"@json-render/shadcn": "workspace:*",
"@visual-json/react": "0.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.7.0",
"lucide-react": "^1.6.0",
"next": "16.2.1",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-resizable-panels": "^4.7.6",
"tailwind-merge": "^3.5.0",
"zod": "4.3.6"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2"
}
}
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
@@ -0,0 +1,13 @@
{
"extends": "../../packages/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"declaration": false,
"declarationMap": false,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+103
View File
@@ -0,0 +1,103 @@
# @json-render/next
Next.js renderer for [@json-render/core](https://json-render.dev). JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.
## Installation
```bash
npm install @json-render/core @json-render/react @json-render/next
```
## Quick Start
### 1. Define your application spec
```typescript
import type { NextAppSpec } from "@json-render/next";
const spec: NextAppSpec = {
metadata: {
title: { default: "My App", template: "%s | My App" },
},
layouts: {
main: {
root: "shell",
elements: {
shell: { type: "Container", props: {}, children: ["nav", "slot"] },
nav: { type: "NavBar", props: {}, children: [] },
slot: { type: "Slot", props: {}, children: [] },
},
},
},
routes: {
"/": {
layout: "main",
metadata: { title: "Home" },
page: { root: "hero", elements: { hero: { type: "Card", props: { title: "Welcome" }, children: [] } } },
},
"/about": {
layout: "main",
metadata: { title: "About" },
page: { root: "content", elements: { content: { type: "Card", props: { title: "About" }, children: [] } } },
},
},
};
```
### 2. Create the app
```typescript
// lib/app.ts
import { createNextApp } from "@json-render/next/server";
export const { Page, generateMetadata, generateStaticParams } = createNextApp({ spec });
```
### 3. Wire up Next.js routes
```tsx
// app/[[...slug]]/page.tsx
export { Page as default, generateMetadata, generateStaticParams } from "@/lib/app";
```
```tsx
// app/[[...slug]]/layout.tsx
import { NextAppProvider } from "@json-render/next";
import { registry, handlers } from "@/lib/registry";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<NextAppProvider registry={registry} handlers={handlers}>
{children}
</NextAppProvider>
</body>
</html>
);
}
```
## Features
- **Pages as spec** -- Define entire multi-page apps in JSON
- **Route matching** -- Dynamic segments (`[slug]`), catch-all (`[...path]`), optional catch-all (`[[...path]]`)
- **Nested layouts** -- Reusable layouts with `Slot` component for content injection
- **SEO metadata** -- Per-route metadata with title templates, OpenGraph, Twitter cards
- **SSR** -- Server-side rendering via Next.js App Router
- **Data loaders** -- Server-side async data loading before page render
- **Static generation** -- `generateStaticParams` for pre-rendering
- **Client navigation** -- Built-in `Link` component wrapping `next/link`
- **Error/Loading/NotFound** -- Per-route error boundaries, loading states, and 404 pages
- **AI streaming** -- Generate entire apps with JSONL patches via `SpecStream`
## Entry Points
| Import | Description |
| -------------------------- | ------------------------------------------------ |
| `@json-render/next` | Client components (NextAppProvider, PageRenderer) |
| `@json-render/next/server` | Server utilities (createNextApp, matchRoute) |
## Documentation
See the [json-render documentation](https://json-render.dev/docs/api/next) for full API reference.
+71
View File
@@ -0,0 +1,71 @@
{
"name": "@json-render/next",
"version": "0.15.0",
"license": "Apache-2.0",
"description": "Next.js renderer for @json-render/core. JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.",
"keywords": [
"json",
"ui",
"next",
"nextjs",
"ai",
"generative-ui",
"llm",
"renderer",
"streaming",
"ssr",
"pages",
"routing"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/next"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.mjs",
"require": "./dist/server.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.14",
"next": "16.2.1",
"tsup": "^8.0.2",
"typescript": "^5.4.5"
},
"peerDependencies": {
"next": "^16.2.0",
"react": "^19.2.3"
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Catalog-aware types for @json-render/next.
*
* Re-exports the React catalog types since Next.js uses the same
* component and action model as @json-render/react.
*/
export type {
EventHandle,
BaseComponentProps,
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
ActionFn,
Actions,
} from "@json-render/react";
@@ -0,0 +1,69 @@
"use client";
import React from "react";
import type { Spec } from "@json-render/core";
import { Renderer, type ComponentRegistry } from "@json-render/react";
import { useNextApp } from "./provider";
/**
* Props for NextErrorBoundary.
*/
export interface NextErrorBoundaryProps {
/** The error that occurred */
error: Error & { digest?: string };
/** Function to attempt re-rendering the page */
reset: () => void;
/** Optional error UI spec from the matched route */
errorSpec?: Spec | null;
}
/**
* Error boundary component for json-render Next.js apps.
*
* If the matched route defines an `error` spec, it renders that.
* Otherwise renders a minimal default error UI.
*
* Must be used in a file with `"use client"` directive (Next.js requirement).
*/
export function NextErrorBoundary({
error,
reset,
errorSpec,
}: NextErrorBoundaryProps) {
let registry: ComponentRegistry | undefined;
try {
const ctx = useNextApp();
registry = ctx.registry;
} catch {
// Provider may not be available in error boundaries
}
if (errorSpec && registry) {
const augmentedRegistry: ComponentRegistry = {
...registry,
Slot: () => null,
};
return <Renderer spec={errorSpec} registry={augmentedRegistry} />;
}
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<h2 style={{ marginBottom: "1rem" }}>Something went wrong</h2>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
{error.message || "An unexpected error occurred."}
</p>
<button
onClick={reset}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: "1px solid #ccc",
background: "#fff",
cursor: "pointer",
}}
>
Try again
</button>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import React, { type ReactNode } from "react";
import NextLink from "next/link";
import type { ComponentRenderProps } from "@json-render/react";
/**
* Props expected by the Link component in json-render specs.
*/
export interface LinkProps {
/** The target URL or route path */
href: string;
/** Whether to replace the current history entry */
replace?: boolean;
/** Whether to prefetch the linked route */
prefetch?: boolean;
/** CSS class name */
className?: string;
/** Inline styles */
style?: React.CSSProperties;
}
/**
* Built-in Link component for json-render Next.js specs.
*
* Wraps `next/link` for client-side navigation between routes.
*
* Usage in a spec:
* ```json
* {
* "type": "Link",
* "props": { "href": "/about" },
* "children": ["link-text"]
* }
* ```
*/
export function Link({ element, children }: ComponentRenderProps<LinkProps>) {
const { href, replace, prefetch, className, style } =
element.props as LinkProps;
return (
<NextLink
href={href}
replace={replace}
prefetch={prefetch}
className={className}
style={style}
>
{children}
</NextLink>
);
}
@@ -0,0 +1,45 @@
import React from "react";
import type { Spec } from "@json-render/core";
/**
* Props for NextLoading.
*/
export interface NextLoadingProps {
/** Optional loading UI spec */
loadingSpec?: Spec | null;
}
/**
* Loading component for json-render Next.js apps.
*
* If a loading spec is provided, it will be rendered by the PageRenderer.
* Otherwise renders a minimal default loading indicator.
*
* Note: Since Next.js loading.tsx is static (no params), this renders
* a generic loading state. Route-specific loading can be achieved with
* Suspense boundaries in page content.
*/
export function NextLoading({ loadingSpec: _loadingSpec }: NextLoadingProps) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "200px",
}}
>
<div
style={{
width: "2rem",
height: "2rem",
border: "2px solid #e5e7eb",
borderTopColor: "#3b82f6",
borderRadius: "50%",
animation: "jr-spin 0.6s linear infinite",
}}
/>
<style>{`@keyframes jr-spin { to { transform: rotate(360deg) } }`}</style>
</div>
);
}
@@ -0,0 +1,27 @@
import React from "react";
/**
* Not-found component for json-render Next.js apps.
*
* Renders when the pathname does not match any route in the spec.
*/
export function NextNotFound() {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "400px",
padding: "2rem",
textAlign: "center",
}}
>
<h1 style={{ fontSize: "4rem", fontWeight: 700, margin: 0 }}>404</h1>
<p style={{ color: "#666", marginTop: "0.5rem", fontSize: "1.125rem" }}>
This page could not be found.
</p>
</div>
);
}
@@ -0,0 +1,135 @@
"use client";
import React, { useMemo, type ReactNode } from "react";
import type { Spec } from "@json-render/core";
import {
Renderer,
StateProvider,
VisibilityProvider,
ValidationProvider,
ActionProvider,
type ComponentRegistry,
type ComponentRenderProps,
} from "@json-render/react";
import { useNextApp } from "./provider";
/**
* Props for PageRenderer.
*/
export interface PageRendererProps {
/** Page element tree spec */
spec: Spec;
/** Initial state (merged from spec.state, global state, and loader data) */
initialState?: Record<string, unknown>;
/** Optional layout element tree spec */
layoutSpec?: Spec | null;
/** Whether the spec is currently loading/streaming */
loading?: boolean;
}
/**
* Built-in Slot component that renders children passed into layouts.
*/
function SlotComponent({ children }: ComponentRenderProps) {
return <>{children}</>;
}
/**
* Client component that renders a json-render page within the Next.js app.
*
* Reads the component registry and action handlers from NextAppProvider context.
* Handles layout rendering by injecting page content into a Slot component.
*/
export function PageRenderer({
spec,
initialState,
layoutSpec,
loading,
}: PageRendererProps) {
const { registry, handlers, navigate } = useNextApp();
const augmentedRegistry: ComponentRegistry = useMemo(() => {
return {
...registry,
Slot: SlotComponent,
};
}, [registry]);
const actionHandlers = useMemo(() => {
const base: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
> = { ...handlers };
base.navigate = (params: Record<string, unknown>) => {
const href = params.href as string | undefined;
if (href) navigate(href);
};
return base;
}, [handlers, navigate]);
const pageContent = (
<Renderer spec={spec} registry={augmentedRegistry} loading={loading} />
);
if (layoutSpec) {
return (
<StateProvider initialState={initialState}>
<VisibilityProvider>
<ValidationProvider>
<ActionProvider handlers={actionHandlers} navigate={navigate}>
<LayoutWithSlot
layoutSpec={layoutSpec}
registry={augmentedRegistry}
loading={loading}
>
{pageContent}
</LayoutWithSlot>
</ActionProvider>
</ValidationProvider>
</VisibilityProvider>
</StateProvider>
);
}
return (
<StateProvider initialState={initialState}>
<VisibilityProvider>
<ValidationProvider>
<ActionProvider handlers={actionHandlers} navigate={navigate}>
{pageContent}
</ActionProvider>
</ValidationProvider>
</VisibilityProvider>
</StateProvider>
);
}
/**
* Renders a layout spec and injects children into the Slot.
*/
function LayoutWithSlot({
layoutSpec,
registry,
loading,
children,
}: {
layoutSpec: Spec;
registry: ComponentRegistry;
loading?: boolean;
children: ReactNode;
}) {
const layoutRegistry: ComponentRegistry = useMemo(() => {
return {
...registry,
Slot: function LayoutSlot() {
return <>{children}</>;
},
};
}, [registry, children]);
return (
<Renderer spec={layoutSpec} registry={layoutRegistry} loading={loading} />
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import React, { createContext, useContext, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import type { ComponentRegistry } from "@json-render/react";
/**
* Context value holding the component registry, action handlers,
* and the Next.js router navigate function.
*/
export interface NextAppContextValue {
registry: ComponentRegistry;
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
navigate: (href: string) => void;
}
const NextAppContext = createContext<NextAppContextValue | null>(null);
/**
* Props for NextAppProvider.
*/
export interface NextAppProviderProps {
/** Component registry for rendering */
registry: ComponentRegistry;
/** Action handlers */
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
children: ReactNode;
}
/**
* Provider that holds the component registry and action handlers
* for the entire json-render Next.js application.
*
* Wrap your root layout with this provider:
*
* ```tsx
* // app/[[...slug]]/layout.tsx
* import { NextAppProvider } from "@json-render/next";
* import { registry, handlers } from "@/lib/registry";
*
* export default function Layout({ children }) {
* return (
* <html><body>
* <NextAppProvider registry={registry} handlers={handlers}>
* {children}
* </NextAppProvider>
* </body></html>
* );
* }
* ```
*/
export function NextAppProvider({
registry,
handlers,
children,
}: NextAppProviderProps) {
const router = useRouter();
const navigate = React.useCallback(
(href: string) => router.push(href),
[router],
);
const value = React.useMemo(
() => ({ registry, handlers, navigate }),
[registry, handlers, navigate],
);
return (
<NextAppContext.Provider value={value}>{children}</NextAppContext.Provider>
);
}
/**
* Hook to access the NextApp context.
* Must be used within a NextAppProvider.
*/
export function useNextApp(): NextAppContextValue {
const ctx = useContext(NextAppContext);
if (!ctx) {
throw new Error(
"[json-render/next] useNextApp must be used within a <NextAppProvider>. " +
"Wrap your root layout with <NextAppProvider registry={...} handlers={...}>.",
);
}
return ctx;
}
+148
View File
@@ -0,0 +1,148 @@
import { notFound } from "next/navigation";
import type {
NextAppSpec,
CreateNextAppOptions,
NextAppExports,
PageData,
} from "./types";
import { matchRoute, slugToPath, collectStaticParams } from "./router";
import { resolveMetadata, type ResolvedMetadata } from "./metadata";
/**
* Resolve the spec from the options — supports both static specs
* and factory functions that return specs (sync or async).
*/
async function resolveSpec(
specOrFn: NextAppSpec | (() => NextAppSpec | Promise<NextAppSpec>),
): Promise<NextAppSpec> {
if (typeof specOrFn === "function") {
return await specOrFn();
}
return specOrFn;
}
/**
* Merge state from multiple sources: global state, page state, and loader data.
* Later sources override earlier ones.
*/
function mergeState(
...sources: (Record<string, unknown> | undefined | null)[]
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const source of sources) {
if (source) {
Object.assign(result, source);
}
}
return result;
}
/**
* Create a fully wired Next.js application from a json-render spec.
*
* Returns `getPageData`, `generateMetadata`, and `generateStaticParams`
* ready to be used in Next.js `[[...slug]]` catch-all routes.
*
* @example
* ```typescript
* // lib/app.ts
* import { createNextApp } from "@json-render/next/server";
*
* export const { getPageData, generateMetadata, generateStaticParams } =
* createNextApp({ spec });
*
* // app/[[...slug]]/page.tsx (Server Component)
* import { notFound } from "next/navigation";
* import { getPageData, generateMetadata, generateStaticParams } from "@/lib/app";
* import { SiteRenderer } from "./renderer";
*
* export { generateMetadata, generateStaticParams };
*
* export default async function Page({ params }) {
* const data = await getPageData({ params });
* if (!data) notFound();
* return <SiteRenderer {...data} />;
* }
*
* // app/[[...slug]]/renderer.tsx ("use client")
* import { PageRenderer } from "@json-render/next";
* export function SiteRenderer(props) {
* return <PageRenderer {...props} />;
* }
* ```
*/
export function createNextApp(options: CreateNextAppOptions): NextAppExports {
const { spec: specOrFn, loaders } = options;
/**
* Resolve page data for a given set of route params.
* Returns null when no route matches.
*/
async function getPageData({
params,
}: {
params: Promise<{ slug?: string[] }>;
}): Promise<PageData | null> {
const { slug } = await params;
const pathname = slugToPath(slug);
const spec = await resolveSpec(specOrFn);
const matched = matchRoute(spec, pathname);
if (!matched) {
return null;
}
const { route } = matched;
let loaderData: Record<string, unknown> | undefined;
if (route.loader && loaders?.[route.loader]) {
loaderData = await loaders[route.loader]!(matched.params);
}
const initialState = mergeState(
spec.state,
route.page.state as Record<string, unknown> | undefined,
loaderData,
);
const layoutSpec =
route.layout && spec.layouts
? (spec.layouts[route.layout] ?? null)
: null;
return {
spec: route.page,
initialState:
Object.keys(initialState).length > 0 ? initialState : undefined,
layoutSpec,
};
}
/**
* Generate metadata for the matched route.
* Merges global spec metadata with route-specific metadata.
*/
async function generateMetadata({
params,
}: {
params: Promise<{ slug?: string[] }>;
}): Promise<ResolvedMetadata> {
const { slug } = await params;
const pathname = slugToPath(slug);
const spec = await resolveSpec(specOrFn);
const matched = matchRoute(spec, pathname);
return resolveMetadata(spec, matched?.route ?? null);
}
/**
* Generate static params for all statically-known routes.
* Used by Next.js to pre-render pages at build time.
*/
async function generateStaticParams(): Promise<{ slug: string[] }[]> {
const spec = await resolveSpec(specOrFn);
return collectStaticParams(spec);
}
return { getPageData, generateMetadata, generateStaticParams };
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
// =============================================================================
// @json-render/next — Client exports
//
// This entry point includes client components and is safe to import
// in "use client" modules and Next.js pages/layouts.
// =============================================================================
// Provider
export {
NextAppProvider,
useNextApp,
type NextAppProviderProps,
type NextAppContextValue,
} from "./components/provider";
// Page renderer
export {
PageRenderer,
type PageRendererProps,
} from "./components/page-renderer";
// Error boundary
export {
NextErrorBoundary,
type NextErrorBoundaryProps,
} from "./components/error-boundary";
// Loading
export {
NextLoading,
type NextLoadingProps,
} from "./components/loading-renderer";
// Not found
export { NextNotFound } from "./components/not-found-renderer";
// Link component
export { Link, type LinkProps } from "./components/link";
// Types (re-exported for convenience)
export type {
NextAppSpec,
NextRouteSpec,
NextMetadata,
MatchedRoute,
LoaderFn,
CreateNextAppOptions,
NextAppExports,
PageData,
} from "./types";
// Catalog types (re-exported from @json-render/react)
export type {
EventHandle,
BaseComponentProps,
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
ActionFn,
Actions,
} from "./catalog-types";
// Core types (re-exported for convenience)
export type { Spec, StateStore } from "@json-render/core";
export { createStateStore } from "@json-render/core";
export type {
ComponentRegistry,
ComponentRenderProps,
} from "@json-render/react";
+101
View File
@@ -0,0 +1,101 @@
import type { NextMetadata, NextAppSpec, NextRouteSpec } from "./types";
/**
* Metadata result compatible with Next.js's Metadata type.
* Uses plain objects to avoid importing Next.js types at runtime.
*/
export type ResolvedMetadata = Record<string, unknown>;
/**
* Resolve metadata for a route by merging global spec metadata
* with route-specific metadata.
*
* Follows Next.js metadata merging semantics:
* - Route metadata overrides global metadata for scalar fields
* - Title templates from global metadata apply to route titles
* - OpenGraph and Twitter fields are shallow-merged
*/
export function resolveMetadata(
spec: NextAppSpec,
route?: NextRouteSpec | null,
): ResolvedMetadata {
const globalMeta = spec.metadata;
const routeMeta = route?.metadata;
if (!globalMeta && !routeMeta) return {};
const result: ResolvedMetadata = {};
const mergedTitle = resolveTitle(globalMeta?.title, routeMeta?.title);
if (mergedTitle !== undefined) result.title = mergedTitle;
const description = routeMeta?.description ?? globalMeta?.description;
if (description) result.description = description;
const keywords = routeMeta?.keywords ?? globalMeta?.keywords;
if (keywords) result.keywords = keywords;
const openGraph = mergeObject(globalMeta?.openGraph, routeMeta?.openGraph);
if (openGraph) result.openGraph = openGraph;
const twitter = mergeObject(globalMeta?.twitter, routeMeta?.twitter);
if (twitter) result.twitter = twitter;
const robots = routeMeta?.robots ?? globalMeta?.robots;
if (robots) result.robots = robots;
const alternates = routeMeta?.alternates ?? globalMeta?.alternates;
if (alternates) result.alternates = alternates;
const icons = routeMeta?.icons ?? globalMeta?.icons;
if (icons) result.icons = icons;
return result;
}
/**
* Resolve a title value considering templates.
*
* If global metadata defines a title template (e.g. "%s | My App"),
* route titles are interpolated into it.
*/
function resolveTitle(
globalTitle: NextMetadata["title"],
routeTitle: NextMetadata["title"],
): unknown {
if (!routeTitle && !globalTitle) return undefined;
if (!routeTitle) {
if (typeof globalTitle === "string") return globalTitle;
if (typeof globalTitle === "object" && globalTitle !== null) {
return globalTitle.default ?? undefined;
}
return undefined;
}
if (typeof routeTitle === "object" && routeTitle !== null) {
if (routeTitle.absolute) return routeTitle.absolute;
if (routeTitle.template || routeTitle.default) return routeTitle;
return routeTitle;
}
if (
typeof globalTitle === "object" &&
globalTitle !== null &&
globalTitle.template
) {
return globalTitle.template.replace("%s", routeTitle as string);
}
return routeTitle;
}
function mergeObject(
base: Record<string, unknown> | undefined,
override: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!base && !override) return undefined;
if (!base) return override;
if (!override) return base;
return { ...base, ...override };
}
@@ -0,0 +1,4 @@
"use client";
export { PageRenderer } from "./components/page-renderer";
export type { PageRendererProps } from "./components/page-renderer";
+196
View File
@@ -0,0 +1,196 @@
import type { NextAppSpec, MatchedRoute } from "./types";
interface CompiledRoute {
pattern: string;
regex: RegExp;
paramNames: string[];
/** Whether the last segment is catch-all or optional catch-all */
catchAll: boolean;
optionalCatchAll: boolean;
/** Number of static segments (higher = more specific) */
specificity: number;
}
/**
* Compile a Next.js route pattern into a regex matcher.
*
* Supports:
* - Static segments: `/about`, `/blog`
* - Dynamic segments: `/blog/[slug]`
* - Catch-all segments: `/docs/[...path]`
* - Optional catch-all segments: `/settings/[[...path]]`
*/
function compileRoute(pattern: string): CompiledRoute {
const paramNames: string[] = [];
let catchAll = false;
let optionalCatchAll = false;
let specificity = 0;
const segments = pattern === "/" ? [""] : pattern.split("/").slice(1);
const regexParts: string[] = [];
for (const segment of segments) {
if (segment.startsWith("[[...") && segment.endsWith("]]")) {
const paramName = segment.slice(5, -2);
paramNames.push(paramName);
optionalCatchAll = true;
regexParts.push("(?:/(.+))?");
} else if (segment.startsWith("[...") && segment.endsWith("]")) {
const paramName = segment.slice(4, -1);
paramNames.push(paramName);
catchAll = true;
regexParts.push("/(.+)");
} else if (segment.startsWith("[") && segment.endsWith("]")) {
const paramName = segment.slice(1, -1);
paramNames.push(paramName);
regexParts.push("/([^/]+)");
} else {
specificity++;
regexParts.push(`/${escapeRegExp(segment)}`);
}
}
const regexStr = pattern === "/" ? "^/$" : `^${regexParts.join("")}$`;
return {
pattern,
regex: new RegExp(regexStr),
paramNames,
catchAll,
optionalCatchAll,
specificity,
};
}
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Match a pathname against a spec's routes.
*
* Routes are matched in order of specificity:
* 1. Exact/static matches first (most specific)
* 2. Dynamic segment matches
* 3. Catch-all matches (least specific)
* 4. Optional catch-all matches (fallback)
*
* @returns The matched route with extracted params, or null if no match.
*/
export function matchRoute(
spec: NextAppSpec,
pathname: string,
): MatchedRoute | null {
const normalizedPath = pathname === "" ? "/" : pathname;
const compiled = Object.keys(spec.routes).map(compileRoute);
compiled.sort((a, b) => {
if (a.optionalCatchAll !== b.optionalCatchAll) {
return a.optionalCatchAll ? 1 : -1;
}
if (a.catchAll !== b.catchAll) {
return a.catchAll ? 1 : -1;
}
if (a.specificity !== b.specificity) {
return b.specificity - a.specificity;
}
return a.paramNames.length - b.paramNames.length;
});
for (const route of compiled) {
const match = route.regex.exec(normalizedPath);
if (!match) continue;
const params: Record<string, string | string[]> = {};
for (let i = 0; i < route.paramNames.length; i++) {
const value = match[i + 1];
const name = route.paramNames[i]!;
if (route.catchAll || route.optionalCatchAll) {
params[name] = value ? value.split("/") : [];
} else {
params[name] = value ?? "";
}
}
return {
route: spec.routes[route.pattern]!,
pattern: route.pattern,
params,
};
}
return null;
}
/**
* Convert a Next.js catch-all slug array to a pathname.
*
* @example
* slugToPath(undefined) // "/"
* slugToPath([]) // "/"
* slugToPath(["blog"]) // "/blog"
* slugToPath(["blog","hi"]) // "/blog/hi"
*/
export function slugToPath(slug: string[] | undefined): string {
if (!slug || slug.length === 0) return "/";
return "/" + slug.join("/");
}
/**
* Collect all static params from the spec for generateStaticParams.
* Returns params suitable for Next.js [[...slug]] catch-all routes.
*/
export function collectStaticParams(spec: NextAppSpec): { slug: string[] }[] {
const results: { slug: string[] }[] = [];
for (const [pattern, route] of Object.entries(spec.routes)) {
if (route.staticParams) {
for (const paramSet of route.staticParams) {
const slug = buildSlugFromPattern(pattern, paramSet);
if (slug) results.push({ slug });
}
} else if (!pattern.includes("[")) {
const slug = pattern === "/" ? [] : pattern.slice(1).split("/");
results.push({ slug });
}
}
return results;
}
/**
* Build a slug array from a route pattern and a set of params.
*/
function buildSlugFromPattern(
pattern: string,
params: Record<string, string>,
): string[] | null {
if (pattern === "/") return [];
const segments = pattern.split("/").slice(1);
const result: string[] = [];
for (const segment of segments) {
if (segment.startsWith("[[...") && segment.endsWith("]]")) {
const paramName = segment.slice(5, -2);
const value = params[paramName];
if (value) result.push(...value.split("/"));
} else if (segment.startsWith("[...") && segment.endsWith("]")) {
const paramName = segment.slice(4, -1);
const value = params[paramName];
if (!value) return null;
result.push(...value.split("/"));
} else if (segment.startsWith("[") && segment.endsWith("]")) {
const paramName = segment.slice(1, -1);
const value = params[paramName];
if (!value) return null;
result.push(value);
} else {
result.push(segment);
}
}
return result;
}
+332
View File
@@ -0,0 +1,332 @@
import { defineSchema, type PromptContext } from "@json-render/core";
/**
* Prompt template for Next.js app generation.
*
* Teaches the AI to generate multi-page applications with routes,
* layouts, metadata, and page content using JSONL patches.
*/
function nextAppPromptTemplate(context: PromptContext): string {
const { catalog, options, formatZodType } = context;
const {
system = "You are a Next.js application generator.",
customRules = [],
} = options;
const lines: string[] = [];
lines.push(system);
lines.push("");
lines.push("OUTPUT FORMAT:");
lines.push(
"Output JSONL (one JSON object per line) with RFC 6902 JSON Patch operations to build a Next.js application spec.",
);
lines.push(
"The spec defines routes (pages), layouts, metadata, and state for a full Next.js app.",
);
lines.push("");
lines.push("Example output (each line is a separate JSON object):");
lines.push("");
lines.push(
`{"op":"add","path":"/metadata","value":{"title":{"default":"My App","template":"%s | My App"},"description":"A full Next.js application"}}`,
);
lines.push(`{"op":"add","path":"/layouts","value":{}}`);
lines.push(
`{"op":"add","path":"/layouts/main","value":{"root":"shell","elements":{"shell":{"type":"AppShell","props":{},"children":["nav","slot"]},"nav":{"type":"NavBar","props":{"links":[{"href":"/","label":"Home"},{"href":"/about","label":"About"}]},"children":[]},"slot":{"type":"Slot","props":{},"children":[]}}}}`,
);
lines.push(`{"op":"add","path":"/routes","value":{}}`);
lines.push(
`{"op":"add","path":"/routes/~1","value":{"layout":"main","metadata":{"title":"Home"},"page":{"root":"hero","elements":{"hero":{"type":"Card","props":{"title":"Welcome"},"children":[]}}}}}`,
);
lines.push(
`{"op":"add","path":"/routes/~1about","value":{"layout":"main","metadata":{"title":"About"},"page":{"root":"content","elements":{"content":{"type":"Card","props":{"title":"About Us"},"children":[]}}}}}`,
);
lines.push("");
lines.push("SPEC STRUCTURE:");
lines.push("The top-level spec has these fields:");
lines.push(
" - metadata: Root-level SEO metadata (title template, description, openGraph, twitter)",
);
lines.push(
" - layouts: Reusable layout element trees. Each layout MUST include a { type: 'Slot' } element where page content will be injected.",
);
lines.push(" - routes: Route definitions keyed by URL pattern");
lines.push(" - state: Global initial state shared across all routes");
lines.push("");
lines.push("ROUTES:");
lines.push("Route keys use Next.js URL patterns:");
lines.push(" - '/' - home page");
lines.push(" - '/about' - static route");
lines.push(" - '/blog/[slug]' - dynamic segment");
lines.push(" - '/docs/[...path]' - catch-all segment");
lines.push(" - '/settings/[[...path]]' - optional catch-all segment");
lines.push("");
lines.push(
"IMPORTANT: In JSON Patch paths, forward slashes in route keys must be escaped as ~1.",
);
lines.push(" - Route '/' becomes path '/routes/~1'");
lines.push(" - Route '/about' becomes path '/routes/~1about'");
lines.push(" - Route '/blog/[slug]' becomes path '/routes/~1blog~1[slug]'");
lines.push("");
lines.push("Each route has:");
lines.push(
" - page: Element tree (root + elements + optional state) — the page content",
);
lines.push(
" - metadata: Per-route SEO metadata (title, description, openGraph)",
);
lines.push(" - layout: Layout key referencing an entry in the layouts map");
lines.push(" - loading: Optional loading state element tree");
lines.push(" - error: Optional error state element tree");
lines.push(" - loader: Optional server-side data loader name");
lines.push("");
lines.push("LAYOUTS:");
lines.push(
"Layouts wrap page content. A layout element tree MUST include a component with type 'Slot' — this is where the page content will be rendered.",
);
lines.push(
"Layouts are defined once and reused across routes via the layout field.",
);
lines.push("");
lines.push("METADATA:");
lines.push("Root metadata sets defaults. Route metadata overrides per page.");
lines.push(
" - title: string or { default, template } (use %s for page title in template)",
);
lines.push(" - description: string");
lines.push(" - openGraph: { title, description, images, type }");
lines.push(" - twitter: { card, title, description }");
lines.push("");
lines.push("PAGE CONTENT:");
lines.push(
"Each page uses the standard json-render element tree format: root, elements, optional state.",
);
lines.push(
"Elements have type, props, children, and optionally visible, on, repeat, watch fields.",
);
lines.push("");
const catalogData = catalog as {
components?: Record<
string,
{
description?: string;
props?: unknown;
slots?: string[];
events?: string[];
}
>;
actions?: Record<string, { description?: string }>;
};
if (catalogData.components) {
lines.push(
`AVAILABLE COMPONENTS (${Object.keys(catalogData.components).length}):`,
);
lines.push("");
for (const [name, def] of Object.entries(catalogData.components)) {
const propsStr = def.props ? formatZodType(def.props as any) : "{}";
const hasChildren = def.slots && def.slots.length > 0;
const childrenStr = hasChildren ? " [accepts children]" : "";
const eventsStr =
def.events && def.events.length > 0
? ` [events: ${def.events.join(", ")}]`
: "";
const descStr = def.description ? ` - ${def.description}` : "";
lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`);
}
lines.push("");
lines.push("Built-in components (always available):");
lines.push(
"- Slot: {} - Placeholder in layouts where page content is rendered. Required in every layout.",
);
lines.push(
"- Link: { href: string } [accepts children] - Client-side navigation link (renders as next/link).",
);
lines.push("");
}
if (catalogData.actions && Object.keys(catalogData.actions).length > 0) {
lines.push("AVAILABLE ACTIONS:");
lines.push("");
for (const [name, def] of Object.entries(catalogData.actions)) {
lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
}
lines.push("");
}
lines.push("BUILT-IN ACTIONS:");
lines.push(
"- setState: Update a value in the state model. Params: { statePath: string, value: any }",
);
lines.push(
"- pushState: Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }",
);
lines.push(
"- removeState: Remove an item from an array by index. Params: { statePath: string, index: number }",
);
lines.push("- navigate: Navigate to a route. Params: { href: string }");
lines.push("");
lines.push("RULES:");
const baseRules = [
"Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
"First add /metadata with the app-level metadata including title template",
"Then add /layouts with reusable layout definitions (each must have a Slot component)",
"Then add /routes with each route's page, metadata, and layout reference",
"Every layout MUST include a { type: 'Slot' } element where page content is injected",
"ONLY use components listed in AVAILABLE COMPONENTS (plus built-in Slot and Link)",
"Each element needs: type, props, children (array of child keys)",
"Use unique keys for element map entries",
"Escape forward slashes in route keys as ~1 in JSON Patch paths",
"Include realistic sample data in state for data-driven pages",
"Use Link component for navigation between routes",
"Create a cohesive multi-page app with consistent layouts and navigation",
];
const allRules = [...baseRules, ...customRules];
allRules.forEach((rule, i) => {
lines.push(`${i + 1}. ${rule}`);
});
return lines.join("\n");
}
/**
* The schema for @json-render/next.
*
* Defines a multi-page Next.js application structure:
* - Spec: Routes with pages, layouts, metadata, loading/error states
* - Catalog: Components with props schemas, and optional actions
*
* This schema is fundamentally different from the React element tree schema.
* It's page-based, designed for full Next.js applications with SSR,
* routing, and metadata generation.
*/
export const schema = defineSchema(
(s) => ({
spec: s.object({
/** Root-level metadata applied as defaults */
metadata: s.object({
title: s.any(),
description: s.string(),
keywords: s.array(s.string()),
openGraph: s.any(),
twitter: s.any(),
robots: s.any(),
icons: s.any(),
}),
/** Route definitions keyed by URL pattern */
routes: s.record(
s.object({
/** Page element tree */
page: s.object({
root: s.string(),
elements: s.record(
s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
children: s.array(s.string()),
visible: s.any(),
}),
),
state: s.any(),
}),
/** Per-route metadata */
metadata: s.any(),
/** Layout key */
layout: s.string(),
/** Loading state element tree */
loading: s.any(),
/** Error state element tree */
error: s.any(),
/** Not-found element tree */
notFound: s.any(),
/** Server-side data loader name */
loader: s.string(),
/** Static params for SSG */
staticParams: s.any(),
}),
),
/** Reusable layout element trees */
layouts: s.record(
s.object({
root: s.string(),
elements: s.record(
s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
children: s.array(s.string()),
visible: s.any(),
}),
),
state: s.any(),
}),
),
/** Global initial state */
state: s.any(),
}),
catalog: s.object({
/** Component definitions */
components: s.map({
props: s.zod(),
slots: s.array(s.string()),
description: s.string(),
example: s.any(),
}),
/** Action definitions (optional) */
actions: s.map({
params: s.zod(),
description: s.string(),
}),
}),
}),
{
promptTemplate: nextAppPromptTemplate,
builtInActions: [
{
name: "setState",
description:
"Update a value in the state model at the given statePath. Params: { statePath: string, value: any }",
},
{
name: "pushState",
description:
'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
},
{
name: "removeState",
description:
"Remove an item from an array in state by index. Params: { statePath: string, index: number }",
},
{
name: "navigate",
description:
"Navigate to a route within the app. Params: { href: string }",
},
],
},
);
/**
* Type for the Next.js schema
*/
export type NextSchema = typeof schema;
/**
* Infer the spec type from a catalog
*/
export type NextSpec<TCatalog> = typeof schema extends {
createCatalog: (catalog: TCatalog) => { _specType: infer S };
}
? S
: never;
+55
View File
@@ -0,0 +1,55 @@
/**
* @json-render/next/server — Server-safe exports
*
* This entry point does not import any client components or React hooks.
* Safe to use in:
* - Server Components
* - API routes
* - Build scripts
* - Server Actions
*
* @example
* ```ts
* import { createNextApp, schema } from "@json-render/next/server";
* ```
*/
// createNextApp (main API)
export { createNextApp } from "./create-app";
// Schema (Next.js app spec format)
export { schema, type NextSchema, type NextSpec } from "./schema";
// Router utilities
export { matchRoute, slugToPath, collectStaticParams } from "./router";
// Metadata resolution
export { resolveMetadata, type ResolvedMetadata } from "./metadata";
// Types
export type {
NextAppSpec,
NextRouteSpec,
NextMetadata,
MatchedRoute,
LoaderFn,
CreateNextAppOptions,
NextAppExports,
PageData,
} from "./types";
// Catalog types (type-only, no runtime)
export type {
EventHandle,
BaseComponentProps,
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
ActionFn,
Actions,
} from "./catalog-types";
// Core types (re-exported for convenience)
export type { Spec, StateStore } from "@json-render/core";
+176
View File
@@ -0,0 +1,176 @@
import type { Spec } from "@json-render/core";
/**
* Metadata for Next.js pages and layouts.
* Maps to Next.js's Metadata type.
*/
export interface NextMetadata {
/** Page title — string or template config */
title?:
| string
| {
/** Default title when no child overrides */
default: string;
/** Template string, use %s for the page title (e.g. "%s | My App") */
template?: string;
/** Absolute title that ignores parent templates */
absolute?: string;
};
/** Meta description */
description?: string;
/** Keywords for SEO */
keywords?: string[];
/** Open Graph metadata */
openGraph?: {
title?: string;
description?: string;
images?: string | string[];
type?: string;
url?: string;
siteName?: string;
locale?: string;
};
/** Twitter card metadata */
twitter?: {
card?: "summary" | "summary_large_image" | "app" | "player";
title?: string;
description?: string;
images?: string | string[];
creator?: string;
site?: string;
};
/** Robots directives */
robots?: string | { index?: boolean; follow?: boolean };
/** Canonical URL */
alternates?: {
canonical?: string;
};
/** Favicon and icons */
icons?: string | { icon?: string; apple?: string; shortcut?: string };
}
/**
* A route definition within a NextAppSpec.
*/
export interface NextRouteSpec {
/** Page content — standard json-render element tree */
page: Spec;
/** Per-route metadata for SEO */
metadata?: NextMetadata;
/** Layout key referencing an entry in NextAppSpec.layouts */
layout?: string;
/** Loading state element tree (rendered during Suspense) */
loading?: Spec;
/** Error state element tree */
error?: Spec;
/** Not-found element tree (for dynamic routes that don't match data) */
notFound?: Spec;
/** Server-side data loader name (references loaders in createNextApp) */
loader?: string;
/** Static params for generateStaticParams */
staticParams?: Record<string, string>[];
}
/**
* The full application spec for @json-render/next.
*
* Describes an entire Next.js application: routes, pages, layouts,
* metadata, and server-side data loading.
*
* Routes are keyed by URL patterns using Next.js conventions:
* - `"/"` — exact match
* - `"/blog/[slug]"` — dynamic segment
* - `"/docs/[...path]"` — catch-all segment
* - `"/settings/[[...path]]"` — optional catch-all segment
*/
export interface NextAppSpec {
/** Root-level metadata applied to all routes (layouts can override) */
metadata?: NextMetadata;
/** Route definitions keyed by URL pattern */
routes: Record<string, NextRouteSpec>;
/**
* Reusable layout element trees. Each layout spec must include a
* `Slot` component type where page content will be injected.
*/
layouts?: Record<string, Spec>;
/** Global initial state shared across all routes */
state?: Record<string, unknown>;
}
/**
* Result of matching a pathname against the spec's routes.
*/
export interface MatchedRoute {
/** The matched route spec */
route: NextRouteSpec;
/** The URL pattern that matched (e.g. "/blog/[slug]") */
pattern: string;
/** Extracted route parameters (e.g. { slug: "hello-world" }) */
params: Record<string, string | string[]>;
}
/**
* Server-side data loader function signature.
* Receives route params and returns data to merge into initial state.
*/
export type LoaderFn = (
params: Record<string, string | string[]>,
) => Promise<Record<string, unknown>> | Record<string, unknown>;
/**
* Options for createNextApp.
*/
export interface CreateNextAppOptions {
/** The application spec */
spec: NextAppSpec | (() => NextAppSpec | Promise<NextAppSpec>);
/** Server-side data loaders keyed by name */
loaders?: Record<string, LoaderFn>;
}
/**
* Data returned by getPageData for client-side rendering.
*/
export interface PageData {
/** Page element tree spec */
spec: Spec;
/** Initial state (merged from spec.state, global state, and loader data) */
initialState?: Record<string, unknown>;
/** Optional layout element tree spec */
layoutSpec?: Spec | null;
}
/**
* The result of createNextApp — exports for Next.js route files.
*/
export interface NextAppExports {
/**
* Resolve page data for a given set of params.
* Returns null if no route matches (caller should call notFound()).
*
* Use this in your Server Component page.tsx alongside a client wrapper
* that renders PageRenderer:
*
* ```tsx
* // app/[[...slug]]/page.tsx
* import { getPageData, generateMetadata, generateStaticParams } from "@/lib/app";
* import { WebsiteRenderer } from "./renderer";
*
* export { generateMetadata, generateStaticParams };
*
* export default async function Page({ params }) {
* const data = await getPageData({ params });
* if (!data) notFound();
* return <WebsiteRenderer {...data} />;
* }
* ```
*/
getPageData: (props: {
params: Promise<{ slug?: string[] }>;
}) => Promise<PageData | null>;
/** generateMetadata function for page.tsx */
generateMetadata: (props: {
params: Promise<{ slug?: string[] }>;
}) => Promise<Record<string, unknown>>;
/** generateStaticParams function for page.tsx */
generateStaticParams: () => Promise<{ slug: string[] }[]>;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from "tsup";
const sharedExternal = [
"react",
"react-dom",
"next",
"next/link",
"next/navigation",
"@json-render/core",
"@json-render/react",
];
export default defineConfig([
{
entry: {
index: "src/index.ts",
"page-renderer-client": "src/page-renderer-client.tsx",
},
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
splitting: true,
clean: true,
banner: { js: '"use client";' },
external: sharedExternal,
},
{
entry: { server: "src/server.ts" },
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
splitting: false,
external: [...sharedExternal, /page-renderer-client/],
},
]);
+5 -2
View File
@@ -37,6 +37,7 @@ export const shadcnComponentDefinitions = {
description: z.string().nullable(),
maxWidth: z.enum(["sm", "md", "lg", "full"]).nullable(),
centered: z.boolean().nullable(),
className: z.string().nullable().describe("Additional CSS classes"),
}),
slots: ["default"],
description:
@@ -47,11 +48,12 @@ export const shadcnComponentDefinitions = {
Stack: {
props: z.object({
direction: z.enum(["horizontal", "vertical"]).nullable(),
gap: z.enum(["none", "sm", "md", "lg"]).nullable(),
gap: z.enum(["none", "sm", "md", "lg", "xl"]).nullable(),
align: z.enum(["start", "center", "end", "stretch"]).nullable(),
justify: z
.enum(["start", "center", "end", "between", "around"])
.nullable(),
className: z.string().nullable().describe("Additional CSS classes"),
}),
slots: ["default"],
description: "Flex container for layouts",
@@ -61,7 +63,8 @@ export const shadcnComponentDefinitions = {
Grid: {
props: z.object({
columns: z.number().nullable(),
gap: z.enum(["sm", "md", "lg"]).nullable(),
gap: z.enum(["sm", "md", "lg", "xl"]).nullable(),
className: z.string().nullable().describe("Additional CSS classes"),
}),
slots: ["default"],
description: "Grid layout (1-6 columns)",
+16 -3
View File
@@ -185,7 +185,7 @@ export const shadcnComponents = {
const centeredClass = props.centered ? "mx-auto" : "";
return (
<Card className={cn(maxWidthClass, centeredClass)}>
<Card className={cn(maxWidthClass, centeredClass, props.className)}>
{(props.title || props.description) && (
<CardHeader>
{props.title && <CardTitle>{props.title}</CardTitle>}
@@ -206,6 +206,7 @@ export const shadcnComponents = {
sm: "gap-2",
md: "gap-3",
lg: "gap-4",
xl: "gap-6",
};
const alignMap: Record<string, string> = {
start: "items-start",
@@ -227,7 +228,14 @@ export const shadcnComponents = {
return (
<div
className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col"} ${gapClass} ${alignClass} ${justifyClass}`}
className={cn(
"flex",
isHorizontal ? "flex-row flex-wrap" : "flex-col",
gapClass,
alignClass,
justifyClass,
props.className,
)}
>
{children}
</div>
@@ -247,13 +255,18 @@ export const shadcnComponents = {
sm: "gap-2",
md: "gap-3",
lg: "gap-4",
xl: "gap-6",
};
const n = Math.max(1, Math.min(6, props.columns ?? 1));
const cols = colsMap[n] ?? "grid-cols-1";
const gridGap = gridGapMap[props.gap ?? "md"] ?? "gap-3";
return <div className={`grid ${cols} ${gridGap}`}>{children}</div>;
return (
<div className={cn("grid", cols, gridGap, props.className)}>
{children}
</div>
);
},
Separator: ({ props }: BaseComponentProps<ShadcnProps<"Separator">>) => {
+295 -18
View File
@@ -754,6 +754,82 @@ importers:
specifier: ^2.3.0
version: 2.3.0(rollup@4.55.1)(vite@6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))
examples/next-website-builder:
dependencies:
'@json-render/core':
specifier: workspace:*
version: link:../../packages/core
'@json-render/next':
specifier: workspace:*
version: link:../../packages/next
'@json-render/react':
specifier: workspace:*
version: link:../../packages/react
'@json-render/shadcn':
specifier: workspace:*
version: link:../../packages/shadcn
'@visual-json/react':
specifier: 0.3.1
version: 0.3.1(react@19.2.4)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
geist:
specifier: ^1.7.0
version: 1.7.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
lucide-react:
specifier: ^1.6.0
version: 1.6.0(react@19.2.4)
next:
specifier: 16.2.1
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: 19.2.4
version: 19.2.4
react-dom:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
react-resizable-panels:
specifier: ^4.7.6
version: 4.7.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
zod:
specifier: 4.3.6
version: 4.3.6
devDependencies:
'@internal/typescript-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@tailwindcss/postcss':
specifier: ^4.2.2
version: 4.2.2
'@types/node':
specifier: ^22.10.0
version: 22.19.6
'@types/react':
specifier: 19.2.3
version: 19.2.3
'@types/react-dom':
specifier: 19.2.3
version: 19.2.3(@types/react@19.2.3)
tailwindcss:
specifier: ^4.2.2
version: 4.2.2
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
typescript:
specifier: ^5.7.2
version: 5.9.3
examples/no-ai:
dependencies:
'@json-render/core':
@@ -1613,7 +1689,7 @@ importers:
version: 19.2.3
tsup:
specifier: ^8.0.2
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
typescript:
specifier: ^5.4.5
version: 5.9.3
@@ -1671,6 +1747,34 @@ importers:
specifier: ^5.4.5
version: 5.9.3
packages/next:
dependencies:
'@json-render/core':
specifier: workspace:*
version: link:../core
'@json-render/react':
specifier: workspace:*
version: link:../react
react:
specifier: ^19.2.3
version: 19.2.4
devDependencies:
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/react':
specifier: 19.2.14
version: 19.2.14
next:
specifier: 16.2.1
version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
tsup:
specifier: ^8.0.2
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
typescript:
specifier: ^5.4.5
version: 5.9.3
packages/react:
dependencies:
'@json-render/core':
@@ -4314,6 +4418,9 @@ packages:
'@next/env@16.1.6':
resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
'@next/env@16.2.1':
resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==}
'@next/eslint-plugin-next@15.5.9':
resolution: {integrity: sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==}
@@ -4340,6 +4447,12 @@ packages:
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-arm64@16.2.1':
resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.1.1':
resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==}
engines: {node: '>= 10'}
@@ -4352,6 +4465,12 @@ packages:
cpu: [x64]
os: [darwin]
'@next/swc-darwin-x64@16.2.1':
resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.1.1':
resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==}
engines: {node: '>= 10'}
@@ -4366,6 +4485,13 @@ packages:
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-gnu@16.2.1':
resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.1':
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
engines: {node: '>= 10'}
@@ -4380,6 +4506,13 @@ packages:
os: [linux]
libc: [musl]
'@next/swc-linux-arm64-musl@16.2.1':
resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.1':
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
engines: {node: '>= 10'}
@@ -4394,6 +4527,13 @@ packages:
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-gnu@16.2.1':
resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.1':
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
engines: {node: '>= 10'}
@@ -4408,6 +4548,13 @@ packages:
os: [linux]
libc: [musl]
'@next/swc-linux-x64-musl@16.2.1':
resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.1':
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
engines: {node: '>= 10'}
@@ -4420,6 +4567,12 @@ packages:
cpu: [arm64]
os: [win32]
'@next/swc-win32-arm64-msvc@16.2.1':
resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.1.1':
resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==}
engines: {node: '>= 10'}
@@ -4432,6 +4585,12 @@ packages:
cpu: [x64]
os: [win32]
'@next/swc-win32-x64-msvc@16.2.1':
resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@noble/ciphers@1.3.0':
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
@@ -6868,11 +7027,19 @@ packages:
'@visual-json/core@0.1.1':
resolution: {integrity: sha512-VLARpQnjJU1SnnYk1NiXScGfp2FyAwyZS2kA8hIJV5OzkaH2oS1dTHsaB8vOiyi14/L7jsbpA4g/EwzRWbxQmg==}
'@visual-json/core@0.3.1':
resolution: {integrity: sha512-rjRbcCbKBixuQpXqVPgzKl3Nfk0QLD1NJhuHittM0ASYETozxTsou4fL6g/IWz/BaYiAAjKnrkb8aasbzJGcvg==}
'@visual-json/react@0.1.1':
resolution: {integrity: sha512-KCpFYmBhH5F+xctfvxxVX7mVZK/iwvDwh/ITNkGPzEKXTIJQ16fau+hrG7m7QoTAOxvsdhHtHoCUiEjNHXboUQ==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
'@visual-json/react@0.3.1':
resolution: {integrity: sha512-OoyKkhSs2MzS4embJZY3VPYUl8/gTn1BycWZskspHul/Sn4nfWjEUafWe6aWSeIKhuAw8HQwbw2krgTtuMM4Gg==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
'@vitejs/plugin-react@5.1.4':
resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -7425,6 +7592,11 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.10:
resolution: {integrity: sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==}
engines: {node: '>=6.0.0'}
hasBin: true
baseline-browser-mapping@2.9.14:
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
hasBin: true
@@ -10477,6 +10649,11 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
lucide-react@1.6.0:
resolution: {integrity: sha512-YxLKVCOF5ZDI1AhKQE5IBYMY9y/Nr4NT15+7QEWpsTSVCdn4vmZhww+6BP76jWYjQx8rSz1Z+gGme1f+UycWEw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
lucide-svelte@0.500.0:
resolution: {integrity: sha512-fdCJ4z9FuMoXk/ZPv4V5JhpMzQuEsoVNARXEPB0/zTX5AXA3l0x6J5sVUy9y6Rxxi3DEFpa5PmMwcXuO/ksOGw==}
peerDependencies:
@@ -11002,6 +11179,27 @@ packages:
sass:
optional: true
next@16.2.1:
resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@playwright/test': ^1.51.1
babel-plugin-react-compiler: '*'
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
sass: ^1.3.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@playwright/test':
optional: true
babel-plugin-react-compiler:
optional: true
sass:
optional: true
node-abi@3.89.0:
resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==}
engines: {node: '>=10'}
@@ -11747,6 +11945,12 @@ packages:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react-resizable-panels@4.7.6:
resolution: {integrity: sha512-w3gbmUihfvH2Ho0iV1ULS2c/E/7HW/6g0GihogsIHjZf+JmmyVnKhryB3+I4JSxO8++uD3cKsSpOVTJV+GWEuA==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
@@ -15332,7 +15536,6 @@ snapshots:
- graphql
- supports-color
- utf-8-validate
optional: true
'@expo/code-signing-certificates@0.0.6':
dependencies:
@@ -15397,7 +15600,6 @@ snapshots:
optionalDependencies:
react: 19.2.4
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
optional: true
'@expo/env@2.0.8':
dependencies:
@@ -15467,7 +15669,7 @@ snapshots:
postcss: 8.4.49
resolve-from: 5.0.0
optionalDependencies:
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -15548,7 +15750,7 @@ snapshots:
'@expo/json-file': 10.0.8
'@react-native/normalize-colors': 0.81.5
debug: 4.4.3
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
resolve-from: 5.0.0
semver: 7.7.4
xml2js: 0.6.0
@@ -15576,7 +15778,6 @@ snapshots:
expo-font: 14.0.11(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
optional: true
'@expo/ws-tunnel@1.0.6': {}
@@ -16223,6 +16424,8 @@ snapshots:
'@next/env@16.1.6': {}
'@next/env@16.2.1': {}
'@next/eslint-plugin-next@15.5.9':
dependencies:
fast-glob: 3.3.1
@@ -16240,48 +16443,72 @@ snapshots:
'@next/swc-darwin-arm64@16.1.6':
optional: true
'@next/swc-darwin-arm64@16.2.1':
optional: true
'@next/swc-darwin-x64@16.1.1':
optional: true
'@next/swc-darwin-x64@16.1.6':
optional: true
'@next/swc-darwin-x64@16.2.1':
optional: true
'@next/swc-linux-arm64-gnu@16.1.1':
optional: true
'@next/swc-linux-arm64-gnu@16.1.6':
optional: true
'@next/swc-linux-arm64-gnu@16.2.1':
optional: true
'@next/swc-linux-arm64-musl@16.1.1':
optional: true
'@next/swc-linux-arm64-musl@16.1.6':
optional: true
'@next/swc-linux-arm64-musl@16.2.1':
optional: true
'@next/swc-linux-x64-gnu@16.1.1':
optional: true
'@next/swc-linux-x64-gnu@16.1.6':
optional: true
'@next/swc-linux-x64-gnu@16.2.1':
optional: true
'@next/swc-linux-x64-musl@16.1.1':
optional: true
'@next/swc-linux-x64-musl@16.1.6':
optional: true
'@next/swc-linux-x64-musl@16.2.1':
optional: true
'@next/swc-win32-arm64-msvc@16.1.1':
optional: true
'@next/swc-win32-arm64-msvc@16.1.6':
optional: true
'@next/swc-win32-arm64-msvc@16.2.1':
optional: true
'@next/swc-win32-x64-msvc@16.1.1':
optional: true
'@next/swc-win32-x64-msvc@16.1.6':
optional: true
'@next/swc-win32-x64-msvc@16.2.1':
optional: true
'@noble/ciphers@1.3.0': {}
'@noble/curves@1.9.7':
@@ -19143,7 +19370,7 @@ snapshots:
'@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))':
dependencies:
'@types/jest': 28.1.8
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
eslint: 8.57.1
eslint-plugin-react: 7.37.5(eslint@8.57.1)
@@ -19812,7 +20039,7 @@ snapshots:
'@types/node': 22.19.6
optional: true
'@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
'@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
@@ -20060,11 +20287,18 @@ snapshots:
'@visual-json/core@0.1.1': {}
'@visual-json/core@0.3.1': {}
'@visual-json/react@0.1.1(react@19.2.3)':
dependencies:
'@visual-json/core': 0.1.1
react: 19.2.3
'@visual-json/react@0.3.1(react@19.2.4)':
dependencies:
'@visual-json/core': 0.3.1
react: 19.2.4
'@vitejs/plugin-react@5.1.4(vite@6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@babel/core': 7.29.0
@@ -20113,6 +20347,15 @@ snapshots:
msw: 2.12.10(@types/node@22.19.6)(typescript@5.9.2)
vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/mocker@4.0.17(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.17
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.10(@types/node@22.19.6)(typescript@5.9.3)
vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/mocker@4.0.17(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.0.17
@@ -20752,7 +20995,7 @@ snapshots:
resolve-from: 5.0.0
optionalDependencies:
'@babel/runtime': 7.28.6
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
transitivePeerDependencies:
- '@babel/core'
- supports-color
@@ -20787,6 +21030,8 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.10: {}
baseline-browser-mapping@2.9.14: {}
bash-tool@1.3.14(ai@6.0.103(zod@4.3.6))(just-bash@2.9.6):
@@ -22239,7 +22484,6 @@ snapshots:
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
transitivePeerDependencies:
- supports-color
optional: true
expo-clipboard@8.0.8(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
@@ -22264,7 +22508,6 @@ snapshots:
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
transitivePeerDependencies:
- supports-color
optional: true
expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)):
dependencies:
@@ -22275,7 +22518,6 @@ snapshots:
dependencies:
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
optional: true
expo-font@14.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
@@ -22290,7 +22532,6 @@ snapshots:
fontfaceobserver: 2.3.0
react: 19.2.4
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
optional: true
expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0):
dependencies:
@@ -22301,7 +22542,6 @@ snapshots:
dependencies:
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
react: 19.2.4
optional: true
expo-linking@8.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
@@ -22343,7 +22583,6 @@ snapshots:
invariant: 2.2.4
react: 19.2.4
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
optional: true
expo-router@6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.2.4(react@19.1.0))(react-native-reanimated@4.2.1(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.4.0(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.11.1(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
@@ -22506,7 +22745,6 @@ snapshots:
- graphql
- supports-color
- utf-8-validate
optional: true
exponential-backoff@3.1.3: {}
@@ -22811,6 +23049,10 @@ snapshots:
dependencies:
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
geist@1.7.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)):
dependencies:
next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
generator-function@2.0.1: {}
gensync@1.0.0-beta.2: {}
@@ -24542,6 +24784,10 @@ snapshots:
dependencies:
react: 19.2.4
lucide-react@1.6.0(react@19.2.4):
dependencies:
react: 19.2.4
lucide-svelte@0.500.0(svelte@5.53.5):
dependencies:
svelte: 5.53.5
@@ -25459,6 +25705,32 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.2.1
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.10
caniuse-lite: 1.0.30001764
postcss: 8.4.31
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4)
optionalDependencies:
'@next/swc-darwin-arm64': 16.2.1
'@next/swc-darwin-x64': 16.2.1
'@next/swc-linux-arm64-gnu': 16.2.1
'@next/swc-linux-arm64-musl': 16.2.1
'@next/swc-linux-x64-gnu': 16.2.1
'@next/swc-linux-x64-musl': 16.2.1
'@next/swc-win32-arm64-msvc': 16.2.1
'@next/swc-win32-x64-msvc': 16.2.1
'@opentelemetry/api': 1.9.0
babel-plugin-react-compiler: 1.0.0
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
node-abi@3.89.0:
dependencies:
semver: 7.7.4
@@ -26511,6 +26783,11 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-resizable-panels@4.7.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-smooth@4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
fast-equals: 5.4.0
@@ -28647,7 +28924,7 @@ snapshots:
vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.17
'@vitest/mocker': 4.0.17(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/mocker': 4.0.17(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.17
'@vitest/runner': 4.0.17
'@vitest/snapshot': 4.0.17
@@ -28807,7 +29084,7 @@ snapshots:
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
acorn: 8.16.0
browserslist: 4.28.1
chrome-trace-event: 1.0.4
enhanced-resolve: 5.19.0
+194
View File
@@ -0,0 +1,194 @@
---
name: next
description: Next.js renderer for json-render that turns JSON specs into full Next.js applications with routes, layouts, SSR, and metadata. Use when working with @json-render/next, building Next.js apps from JSON specs, or creating AI-generated multi-page applications.
---
# @json-render/next
Next.js renderer that converts JSON specs into full Next.js applications with routes, pages, layouts, metadata, and SSR support.
## Quick Start
```bash
npm install @json-render/core @json-render/react @json-render/next
```
### 1. Define Your Spec
```typescript
// lib/spec.ts
import type { NextAppSpec } from "@json-render/next";
export const spec: NextAppSpec = {
metadata: {
title: { default: "My App", template: "%s | My App" },
description: "A json-render Next.js application",
},
layouts: {
main: {
root: "shell",
elements: {
shell: { type: "Container", props: {}, children: ["nav", "slot"] },
nav: { type: "NavBar", props: { links: [
{ href: "/", label: "Home" },
{ href: "/about", label: "About" },
]}, children: [] },
slot: { type: "Slot", props: {}, children: [] },
},
},
},
routes: {
"/": {
layout: "main",
metadata: { title: "Home" },
page: {
root: "hero",
elements: {
hero: { type: "Card", props: { title: "Welcome" }, children: [] },
},
},
},
"/about": {
layout: "main",
metadata: { title: "About" },
page: {
root: "content",
elements: {
content: { type: "Card", props: { title: "About Us" }, children: [] },
},
},
},
},
};
```
### 2. Create the App
```typescript
// lib/app.ts
import { createNextApp } from "@json-render/next/server";
import { spec } from "./spec";
export const { Page, generateMetadata, generateStaticParams } = createNextApp({
spec,
loaders: {
// Server-side data loaders (optional)
loadPost: async ({ slug }) => {
const post = await getPost(slug as string);
return { post };
},
},
});
```
### 3. Wire Up Route Files
```tsx
// app/[[...slug]]/page.tsx
export { Page as default, generateMetadata, generateStaticParams } from "@/lib/app";
```
```tsx
// app/[[...slug]]/layout.tsx
import { NextAppProvider } from "@json-render/next";
import { registry, handlers } from "@/lib/registry";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<NextAppProvider registry={registry} handlers={handlers}>
{children}
</NextAppProvider>
</body>
</html>
);
}
```
## Key Concepts
### NextAppSpec
The top-level spec defines an entire Next.js application:
- **metadata**: Root-level SEO metadata (title template, description, OpenGraph)
- **layouts**: Reusable layout element trees (each must include a `Slot` component)
- **routes**: Route definitions keyed by URL pattern
- **state**: Global initial state shared across all routes
### Route Patterns
Routes use Next.js URL conventions:
- `"/"` -- home page
- `"/about"` -- static route
- `"/blog/[slug]"` -- dynamic segment
- `"/docs/[...path]"` -- catch-all segment
- `"/settings/[[...path]]"` -- optional catch-all segment
### Layouts
Layouts wrap page content. Every layout MUST include a `Slot` component where page content will be rendered. Layouts are defined once in `spec.layouts` and referenced by routes via the `layout` field.
### Built-in Components
- **Slot**: Placeholder in layouts where page content is rendered
- **Link**: Client-side navigation link (wraps `next/link`)
### Built-in Actions
- **setState**: Update state value. Params: `{ statePath, value }`
- **pushState**: Append to array. Params: `{ statePath, value, clearStatePath? }`
- **removeState**: Remove from array by index. Params: `{ statePath, index }`
- **navigate**: Client-side navigation. Params: `{ href }`
### Data Loaders
Server-side async functions that run in the Server Component before rendering. Results are merged into the page's initial state.
```typescript
createNextApp({
spec,
loaders: {
loadPost: async ({ slug }) => {
const post = await db.post.findUnique({ where: { slug } });
return { post };
},
},
});
```
### SSR
Pages are server-rendered automatically. The `createNextApp` `Page` component is an async Server Component that:
1. Matches the route from the spec
2. Runs server-side data loaders
3. Generates metadata
4. Passes the resolved spec to the client renderer for hydration
### Entry Points
- `@json-render/next` -- Client components (NextAppProvider, PageRenderer, Link)
- `@json-render/next/server` -- Server utilities (createNextApp, matchRoute, schema)
## API Reference
### Server Exports (`@json-render/next/server`)
- `createNextApp(options)` -- Create Page, generateMetadata, generateStaticParams
- `schema` -- Custom schema for Next.js apps (for AI catalog generation)
- `matchRoute(spec, pathname)` -- Match a URL to a route spec
- `resolveMetadata(spec, route)` -- Resolve metadata for a route
- `slugToPath(slug)` -- Convert catch-all slug array to pathname
- `collectStaticParams(spec)` -- Collect static params for all routes
### Client Exports (`@json-render/next`)
- `NextAppProvider` -- Context provider for registry and handlers
- `PageRenderer` -- Renders a page spec with optional layout
- `NextErrorBoundary` -- Error boundary component
- `NextLoading` -- Loading state component
- `NextNotFound` -- Not-found component
- `Link` -- Built-in navigation component (wraps next/link)