Bladeren bron

codegen (#15)

* codegen

* fixes

* fix lint

* address feedback
Chris Tate 5 maanden geleden
bovenliggende
commit
af9dd04085

+ 130 - 0
apps/web/app/docs/api/codegen/page.tsx

@@ -0,0 +1,130 @@
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "@json-render/codegen API | json-render",
+};
+
+export default function CodegenApiPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">@json-render/codegen</h1>
+      <p className="text-muted-foreground mb-8">
+        Utilities for generating code from UI trees.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Tree Traversal</h2>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">traverseTree</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Walk the UI tree depth-first.
+      </p>
+      <Code lang="typescript">{`function traverseTree(
+  tree: UITree,
+  visitor: TreeVisitor,
+  startKey?: string
+): void
+
+interface TreeVisitor {
+  (element: UIElement, depth: number, parent: UIElement | null): void;
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">collectUsedComponents</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Get all unique component types used in a tree.
+      </p>
+      <Code lang="typescript">{`function collectUsedComponents(tree: UITree): Set<string>
+
+// Example
+const components = collectUsedComponents(tree);
+// Set { 'Card', 'Metric', 'Chart' }`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">collectDataPaths</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Get all data paths referenced in props (valuePath, dataPath, bindPath,
+        etc.).
+      </p>
+      <Code lang="typescript">{`function collectDataPaths(tree: UITree): Set<string>
+
+// Example
+const paths = collectDataPaths(tree);
+// Set { 'analytics/revenue', 'analytics/customers' }`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">collectActions</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Get all action names used in the tree.
+      </p>
+      <Code lang="typescript">{`function collectActions(tree: UITree): Set<string>
+
+// Example
+const actions = collectActions(tree);
+// Set { 'submit_form', 'refresh_data' }`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Serialization</h2>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">serializePropValue</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Serialize a single value to a code string.
+      </p>
+      <Code lang="typescript">{`function serializePropValue(
+  value: unknown,
+  options?: SerializeOptions
+): { value: string; needsBraces: boolean }
+
+// Examples
+serializePropValue("hello")
+// { value: '"hello"', needsBraces: false }
+
+serializePropValue(42)
+// { value: '42', needsBraces: true }
+
+serializePropValue({ path: 'user/name' })
+// { value: '{ path: "user/name" }', needsBraces: true }`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">serializeProps</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Serialize a props object to a JSX attributes string.
+      </p>
+      <Code lang="typescript">{`function serializeProps(
+  props: Record<string, unknown>,
+  options?: SerializeOptions
+): string
+
+// Example
+serializeProps({ title: 'Dashboard', columns: 3, disabled: true })
+// 'title="Dashboard" columns={3} disabled'`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">escapeString</h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Escape a string for use in code.
+      </p>
+      <Code lang="typescript">{`function escapeString(
+  str: string,
+  quotes?: 'single' | 'double'
+): string`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">GeneratedFile</h3>
+      <Code lang="typescript">{`interface GeneratedFile {
+  /** File path relative to project root */
+  path: string;
+  /** File contents */
+  content: string;
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">CodeGenerator</h3>
+      <Code lang="typescript">{`interface CodeGenerator {
+  /** Generate files from a UI tree */
+  generate(tree: UITree): GeneratedFile[];
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">SerializeOptions</h3>
+      <Code lang="typescript">{`interface SerializeOptions {
+  /** Quote style for strings */
+  quotes?: 'single' | 'double';
+  /** Indent for objects/arrays */
+  indent?: number;
+}`}</Code>
+    </article>
+  );
+}

+ 170 - 0
apps/web/app/docs/code-export/page.tsx

@@ -0,0 +1,170 @@
+import { Code } from "@/components/code";
+
+export const metadata = {
+  title: "Code Export | json-render",
+};
+
+export default function CodeExportPage() {
+  return (
+    <article>
+      <h1 className="text-3xl font-bold mb-4">Code Export</h1>
+      <p className="text-muted-foreground mb-8">
+        Export generated UI as standalone code for your framework.
+      </p>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Overview</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        While json-render is designed for dynamic rendering, you can export
+        generated UI as static code. The code generation is intentionally
+        project-specific so you have full control over:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-8">
+        <li>Component templates (standalone, no json-render dependencies)</li>
+        <li>Package.json and project structure</li>
+        <li>Framework-specific patterns (Next.js, Remix, etc.)</li>
+        <li>How data is passed to components</li>
+      </ul>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Architecture</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Code export is split into two parts:
+      </p>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">
+        1. @json-render/codegen (utilities)
+      </h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Framework-agnostic utilities for building code generators:
+      </p>
+      <Code lang="typescript">{`import {
+  traverseTree,          // Walk the UI tree
+  collectUsedComponents, // Get all component types used
+  collectDataPaths,      // Get all data binding paths
+  collectActions,        // Get all action names
+  serializeProps,        // Convert props to JSX string
+} from '@json-render/codegen';`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">
+        2. Your Project (generator)
+      </h3>
+      <p className="text-sm text-muted-foreground mb-4">
+        Custom code generator specific to your project and framework:
+      </p>
+      <Code lang="typescript">{`// lib/codegen/generator.ts
+import { collectUsedComponents, serializeProps } from '@json-render/codegen';
+
+export function generateNextJSProject(tree: UITree): GeneratedFile[] {
+  const components = collectUsedComponents(tree);
+  
+  return [
+    { path: 'package.json', content: '...' },
+    { path: 'app/page.tsx', content: '...' },
+    // ... component files
+  ];
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Example: Next.js Export
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        See the dashboard example for a complete implementation that exports:
+      </p>
+      <ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
+        <li>
+          <code className="text-foreground">package.json</code> - Dependencies
+          and scripts
+        </li>
+        <li>
+          <code className="text-foreground">tsconfig.json</code> - TypeScript
+          config
+        </li>
+        <li>
+          <code className="text-foreground">next.config.js</code> - Next.js
+          config
+        </li>
+        <li>
+          <code className="text-foreground">app/layout.tsx</code> - Root layout
+        </li>
+        <li>
+          <code className="text-foreground">app/globals.css</code> - Global
+          styles
+        </li>
+        <li>
+          <code className="text-foreground">app/page.tsx</code> - Generated page
+          with data
+        </li>
+        <li>
+          <code className="text-foreground">components/ui/*.tsx</code> -
+          Standalone components
+        </li>
+      </ul>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">
+        Standalone Components
+      </h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        The exported components are standalone with no json-render dependencies.
+        They receive data as props instead of using hooks:
+      </p>
+      <Code lang="tsx">{`// Generated component (standalone)
+interface MetricProps {
+  label: string;
+  valuePath: string;
+  data?: Record<string, unknown>;
+}
+
+export function Metric({ label, valuePath, data }: MetricProps) {
+  const value = data ? getByPath(data, valuePath) : undefined;
+  return (
+    <div>
+      <span>{label}</span>
+      <span>{formatValue(value)}</span>
+    </div>
+  );
+}`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Using the Utilities</h2>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">traverseTree</h3>
+      <Code lang="typescript">{`import { traverseTree } from '@json-render/codegen';
+
+traverseTree(tree, (element, depth, parent) => {
+  console.log(' '.repeat(depth * 2) + element.type);
+});`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">collectUsedComponents</h3>
+      <Code lang="typescript">{`import { collectUsedComponents } from '@json-render/codegen';
+
+const components = collectUsedComponents(tree);
+// Set { 'Card', 'Metric', 'Chart', 'Table' }
+
+// Generate only the needed component files
+for (const component of components) {
+  files.push({
+    path: \`components/ui/\${component.toLowerCase()}.tsx\`,
+    content: componentTemplates[component],
+  });
+}`}</Code>
+
+      <h3 className="text-lg font-semibold mt-8 mb-4">serializeProps</h3>
+      <Code lang="typescript">{`import { serializeProps } from '@json-render/codegen';
+
+const propsStr = serializeProps({
+  title: 'Dashboard',
+  columns: 3,
+  disabled: true,
+});
+// 'title="Dashboard" columns={3} disabled'`}</Code>
+
+      <h2 className="text-xl font-semibold mt-12 mb-4">Try It</h2>
+      <p className="text-sm text-muted-foreground mb-4">
+        Run the dashboard example and click &quot;Export Project&quot; to see
+        code generation in action:
+      </p>
+      <Code lang="bash">{`cd examples/dashboard
+pnpm dev
+# Open http://localhost:3001
+# Generate a widget, then click "Export Project"`}</Code>
+    </article>
+  );
+}

+ 2 - 0
apps/web/app/docs/layout.tsx

@@ -26,6 +26,7 @@ const navigation = [
     items: [
       { title: "AI SDK Integration", href: "/docs/ai-sdk" },
       { title: "Streaming", href: "/docs/streaming" },
+      { title: "Code Export", href: "/docs/code-export" },
     ],
   },
   {
@@ -33,6 +34,7 @@ const navigation = [
     items: [
       { title: "@json-render/core", href: "/docs/api/core" },
       { title: "@json-render/react", href: "/docs/api/react" },
+      { title: "@json-render/codegen", href: "/docs/api/codegen" },
     ],
   },
 ];

+ 94 - 4
apps/web/app/page.tsx

@@ -150,6 +150,96 @@ export const catalog = createCatalog({
         </div>
       </section>
 
+      {/* Code Export */}
+      <section className="border-t border-border">
+        <div className="max-w-5xl mx-auto px-6 py-24">
+          <div className="text-center mb-12">
+            <h2 className="text-2xl font-semibold mb-4">Export as Code</h2>
+            <p className="text-muted-foreground max-w-2xl mx-auto">
+              Export generated UI as standalone React components. No runtime
+              dependencies required.
+            </p>
+          </div>
+          <div className="grid lg:grid-cols-2 gap-12">
+            <div className="min-w-0">
+              <h3 className="text-lg font-semibold mb-4">Generated UI Tree</h3>
+              <p className="text-muted-foreground mb-6 text-sm">
+                AI generates a JSON structure from the user&apos;s prompt.
+              </p>
+              <Code lang="json">{`{
+  "root": "card",
+  "elements": {
+    "card": {
+      "key": "card",
+      "type": "Card",
+      "props": { "title": "Revenue" },
+      "children": ["metric", "chart"]
+    },
+    "metric": {
+      "key": "metric",
+      "type": "Metric",
+      "props": {
+        "label": "Total Revenue",
+        "valuePath": "analytics/revenue",
+        "format": "currency"
+      }
+    },
+    "chart": {
+      "key": "chart",
+      "type": "Chart",
+      "props": {
+        "dataPath": "analytics/salesByRegion"
+      }
+    }
+  }
+}`}</Code>
+            </div>
+            <div className="min-w-0">
+              <h3 className="text-lg font-semibold mb-4">
+                Exported React Code
+              </h3>
+              <p className="text-muted-foreground mb-6 text-sm">
+                Export as a standalone Next.js project with all components.
+              </p>
+              <Code lang="tsx">{`"use client";
+
+import { Card, Metric, Chart } from "@/components/ui";
+
+const data = {
+  analytics: {
+    revenue: 125000,
+    salesByRegion: [
+      { label: "US", value: 45000 },
+      { label: "EU", value: 35000 },
+    ],
+  },
+};
+
+export default function Page() {
+  return (
+    <Card data={data} title="Revenue">
+      <Metric
+        data={data}
+        label="Total Revenue"
+        valuePath="analytics/revenue"
+        format="currency"
+      />
+      <Chart data={data} dataPath="analytics/salesByRegion" />
+    </Card>
+  );
+}`}</Code>
+            </div>
+          </div>
+          <div className="mt-8 text-center">
+            <p className="text-sm text-muted-foreground">
+              The export includes{" "}
+              <code className="text-foreground">package.json</code>, component
+              files, styles, and everything needed to run independently.
+            </p>
+          </div>
+        </div>
+      </section>
+
       {/* Features */}
       <section className="border-t border-border">
         <div className="max-w-5xl mx-auto px-6 py-24">
@@ -164,6 +254,10 @@ export const catalog = createCatalog({
                 title: "Streaming",
                 desc: "Progressive rendering as JSON streams from the model",
               },
+              {
+                title: "Code Export",
+                desc: "Export as standalone React code with no runtime dependencies",
+              },
               {
                 title: "Data Binding",
                 desc: "Two-way binding with JSON Pointer paths",
@@ -176,10 +270,6 @@ export const catalog = createCatalog({
                 title: "Visibility",
                 desc: "Conditional show/hide based on data or auth",
               },
-              {
-                title: "Validation",
-                desc: "Built-in and custom validation functions",
-              },
             ].map((feature) => (
               <div key={feature.title}>
                 <h3 className="font-semibold mb-2">{feature.title}</h3>

+ 19 - 10
apps/web/components/code-block.tsx

@@ -159,9 +159,16 @@ if (typeof window !== "undefined") {
 interface CodeBlockProps {
   code: string;
   lang: "json" | "tsx" | "typescript";
+  fillHeight?: boolean;
+  hideCopyButton?: boolean;
 }
 
-export function CodeBlock({ code, lang }: CodeBlockProps) {
+export function CodeBlock({
+  code,
+  lang,
+  fillHeight,
+  hideCopyButton,
+}: CodeBlockProps) {
   const [html, setHtml] = useState<string>("");
 
   useEffect(() => {
@@ -180,19 +187,21 @@ export function CodeBlock({ code, lang }: CodeBlockProps) {
   }, [code, lang]);
 
   if (!html) {
-    return null;
+    return fillHeight ? <div className="p-3" /> : null;
   }
 
   return (
-    <div className="relative group">
-      <div className="sticky top-0 float-right z-10">
-        <CopyButton
-          text={code}
-          className="opacity-0 group-hover:opacity-100 text-neutral-400"
-        />
-      </div>
+    <div className={`relative group ${fillHeight ? "p-3" : ""}`}>
+      {!hideCopyButton && (
+        <div className="float-right sticky top-3 z-10 ml-2">
+          <CopyButton
+            text={code}
+            className="opacity-0 group-hover:opacity-100 text-neutral-400"
+          />
+        </div>
+      )}
       <div
-        className="text-[11px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_pre]:border-none! [&_pre]:rounded-none! [&_pre]:text-[11px]! [&_code]:bg-transparent! [&_code]:p-0! [&_code]:rounded-none! [&_code]:text-[11px]!"
+        className="text-[11px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_pre]:border-none! [&_pre]:rounded-none! [&_pre]:text-[11px]! [&_pre]:overflow-visible! [&_code]:bg-transparent! [&_code]:p-0! [&_code]:rounded-none! [&_code]:text-[11px]!"
         dangerouslySetInnerHTML={{ __html: html }}
       />
     </div>

+ 1080 - 77
apps/web/components/demo.tsx

@@ -1,10 +1,18 @@
 "use client";
 
-import React, { useEffect, useState, useCallback, useRef } from "react";
+import React, {
+  useEffect,
+  useState,
+  useCallback,
+  useRef,
+  useMemo,
+} from "react";
 import { Renderer, useUIStream, JSONUIProvider } from "@json-render/react";
 import type { UITree } from "@json-render/core";
+import { collectUsedComponents, serializeProps } from "@json-render/codegen";
 import { toast } from "sonner";
 import { CodeBlock } from "./code-block";
+import { CopyButton } from "./copy-button";
 import { Toaster } from "./ui/sonner";
 import {
   demoRegistry,
@@ -146,26 +154,10 @@ const SIMULATION_STAGES: SimulationStage[] = [
   },
 ];
 
-const CODE_EXAMPLE = `import { Renderer, useUIStream } from '@json-render/react';
-import { registry } from './registry';
-
-function App() {
-  const { tree, isStreaming, send } = useUIStream({
-    api: '/api/generate',
-  });
-
-  return (
-    <Renderer
-      tree={tree}
-      registry={registry}
-      loading={isStreaming}
-    />
-  );
-}`;
-
 type Mode = "simulation" | "interactive";
 type Phase = "typing" | "streaming" | "complete";
-type Tab = "stream" | "json" | "code";
+type Tab = "stream" | "json";
+type RenderView = "dynamic" | "static";
 
 export function Demo() {
   const [mode, setMode] = useState<Mode>("simulation");
@@ -175,10 +167,31 @@ export function Demo() {
   const [stageIndex, setStageIndex] = useState(-1);
   const [streamLines, setStreamLines] = useState<string[]>([]);
   const [activeTab, setActiveTab] = useState<Tab>("json");
+  const [renderView, setRenderView] = useState<RenderView>("dynamic");
   const [simulationTree, setSimulationTree] = useState<UITree | null>(null);
   const [isFullscreen, setIsFullscreen] = useState(false);
+  const [showExportModal, setShowExportModal] = useState(false);
+  const [selectedExportFile, setSelectedExportFile] = useState<string | null>(
+    null,
+  );
+  const [showMobileFileTree, setShowMobileFileTree] = useState(false);
+  const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
+    new Set(),
+  );
   const inputRef = useRef<HTMLInputElement>(null);
 
+  // Disable body scroll when any modal is open
+  useEffect(() => {
+    if (isFullscreen || showExportModal) {
+      document.body.style.overflow = "hidden";
+    } else {
+      document.body.style.overflow = "";
+    }
+    return () => {
+      document.body.style.overflow = "";
+    };
+  }, [isFullscreen, showExportModal]);
+
   // Use the library's useUIStream hook for real API calls
   const {
     tree: apiTree,
@@ -300,6 +313,598 @@ export function Demo() {
     ? JSON.stringify(currentTree, null, 2)
     : "// waiting...";
 
+  // Generate all export files for Next.js project
+  const exportedFiles = useMemo(() => {
+    if (!currentTree || !currentTree.root) {
+      return [];
+    }
+
+    const tree = currentTree;
+    const components = collectUsedComponents(tree);
+    const files: { path: string; content: string }[] = [];
+
+    // Helper to generate JSX
+    function generateJSX(key: string, indent: number): string {
+      const element = tree.elements[key];
+      if (!element) return "";
+
+      const spaces = "  ".repeat(indent);
+      const componentName = element.type;
+
+      const propsObj: Record<string, unknown> = {};
+      for (const [k, v] of Object.entries(element.props)) {
+        if (v !== null && v !== undefined) {
+          propsObj[k] = v;
+        }
+      }
+
+      const propsStr = serializeProps(propsObj);
+      const hasChildren = element.children && element.children.length > 0;
+
+      if (!hasChildren) {
+        return propsStr
+          ? `${spaces}<${componentName} ${propsStr} />`
+          : `${spaces}<${componentName} />`;
+      }
+
+      const lines: string[] = [];
+      lines.push(
+        propsStr
+          ? `${spaces}<${componentName} ${propsStr}>`
+          : `${spaces}<${componentName}>`,
+      );
+
+      for (const childKey of element.children!) {
+        lines.push(generateJSX(childKey, indent + 1));
+      }
+
+      lines.push(`${spaces}</${componentName}>`);
+      return lines.join("\n");
+    }
+
+    // 1. package.json
+    files.push({
+      path: "package.json",
+      content: JSON.stringify(
+        {
+          name: "generated-app",
+          version: "0.1.0",
+          private: true,
+          scripts: {
+            dev: "next dev",
+            build: "next build",
+            start: "next start",
+          },
+          dependencies: {
+            next: "^16.1.3",
+            react: "^19.2.3",
+            "react-dom": "^19.2.3",
+          },
+          devDependencies: {
+            "@types/node": "^25.0.9",
+            "@types/react": "^19.2.8",
+            typescript: "^5.9.3",
+          },
+        },
+        null,
+        2,
+      ),
+    });
+
+    // 2. tsconfig.json
+    files.push({
+      path: "tsconfig.json",
+      content: JSON.stringify(
+        {
+          compilerOptions: {
+            target: "ES2017",
+            lib: ["dom", "dom.iterable", "esnext"],
+            allowJs: true,
+            skipLibCheck: true,
+            strict: true,
+            noEmit: true,
+            esModuleInterop: true,
+            module: "esnext",
+            moduleResolution: "bundler",
+            resolveJsonModule: true,
+            isolatedModules: true,
+            jsx: "preserve",
+            incremental: true,
+            plugins: [{ name: "next" }],
+            paths: { "@/*": ["./*"] },
+          },
+          include: ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
+          exclude: ["node_modules"],
+        },
+        null,
+        2,
+      ),
+    });
+
+    // 3. next.config.js
+    files.push({
+      path: "next.config.js",
+      content: `/** @type {import('next').NextConfig} */
+module.exports = {
+  reactStrictMode: true,
+};
+`,
+    });
+
+    // 4. app/globals.css
+    files.push({
+      path: "app/globals.css",
+      content: `@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+  --background: #ffffff;
+  --foreground: #171717;
+  --border: #e5e5e5;
+  --muted-foreground: #737373;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --background: #0a0a0a;
+    --foreground: #ededed;
+    --border: #262626;
+    --muted-foreground: #a3a3a3;
+  }
+}
+
+body {
+  background: var(--background);
+  color: var(--foreground);
+  font-family: system-ui, sans-serif;
+}
+`,
+    });
+
+    // 5. tailwind.config.js
+    files.push({
+      path: "tailwind.config.js",
+      content: `/** @type {import('tailwindcss').Config} */
+module.exports = {
+  content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
+  theme: {
+    extend: {
+      colors: {
+        background: "var(--background)",
+        foreground: "var(--foreground)",
+        border: "var(--border)",
+        "muted-foreground": "var(--muted-foreground)",
+      },
+    },
+  },
+  plugins: [],
+};
+`,
+    });
+
+    // 6. app/layout.tsx
+    files.push({
+      path: "app/layout.tsx",
+      content: `import type { Metadata } from "next";
+import "./globals.css";
+
+export const metadata: Metadata = {
+  title: "Generated App",
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en">
+      <body>{children}</body>
+    </html>
+  );
+}
+`,
+    });
+
+    // 7. Component files
+    const componentTemplates: Record<string, string> = {
+      Card: `"use client";
+
+import { ReactNode } from "react";
+
+interface CardProps {
+  title?: string;
+  description?: string;
+  maxWidth?: "sm" | "md" | "lg";
+  children?: ReactNode;
+}
+
+export function Card({ title, description, maxWidth, children }: CardProps) {
+  const widthClass = maxWidth === "sm" ? "max-w-xs" : maxWidth === "md" ? "max-w-sm" : maxWidth === "lg" ? "max-w-md" : "w-full";
+  
+  return (
+    <div className={\`border border-border rounded-lg p-4 bg-background \${widthClass}\`}>
+      {title && <div className="font-semibold text-sm mb-1">{title}</div>}
+      {description && <div className="text-xs text-muted-foreground mb-2">{description}</div>}
+      <div className="space-y-3">{children}</div>
+    </div>
+  );
+}
+`,
+      Input: `"use client";
+
+interface InputProps {
+  label?: string;
+  name?: string;
+  type?: string;
+  placeholder?: string;
+}
+
+export function Input({ label, name, type = "text", placeholder }: InputProps) {
+  return (
+    <div>
+      {label && <label className="text-xs text-muted-foreground block mb-1">{label}</label>}
+      <input
+        type={type}
+        name={name}
+        placeholder={placeholder}
+        className="h-9 w-full bg-background border border-border rounded px-3 text-sm focus:outline-none focus:ring-2 focus:ring-foreground/20"
+      />
+    </div>
+  );
+}
+`,
+      Textarea: `"use client";
+
+interface TextareaProps {
+  label?: string;
+  name?: string;
+  placeholder?: string;
+  rows?: number;
+}
+
+export function Textarea({ label, name, placeholder, rows = 3 }: TextareaProps) {
+  return (
+    <div>
+      {label && <label className="text-xs text-muted-foreground block mb-1">{label}</label>}
+      <textarea
+        name={name}
+        placeholder={placeholder}
+        rows={rows}
+        className="w-full bg-background border border-border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-foreground/20 resize-none"
+      />
+    </div>
+  );
+}
+`,
+      Button: `"use client";
+
+interface ButtonProps {
+  label: string;
+  variant?: "primary" | "secondary" | "outline";
+  onClick?: () => void;
+}
+
+export function Button({ label, variant = "primary", onClick }: ButtonProps) {
+  const baseClass = "px-4 py-2 rounded text-sm font-medium transition-colors";
+  const variantClass = variant === "primary" 
+    ? "bg-foreground text-background hover:bg-foreground/90"
+    : variant === "outline"
+    ? "border border-border hover:bg-border/50"
+    : "bg-border/50 hover:bg-border";
+    
+  return (
+    <button onClick={onClick} className={\`\${baseClass} \${variantClass}\`}>
+      {label}
+    </button>
+  );
+}
+`,
+      Text: `"use client";
+
+interface TextProps {
+  content: string;
+  variant?: "body" | "caption" | "label";
+}
+
+export function Text({ content, variant = "body" }: TextProps) {
+  const sizeClass = variant === "caption" ? "text-xs" : variant === "label" ? "text-sm font-medium" : "text-sm";
+  return <p className={\`\${sizeClass} text-muted-foreground\`}>{content}</p>;
+}
+`,
+      Heading: `"use client";
+
+interface HeadingProps {
+  text: string;
+  level?: "h1" | "h2" | "h3" | "h4";
+}
+
+export function Heading({ text, level = "h2" }: HeadingProps) {
+  const Tag = level;
+  const sizeClass = level === "h1" ? "text-2xl" : level === "h2" ? "text-xl" : level === "h3" ? "text-lg" : "text-base";
+  return <Tag className={\`\${sizeClass} font-semibold\`}>{text}</Tag>;
+}
+`,
+      Stack: `"use client";
+
+import { ReactNode } from "react";
+
+interface StackProps {
+  direction?: "horizontal" | "vertical";
+  gap?: "sm" | "md" | "lg";
+  children?: ReactNode;
+}
+
+export function Stack({ direction = "vertical", gap = "md", children }: StackProps) {
+  const gapClass = gap === "sm" ? "gap-2" : gap === "lg" ? "gap-6" : "gap-4";
+  const dirClass = direction === "horizontal" ? "flex-row" : "flex-col";
+  return <div className={\`flex \${dirClass} \${gapClass}\`}>{children}</div>;
+}
+`,
+      Grid: `"use client";
+
+import { ReactNode } from "react";
+
+interface GridProps {
+  columns?: number;
+  gap?: "sm" | "md" | "lg";
+  children?: ReactNode;
+}
+
+export function Grid({ columns = 2, gap = "md", children }: GridProps) {
+  const gapClass = gap === "sm" ? "gap-2" : gap === "lg" ? "gap-6" : "gap-4";
+  return (
+    <div className={\`grid \${gapClass}\`} style={{ gridTemplateColumns: \`repeat(\${columns}, 1fr)\` }}>
+      {children}
+    </div>
+  );
+}
+`,
+      Select: `"use client";
+
+interface SelectProps {
+  label?: string;
+  name?: string;
+  options?: Array<{ value: string; label: string }>;
+  placeholder?: string;
+}
+
+export function Select({ label, name, options = [], placeholder }: SelectProps) {
+  return (
+    <div>
+      {label && <label className="text-xs text-muted-foreground block mb-1">{label}</label>}
+      <select
+        name={name}
+        className="h-9 w-full bg-background border border-border rounded px-3 text-sm focus:outline-none focus:ring-2 focus:ring-foreground/20"
+      >
+        {placeholder && <option value="">{placeholder}</option>}
+        {options.map((opt) => (
+          <option key={opt.value} value={opt.value}>{opt.label}</option>
+        ))}
+      </select>
+    </div>
+  );
+}
+`,
+      Checkbox: `"use client";
+
+interface CheckboxProps {
+  label?: string;
+  name?: string;
+  checked?: boolean;
+}
+
+export function Checkbox({ label, name, checked }: CheckboxProps) {
+  return (
+    <label className="flex items-center gap-2 text-sm">
+      <input type="checkbox" name={name} defaultChecked={checked} className="rounded border-border" />
+      {label}
+    </label>
+  );
+}
+`,
+      Radio: `"use client";
+
+interface RadioProps {
+  label?: string;
+  name?: string;
+  options?: Array<{ value: string; label: string }>;
+}
+
+export function Radio({ label, name, options = [] }: RadioProps) {
+  return (
+    <div>
+      {label && <div className="text-xs text-muted-foreground mb-1">{label}</div>}
+      <div className="space-y-1">
+        {options.map((opt) => (
+          <label key={opt.value} className="flex items-center gap-2 text-sm">
+            <input type="radio" name={name} value={opt.value} className="border-border" />
+            {opt.label}
+          </label>
+        ))}
+      </div>
+    </div>
+  );
+}
+`,
+      Divider: `"use client";
+
+export function Divider() {
+  return <hr className="border-border my-4" />;
+}
+`,
+      Badge: `"use client";
+
+interface BadgeProps {
+  text: string;
+  variant?: "default" | "success" | "warning" | "error";
+}
+
+export function Badge({ text, variant = "default" }: BadgeProps) {
+  const colorClass = variant === "success" ? "bg-green-100 text-green-800" 
+    : variant === "warning" ? "bg-yellow-100 text-yellow-800"
+    : variant === "error" ? "bg-red-100 text-red-800"
+    : "bg-border text-foreground";
+  return <span className={\`px-2 py-0.5 rounded text-xs \${colorClass}\`}>{text}</span>;
+}
+`,
+      Switch: `"use client";
+
+interface SwitchProps {
+  label?: string;
+  name?: string;
+  checked?: boolean;
+}
+
+export function Switch({ label, name, checked }: SwitchProps) {
+  return (
+    <label className="flex items-center justify-between gap-2 text-sm">
+      {label}
+      <input type="checkbox" name={name} defaultChecked={checked} className="sr-only peer" />
+      <div className="w-9 h-5 bg-border rounded-full peer-checked:bg-foreground transition-colors relative after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:bg-background after:rounded-full after:transition-transform peer-checked:after:translate-x-4" />
+    </label>
+  );
+}
+`,
+      Rating: `"use client";
+
+interface RatingProps {
+  label?: string;
+  value?: number;
+  max?: number;
+}
+
+export function Rating({ label, value = 0, max = 5 }: RatingProps) {
+  return (
+    <div>
+      {label && <div className="text-xs text-muted-foreground mb-1">{label}</div>}
+      <div className="flex gap-1">
+        {Array.from({ length: max }).map((_, i) => (
+          <span key={i} className={\`text-lg \${i < value ? "text-yellow-400" : "text-border"}\`}>★</span>
+        ))}
+      </div>
+    </div>
+  );
+}
+`,
+      Form: `"use client";
+
+import { ReactNode } from "react";
+
+interface FormProps {
+  children?: ReactNode;
+}
+
+export function Form({ children }: FormProps) {
+  return <form className="space-y-4" onSubmit={(e) => e.preventDefault()}>{children}</form>;
+}
+`,
+    };
+
+    // Add component files
+    for (const comp of components) {
+      const template = componentTemplates[comp];
+      if (template) {
+        files.push({
+          path: `components/ui/${comp.toLowerCase()}.tsx`,
+          content: template,
+        });
+      }
+    }
+
+    // 8. components/ui/index.ts
+    const indexExports = Array.from(components)
+      .filter((c) => componentTemplates[c])
+      .map((c) => `export { ${c} } from "./${c.toLowerCase()}";`)
+      .join("\n");
+    files.push({
+      path: "components/ui/index.ts",
+      content: indexExports + "\n",
+    });
+
+    // 9. app/page.tsx
+    const jsx = generateJSX(tree.root, 2);
+    const imports = Array.from(components)
+      .filter((c) => componentTemplates[c])
+      .sort()
+      .join(", ");
+    files.push({
+      path: "app/page.tsx",
+      content: `"use client";
+
+import { ${imports} } from "@/components/ui";
+
+export default function Page() {
+  return (
+    <div className="min-h-screen p-8 flex items-center justify-center">
+${jsx}
+    </div>
+  );
+}
+`,
+    });
+
+    // 10. README.md
+    files.push({
+      path: "README.md",
+      content: `# Generated App
+
+This app was generated from a json-render UI tree.
+
+## Getting Started
+
+\`\`\`bash
+npm install
+npm run dev
+\`\`\`
+
+Open [http://localhost:3000](http://localhost:3000) to view.
+`,
+    });
+
+    return files;
+  }, [currentTree]);
+
+  // Reset state when export modal closes
+  useEffect(() => {
+    if (!showExportModal) {
+      setCollapsedFolders(new Set());
+      setSelectedExportFile(null);
+    }
+  }, [showExportModal]);
+
+  // Get active file content
+  const activeExportFile =
+    selectedExportFile ||
+    (exportedFiles.length > 0 ? exportedFiles[0]?.path : null);
+  const activeExportContent =
+    exportedFiles.find((f) => f.path === activeExportFile)?.content || "";
+
+  // Get generated page code for the code tab
+  const generatedCode =
+    exportedFiles.find((f) => f.path === "app/page.tsx")?.content ||
+    "// Generate a UI to see the code";
+
+  const downloadAllFiles = useCallback(() => {
+    const allContent = exportedFiles
+      .map((f) => `// ========== ${f.path} ==========\n${f.content}`)
+      .join("\n\n");
+    const blob = new Blob([allContent], { type: "text/plain" });
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement("a");
+    a.href = url;
+    a.download = "generated-app.txt";
+    a.click();
+    URL.revokeObjectURL(url);
+    toast("Downloaded generated-app.txt");
+  }, [exportedFiles]);
+
+  const copyFileContent = useCallback((content: string) => {
+    navigator.clipboard.writeText(content);
+    toast("Copied to clipboard");
+  }, []);
+
   const isTypingSimulation = mode === "simulation" && phase === "typing";
   const isStreamingSimulation = mode === "simulation" && phase === "streaming";
   const showLoadingDots = isStreamingSimulation || isStreaming;
@@ -405,7 +1010,7 @@ export function Demo() {
         {/* Tabbed code/stream/json panel */}
         <div className="min-w-0">
           <div className="flex items-center gap-4 mb-2 h-6">
-            {(["json", "stream", "code"] as const).map((tab) => (
+            {(["json", "stream"] as const).map((tab) => (
               <button
                 key={tab}
                 onClick={() => setActiveTab(tab)}
@@ -419,13 +1024,28 @@ export function Demo() {
               </button>
             ))}
           </div>
-          <div className="border border-border rounded p-3 bg-background font-mono text-xs h-96 overflow-auto text-left">
-            <div className={activeTab === "stream" ? "" : "hidden"}>
+          <div className="border border-border rounded bg-background font-mono text-xs h-96 text-left grid relative group">
+            <div className="absolute top-2 right-2 z-10">
+              <CopyButton
+                text={
+                  activeTab === "stream" ? streamLines.join("\n") : jsonCode
+                }
+                className="opacity-0 group-hover:opacity-100 text-muted-foreground"
+              />
+            </div>
+            <div
+              className={`overflow-auto h-full ${activeTab === "stream" ? "" : "hidden"}`}
+            >
               {streamLines.length > 0 ? (
                 <>
-                  <CodeBlock code={streamLines.join("\n")} lang="json" />
+                  <CodeBlock
+                    code={streamLines.join("\n")}
+                    lang="json"
+                    fillHeight
+                    hideCopyButton
+                  />
                   {showLoadingDots && (
-                    <div className="flex gap-1 mt-2">
+                    <div className="flex gap-1 p-3 pt-0">
                       <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse" />
                       <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:75ms]" />
                       <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:150ms]" />
@@ -433,16 +1053,20 @@ export function Demo() {
                   )}
                 </>
               ) : (
-                <div className="text-muted-foreground/50">
+                <div className="text-muted-foreground/50 p-3 h-full">
                   {showLoadingDots ? "streaming..." : "waiting..."}
                 </div>
               )}
             </div>
-            <div className={activeTab === "json" ? "" : "hidden"}>
-              <CodeBlock code={jsonCode} lang="json" />
-            </div>
-            <div className={activeTab === "code" ? "" : "hidden"}>
-              <CodeBlock code={CODE_EXAMPLE} lang="tsx" />
+            <div
+              className={`overflow-auto h-full ${activeTab === "json" ? "" : "hidden"}`}
+            >
+              <CodeBlock
+                code={jsonCode}
+                lang="json"
+                fillHeight
+                hideCopyButton
+              />
             </div>
           </div>
         </div>
@@ -450,58 +1074,108 @@ export function Demo() {
         {/* Rendered output using json-render */}
         <div className="min-w-0">
           <div className="flex items-center justify-between mb-2 h-6">
-            <div className="text-xs text-muted-foreground font-mono">
-              render
+            <div className="flex items-center gap-4">
+              {(
+                [
+                  { key: "dynamic", label: "live render" },
+                  { key: "static", label: "static code" },
+                ] as const
+              ).map(({ key, label }) => (
+                <button
+                  key={key}
+                  onClick={() => setRenderView(key)}
+                  className={`text-xs font-mono transition-colors ${
+                    renderView === key
+                      ? "text-foreground"
+                      : "text-muted-foreground hover:text-foreground"
+                  }`}
+                >
+                  {label}
+                </button>
+              ))}
             </div>
-            <button
-              onClick={() => setIsFullscreen(true)}
-              className="text-muted-foreground hover:text-foreground transition-colors"
-              aria-label="Maximize"
-            >
-              <svg
-                width="14"
-                height="14"
-                viewBox="0 0 24 24"
-                fill="none"
-                stroke="currentColor"
-                strokeWidth="2"
-                strokeLinecap="round"
-                strokeLinejoin="round"
+            <div className="flex items-center gap-2">
+              <button
+                onClick={() => setShowExportModal(true)}
+                disabled={!currentTree?.root}
+                className="text-xs font-mono text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
+                title="Export as Next.js project"
               >
-                <path d="M8 3H5a2 2 0 0 0-2 2v3" />
-                <path d="M21 8V5a2 2 0 0 0-2-2h-3" />
-                <path d="M3 16v3a2 2 0 0 0 2 2h3" />
-                <path d="M16 21h3a2 2 0 0 0 2-2v-3" />
-              </svg>
-            </button>
-          </div>
-          <div className="border border-border rounded p-3 bg-background h-96 overflow-auto">
-            {currentTree && currentTree.root ? (
-              <div className="animate-in fade-in duration-200 w-full min-h-full flex items-center justify-center py-4">
-                <JSONUIProvider
-                  registry={
-                    demoRegistry as Parameters<
-                      typeof JSONUIProvider
-                    >[0]["registry"]
-                  }
+                export
+              </button>
+              <button
+                onClick={() => setIsFullscreen(true)}
+                className="text-muted-foreground hover:text-foreground transition-colors"
+                aria-label="Maximize"
+              >
+                <svg
+                  width="14"
+                  height="14"
+                  viewBox="0 0 24 24"
+                  fill="none"
+                  stroke="currentColor"
+                  strokeWidth="2"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
                 >
-                  <Renderer
-                    tree={currentTree}
-                    registry={
-                      demoRegistry as Parameters<typeof Renderer>[0]["registry"]
-                    }
-                    loading={isStreaming || isStreamingSimulation}
-                    fallback={
-                      fallbackComponent as Parameters<
-                        typeof Renderer
-                      >[0]["fallback"]
-                    }
-                  />
-                </JSONUIProvider>
+                  <path d="M8 3H5a2 2 0 0 0-2 2v3" />
+                  <path d="M21 8V5a2 2 0 0 0-2-2h-3" />
+                  <path d="M3 16v3a2 2 0 0 0 2 2h3" />
+                  <path d="M16 21h3a2 2 0 0 0 2-2v-3" />
+                </svg>
+              </button>
+            </div>
+          </div>
+          <div className="border border-border rounded bg-background h-96 grid relative group">
+            {renderView === "static" && (
+              <div className="absolute top-2 right-2 z-10">
+                <CopyButton
+                  text={generatedCode}
+                  className="opacity-0 group-hover:opacity-100 text-muted-foreground"
+                />
+              </div>
+            )}
+            {renderView === "dynamic" ? (
+              <div className="overflow-auto">
+                {currentTree && currentTree.root ? (
+                  <div className="animate-in fade-in duration-200 w-full min-h-full flex items-center justify-center p-3 py-4">
+                    <JSONUIProvider
+                      registry={
+                        demoRegistry as Parameters<
+                          typeof JSONUIProvider
+                        >[0]["registry"]
+                      }
+                    >
+                      <Renderer
+                        tree={currentTree}
+                        registry={
+                          demoRegistry as Parameters<
+                            typeof Renderer
+                          >[0]["registry"]
+                        }
+                        loading={isStreaming || isStreamingSimulation}
+                        fallback={
+                          fallbackComponent as Parameters<
+                            typeof Renderer
+                          >[0]["fallback"]
+                        }
+                      />
+                    </JSONUIProvider>
+                  </div>
+                ) : (
+                  <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
+                    {isStreaming ? "generating..." : "waiting..."}
+                  </div>
+                )}
               </div>
             ) : (
-              <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
-                {isStreaming ? "generating..." : "waiting..."}
+              <div className="overflow-auto h-full font-mono text-xs text-left">
+                <CodeBlock
+                  code={generatedCode}
+                  lang="tsx"
+                  fillHeight
+                  hideCopyButton
+                />
               </div>
             )}
           </div>
@@ -566,6 +1240,335 @@ export function Demo() {
           </div>
         </div>
       )}
+
+      {/* Export Modal */}
+      {showExportModal && (
+        <div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 sm:p-8">
+          <div className="bg-background border border-border rounded-lg w-full max-w-5xl h-full max-h-[80vh] flex flex-col shadow-2xl">
+            {/* Header */}
+            <div className="flex items-center justify-between px-4 sm:px-6 h-14 border-b border-border shrink-0">
+              <div className="flex items-center gap-2 sm:gap-3">
+                {/* Mobile file tree toggle */}
+                <button
+                  onClick={() => setShowMobileFileTree(!showMobileFileTree)}
+                  className="sm:hidden text-muted-foreground hover:text-foreground transition-colors p-1"
+                  aria-label="Toggle file tree"
+                >
+                  <svg
+                    width="18"
+                    height="18"
+                    viewBox="0 0 24 24"
+                    fill="none"
+                    stroke="currentColor"
+                    strokeWidth="2"
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                  >
+                    <path d="M3 6h18M3 12h18M3 18h18" />
+                  </svg>
+                </button>
+                <span className="text-sm font-mono">export static code</span>
+                <span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded hidden sm:inline">
+                  {exportedFiles.length} files
+                </span>
+              </div>
+              <div className="flex items-center gap-2">
+                <button
+                  onClick={downloadAllFiles}
+                  className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-foreground text-background rounded hover:bg-foreground/90 transition-colors"
+                >
+                  <svg
+                    width="12"
+                    height="12"
+                    viewBox="0 0 24 24"
+                    fill="none"
+                    stroke="currentColor"
+                    strokeWidth="2"
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                  >
+                    <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
+                    <polyline points="7 10 12 15 17 10" />
+                    <line x1="12" y1="15" x2="12" y2="3" />
+                  </svg>
+                  Download All
+                </button>
+                <button
+                  onClick={() => setShowExportModal(false)}
+                  className="text-muted-foreground hover:text-foreground transition-colors p-1"
+                  aria-label="Close"
+                >
+                  <svg
+                    width="20"
+                    height="20"
+                    viewBox="0 0 24 24"
+                    fill="none"
+                    stroke="currentColor"
+                    strokeWidth="2"
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                  >
+                    <path d="M18 6L6 18" />
+                    <path d="M6 6l12 12" />
+                  </svg>
+                </button>
+              </div>
+            </div>
+
+            {/* Content */}
+            <div className="flex flex-1 min-h-0 relative">
+              {/* File Tree - hidden on mobile, overlay when shown */}
+              <div
+                className={`
+                ${showMobileFileTree ? "absolute inset-0 z-10 bg-background" : "hidden"}
+                sm:relative sm:block sm:w-56 sm:bg-transparent
+                border-r border-border overflow-auto py-2
+              `}
+              >
+                {(() => {
+                  // Build tree structure from flat file list
+                  type TreeNode = {
+                    name: string;
+                    path: string;
+                    isFolder: boolean;
+                    children: TreeNode[];
+                    file?: { path: string; content: string };
+                  };
+
+                  const root: TreeNode = {
+                    name: "",
+                    path: "",
+                    isFolder: true,
+                    children: [],
+                  };
+
+                  exportedFiles.forEach((file) => {
+                    const parts = file.path.split("/");
+                    let current = root;
+
+                    parts.forEach((part, idx) => {
+                      const isLast = idx === parts.length - 1;
+                      const path = parts.slice(0, idx + 1).join("/");
+                      let child = current.children.find((c) => c.name === part);
+
+                      if (!child) {
+                        child = {
+                          name: part,
+                          path,
+                          isFolder: !isLast,
+                          children: [],
+                          file: isLast ? file : undefined,
+                        };
+                        current.children.push(child);
+                      }
+
+                      current = child;
+                    });
+                  });
+
+                  // Sort: folders first, then alphabetically
+                  const sortNodes = (nodes: TreeNode[]): TreeNode[] => {
+                    return nodes.sort((a, b) => {
+                      if (a.isFolder && !b.isFolder) return -1;
+                      if (!a.isFolder && b.isFolder) return 1;
+                      return a.name.localeCompare(b.name);
+                    });
+                  };
+
+                  const toggleFolder = (path: string) => {
+                    setCollapsedFolders((prev) => {
+                      const next = new Set(prev);
+                      if (next.has(path)) {
+                        next.delete(path);
+                      } else {
+                        next.add(path);
+                      }
+                      return next;
+                    });
+                  };
+
+                  const renderNode = (
+                    node: TreeNode,
+                    depth: number,
+                  ): React.ReactNode[] => {
+                    const result: React.ReactNode[] = [];
+                    const isExpanded = !collapsedFolders.has(node.path);
+
+                    if (node.isFolder && node.name) {
+                      result.push(
+                        <button
+                          key={`folder-${node.path}`}
+                          onClick={() => toggleFolder(node.path)}
+                          className="w-full text-left px-3 py-1 text-xs font-mono text-muted-foreground hover:text-foreground hover:bg-foreground/5 transition-colors"
+                          style={{ paddingLeft: `${12 + depth * 12}px` }}
+                        >
+                          <span className="flex items-center gap-1.5">
+                            <span
+                              className={`text-gray-400 transition-transform ${isExpanded ? "rotate-90" : ""}`}
+                            >
+                              <svg
+                                width="8"
+                                height="8"
+                                viewBox="0 0 24 24"
+                                fill="currentColor"
+                              >
+                                <path d="M8 5l10 7-10 7V5z" />
+                              </svg>
+                            </span>
+                            <span className="text-gray-400">
+                              <svg
+                                width="12"
+                                height="12"
+                                viewBox="0 0 24 24"
+                                fill="currentColor"
+                              >
+                                <path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" />
+                              </svg>
+                            </span>
+                            {node.name}
+                          </span>
+                        </button>,
+                      );
+                    }
+
+                    if (node.file) {
+                      const isActive = node.file.path === activeExportFile;
+                      result.push(
+                        <button
+                          key={node.file.path}
+                          onClick={() => {
+                            setSelectedExportFile(node.file!.path);
+                            setShowMobileFileTree(false);
+                          }}
+                          className={`w-full text-left px-3 py-1 text-xs font-mono transition-colors ${
+                            isActive
+                              ? "bg-foreground/10 text-foreground"
+                              : "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
+                          }`}
+                          style={{ paddingLeft: `${12 + depth * 12}px` }}
+                        >
+                          <span className="flex items-center gap-1.5">
+                            {node.name.endsWith(".tsx") ||
+                            node.name.endsWith(".ts") ? (
+                              <span className="text-blue-400">
+                                <svg
+                                  width="12"
+                                  height="12"
+                                  viewBox="0 0 24 24"
+                                  fill="currentColor"
+                                >
+                                  <path d="M3 3h18v18H3V3zm16.525 13.707c-.131-.821-.666-1.511-2.252-2.155-.552-.259-1.165-.438-1.349-.854-.068-.248-.083-.382-.039-.527.11-.373.458-.487.757-.381.193.07.37.258.482.52.51-.332.51-.332.86-.553-.132-.203-.203-.293-.297-.382-.335-.382-.78-.58-1.502-.558l-.375.047c-.361.09-.705.272-.923.531-.613.721-.437 1.976.245 2.494.674.476 1.661.59 1.791 1.052.12.543-.406.717-.919.65-.387-.071-.6-.273-.831-.641l-.871.529c.1.217.217.31.39.494.803.796 2.8.749 3.163-.476.013-.04.113-.33.071-.765zm-7.158-2.032c-.227.574-.446 1.148-.677 1.722-.204-.54-.42-1.102-.648-1.68l-.002-.02h-1.09v4.4h.798v-3.269l.796 2.011h.69l.793-2.012v3.27h.798v-4.4h-1.06l-.398 1.02v-.042zm-3.39-3.15v1.2h2.99v8.424h1.524v-8.424h2.99v-1.2H8.977z" />
+                                </svg>
+                              </span>
+                            ) : node.name.endsWith(".json") ? (
+                              <span className="text-yellow-400">
+                                <svg
+                                  width="12"
+                                  height="12"
+                                  viewBox="0 0 24 24"
+                                  fill="none"
+                                  stroke="currentColor"
+                                  strokeWidth="2"
+                                >
+                                  <path d="M4 4h16v16H4z" />
+                                  <path d="M8 8h8M8 12h8M8 16h4" />
+                                </svg>
+                              </span>
+                            ) : node.name.endsWith(".css") ? (
+                              <span className="text-pink-400">
+                                <svg
+                                  width="12"
+                                  height="12"
+                                  viewBox="0 0 24 24"
+                                  fill="currentColor"
+                                >
+                                  <path d="M3 3h18v18H3V3zm15.751 10.875l-.634 7.125-6.125 2-6.125-2-.625-7.125h3.125l.312 3.625 3.313 1.125 3.312-1.125.375-3.625H6.125l-.313-3.125h12.376l-.312 3.125H9.125l.25 1.875h8.376v.125z" />
+                                </svg>
+                              </span>
+                            ) : node.name.endsWith(".md") ? (
+                              <span className="text-gray-400">
+                                <svg
+                                  width="12"
+                                  height="12"
+                                  viewBox="0 0 24 24"
+                                  fill="currentColor"
+                                >
+                                  <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM13 9V3.5L18.5 9H13z" />
+                                </svg>
+                              </span>
+                            ) : (
+                              <span className="text-gray-400">
+                                <svg
+                                  width="12"
+                                  height="12"
+                                  viewBox="0 0 24 24"
+                                  fill="currentColor"
+                                >
+                                  <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM13 9V3.5L18.5 9H13z" />
+                                </svg>
+                              </span>
+                            )}
+                            {node.name}
+                          </span>
+                        </button>,
+                      );
+                    }
+
+                    // Only render children if not a folder or if folder is expanded (or root)
+                    if (!node.isFolder || !node.name || isExpanded) {
+                      sortNodes(node.children).forEach((child) => {
+                        result.push(
+                          ...renderNode(child, node.name ? depth + 1 : depth),
+                        );
+                      });
+                    }
+
+                    return result;
+                  };
+
+                  return renderNode(root, 0);
+                })()}
+              </div>
+
+              {/* Code Preview */}
+              <div className="flex-1 flex flex-col min-w-0">
+                <div className="flex items-center justify-between px-4 py-2 border-b border-border bg-muted/30">
+                  <span className="text-xs font-mono text-muted-foreground">
+                    {activeExportFile}
+                  </span>
+                  <button
+                    onClick={() => copyFileContent(activeExportContent)}
+                    className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
+                  >
+                    <svg
+                      width="12"
+                      height="12"
+                      viewBox="0 0 24 24"
+                      fill="none"
+                      stroke="currentColor"
+                      strokeWidth="2"
+                      strokeLinecap="round"
+                      strokeLinejoin="round"
+                    >
+                      <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
+                      <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
+                    </svg>
+                    Copy
+                  </button>
+                </div>
+                <div className="flex-1 overflow-auto">
+                  <CodeBlock
+                    code={activeExportContent}
+                    lang={activeExportFile?.endsWith(".json") ? "json" : "tsx"}
+                    fillHeight
+                    hideCopyButton
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 }

+ 1 - 0
apps/web/package.json

@@ -13,6 +13,7 @@
   },
   "dependencies": {
     "@ai-sdk/gateway": "^3.0.13",
+    "@json-render/codegen": "workspace:*",
     "@json-render/core": "workspace:*",
     "@json-render/react": "workspace:*",
     "@radix-ui/react-dialog": "^1.1.15",

+ 266 - 15
examples/dashboard/app/page.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useState, useCallback } from "react";
+import { useState, useCallback, useMemo } from "react";
 import {
   DataProvider,
   ActionProvider,
@@ -9,6 +9,11 @@ import {
   Renderer,
 } from "@json-render/react";
 import { componentRegistry } from "@/components/ui";
+import { generateNextJSProject } from "@/lib/codegen";
+import {
+  CodeHighlight,
+  getLanguageFromPath,
+} from "@/components/code-highlight";
 
 const INITIAL_DATA = {
   analytics: {
@@ -69,11 +74,24 @@ const ACTION_HANDLERS = {
 
 function DashboardContent() {
   const [prompt, setPrompt] = useState("");
+  const [showCodeExport, setShowCodeExport] = useState(false);
+  const [selectedFile, setSelectedFile] = useState<string | null>(null);
   const { tree, isStreaming, error, send, clear } = useUIStream({
     api: "/api/generate",
     onError: (err) => console.error("Generation error:", err),
   });
 
+  const exportedFiles = useMemo(
+    () =>
+      tree
+        ? generateNextJSProject(tree, {
+            projectName: "my-dashboard",
+            data: INITIAL_DATA,
+          })
+        : [],
+    [tree],
+  );
+
   const handleSubmit = useCallback(
     async (e: React.FormEvent) => {
       e.preventDefault();
@@ -83,6 +101,26 @@ function DashboardContent() {
     [prompt, send],
   );
 
+  const downloadAllFiles = useCallback(() => {
+    // Create a simple zip-like download by creating individual file downloads
+    // For a real implementation, you'd use a library like JSZip
+    const allContent = exportedFiles
+      .map((f) => `// ========== ${f.path} ==========\n${f.content}`)
+      .join("\n\n");
+
+    const blob = new Blob([allContent], { type: "text/plain" });
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement("a");
+    a.href = url;
+    a.download = "my-dashboard-project.txt";
+    a.click();
+    URL.revokeObjectURL(url);
+  }, [exportedFiles]);
+
+  const copyFileContent = useCallback((content: string) => {
+    navigator.clipboard.writeText(content);
+  }, []);
+
   const examples = [
     "Revenue dashboard with metrics and chart",
     "Recent transactions table",
@@ -91,6 +129,13 @@ function DashboardContent() {
 
   const hasElements = tree && Object.keys(tree.elements).length > 0;
 
+  // Auto-select first file when modal opens
+  const activeFile =
+    selectedFile || (exportedFiles.length > 0 ? exportedFiles[0]?.path : null);
+  const activeFileContent = exportedFiles.find(
+    (f) => f.path === activeFile,
+  )?.content;
+
   return (
     <div style={{ maxWidth: 960, margin: "0 auto", padding: "48px 24px" }}>
       <header style={{ marginBottom: 48 }}>
@@ -145,20 +190,39 @@ function DashboardContent() {
             {isStreaming ? "Generating..." : "Generate"}
           </button>
           {hasElements && (
-            <button
-              type="button"
-              onClick={clear}
-              style={{
-                padding: "12px 16px",
-                background: "transparent",
-                color: "var(--muted)",
-                border: "1px solid var(--border)",
-                borderRadius: "var(--radius)",
-                fontSize: 16,
-              }}
-            >
-              Clear
-            </button>
+            <>
+              <button
+                type="button"
+                onClick={() => {
+                  setSelectedFile(null);
+                  setShowCodeExport(true);
+                }}
+                style={{
+                  padding: "12px 16px",
+                  background: "transparent",
+                  color: "var(--foreground)",
+                  border: "1px solid var(--border)",
+                  borderRadius: "var(--radius)",
+                  fontSize: 16,
+                }}
+              >
+                Export Project
+              </button>
+              <button
+                type="button"
+                onClick={clear}
+                style={{
+                  padding: "12px 16px",
+                  background: "transparent",
+                  color: "var(--muted)",
+                  border: "1px solid var(--border)",
+                  borderRadius: "var(--radius)",
+                  fontSize: 16,
+                }}
+              >
+                Clear
+              </button>
+            </>
           )}
         </div>
 
@@ -250,6 +314,193 @@ function DashboardContent() {
           </pre>
         </details>
       )}
+
+      {/* Code Export Modal */}
+      {showCodeExport && (
+        <div
+          style={{
+            position: "fixed",
+            inset: 0,
+            background: "rgba(0, 0, 0, 0.7)",
+            display: "flex",
+            alignItems: "center",
+            justifyContent: "center",
+            zIndex: 50,
+          }}
+          onClick={() => setShowCodeExport(false)}
+        >
+          <div
+            style={{
+              background: "var(--background)",
+              border: "1px solid var(--border)",
+              borderRadius: "var(--radius)",
+              width: "90%",
+              maxWidth: 1000,
+              height: "80vh",
+              overflow: "hidden",
+              display: "flex",
+              flexDirection: "column",
+            }}
+            onClick={(e) => e.stopPropagation()}
+          >
+            {/* Header */}
+            <div
+              style={{
+                padding: "16px 20px",
+                borderBottom: "1px solid var(--border)",
+                display: "flex",
+                justifyContent: "space-between",
+                alignItems: "center",
+              }}
+            >
+              <div>
+                <h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
+                  Export Next.js Project
+                </h2>
+                <p
+                  style={{
+                    margin: "4px 0 0",
+                    fontSize: 13,
+                    color: "var(--muted)",
+                  }}
+                >
+                  {exportedFiles.length} files generated
+                </p>
+              </div>
+              <div style={{ display: "flex", gap: 8 }}>
+                <button
+                  onClick={downloadAllFiles}
+                  style={{
+                    padding: "8px 16px",
+                    background: "var(--foreground)",
+                    color: "var(--background)",
+                    border: "none",
+                    borderRadius: "var(--radius)",
+                    fontSize: 14,
+                    fontWeight: 500,
+                    cursor: "pointer",
+                  }}
+                >
+                  Download All
+                </button>
+                <button
+                  onClick={() => setShowCodeExport(false)}
+                  style={{
+                    padding: "8px 16px",
+                    background: "transparent",
+                    color: "var(--muted)",
+                    border: "1px solid var(--border)",
+                    borderRadius: "var(--radius)",
+                    fontSize: 14,
+                    cursor: "pointer",
+                  }}
+                >
+                  Close
+                </button>
+              </div>
+            </div>
+
+            {/* Content */}
+            <div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
+              {/* File List */}
+              <div
+                style={{
+                  width: 240,
+                  borderRight: "1px solid var(--border)",
+                  overflow: "auto",
+                  padding: "8px 0",
+                }}
+              >
+                {exportedFiles.map((file) => (
+                  <button
+                    key={file.path}
+                    onClick={() => setSelectedFile(file.path)}
+                    style={{
+                      display: "block",
+                      width: "100%",
+                      padding: "8px 16px",
+                      background:
+                        activeFile === file.path
+                          ? "var(--border)"
+                          : "transparent",
+                      border: "none",
+                      textAlign: "left",
+                      fontSize: 13,
+                      fontFamily: "monospace",
+                      color:
+                        activeFile === file.path
+                          ? "var(--foreground)"
+                          : "var(--muted)",
+                      cursor: "pointer",
+                    }}
+                  >
+                    {file.path}
+                  </button>
+                ))}
+              </div>
+
+              {/* File Content */}
+              <div
+                style={{
+                  flex: 1,
+                  overflow: "auto",
+                  display: "flex",
+                  flexDirection: "column",
+                }}
+              >
+                {activeFile && (
+                  <>
+                    <div
+                      style={{
+                        padding: "8px 16px",
+                        borderBottom: "1px solid var(--border)",
+                        display: "flex",
+                        justifyContent: "space-between",
+                        alignItems: "center",
+                        background: "var(--card)",
+                      }}
+                    >
+                      <span style={{ fontSize: 13, fontFamily: "monospace" }}>
+                        {activeFile}
+                      </span>
+                      <button
+                        onClick={() =>
+                          activeFileContent &&
+                          copyFileContent(activeFileContent)
+                        }
+                        style={{
+                          padding: "4px 12px",
+                          background: "var(--border)",
+                          color: "var(--foreground)",
+                          border: "none",
+                          borderRadius: "var(--radius)",
+                          fontSize: 12,
+                          cursor: "pointer",
+                        }}
+                      >
+                        Copy
+                      </button>
+                    </div>
+                    <div
+                      style={{
+                        flex: 1,
+                        padding: 16,
+                        overflow: "auto",
+                        background: "var(--card)",
+                      }}
+                    >
+                      <CodeHighlight
+                        code={activeFileContent || ""}
+                        language={getLanguageFromPath(activeFile)}
+                      />
+                    </div>
+                  </>
+                )}
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 }

+ 179 - 0
examples/dashboard/components/code-highlight.tsx

@@ -0,0 +1,179 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { codeToHtml, type BundledLanguage } from "shiki";
+
+const vercelDarkTheme = {
+  name: "vercel-dark",
+  type: "dark" as const,
+  colors: {
+    "editor.background": "transparent",
+    "editor.foreground": "#EDEDED",
+  },
+  settings: [
+    {
+      scope: ["comment", "punctuation.definition.comment"],
+      settings: { foreground: "#666666" },
+    },
+    {
+      scope: ["string", "string.quoted", "string.template"],
+      settings: { foreground: "#50E3C2" },
+    },
+    {
+      scope: [
+        "constant.numeric",
+        "constant.language.boolean",
+        "constant.language.null",
+      ],
+      settings: { foreground: "#50E3C2" },
+    },
+    {
+      scope: ["keyword", "storage.type", "storage.modifier"],
+      settings: { foreground: "#FF0080" },
+    },
+    {
+      scope: ["keyword.operator", "keyword.control"],
+      settings: { foreground: "#FF0080" },
+    },
+    {
+      scope: ["entity.name.function", "support.function", "meta.function-call"],
+      settings: { foreground: "#7928CA" },
+    },
+    {
+      scope: ["variable", "variable.other", "variable.parameter"],
+      settings: { foreground: "#EDEDED" },
+    },
+    {
+      scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
+      settings: { foreground: "#FF0080" },
+    },
+    {
+      scope: ["punctuation", "meta.brace", "meta.bracket"],
+      settings: { foreground: "#888888" },
+    },
+    {
+      scope: [
+        "support.type.property-name",
+        "entity.name.tag.json",
+        "meta.object-literal.key",
+      ],
+      settings: { foreground: "#EDEDED" },
+    },
+    {
+      scope: ["entity.other.attribute-name"],
+      settings: { foreground: "#50E3C2" },
+    },
+    {
+      scope: ["support.type.primitive", "entity.name.type.primitive"],
+      settings: { foreground: "#50E3C2" },
+    },
+  ],
+};
+
+interface CodeHighlightProps {
+  code: string;
+  language?: BundledLanguage;
+}
+
+export function CodeHighlight({ code, language = "tsx" }: CodeHighlightProps) {
+  const [html, setHtml] = useState<string>("");
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    let cancelled = false;
+
+    async function highlight() {
+      setIsLoading(true);
+      try {
+        const result = await codeToHtml(code, {
+          lang: language,
+          theme: vercelDarkTheme,
+        });
+        if (!cancelled) {
+          setHtml(result);
+        }
+      } catch {
+        // Fallback to plain text on error
+        if (!cancelled) {
+          setHtml(`<pre><code>${escapeHtml(code)}</code></pre>`);
+        }
+      } finally {
+        if (!cancelled) {
+          setIsLoading(false);
+        }
+      }
+    }
+
+    highlight();
+
+    return () => {
+      cancelled = true;
+    };
+  }, [code, language]);
+
+  if (isLoading) {
+    return (
+      <pre
+        style={{
+          margin: 0,
+          padding: 0,
+          overflow: "auto",
+          fontSize: 13,
+          lineHeight: 1.6,
+          fontFamily:
+            'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
+          color: "#EDEDED",
+        }}
+      >
+        <code>{code}</code>
+      </pre>
+    );
+  }
+
+  return (
+    <div
+      style={{
+        overflow: "auto",
+        fontSize: 13,
+        lineHeight: 1.6,
+        fontFamily:
+          'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
+      }}
+      dangerouslySetInnerHTML={{ __html: html }}
+    />
+  );
+}
+
+function escapeHtml(text: string): string {
+  return text
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&#039;");
+}
+
+/**
+ * Get the appropriate language for a file extension
+ */
+export function getLanguageFromPath(path: string): BundledLanguage {
+  const ext = path.split(".").pop()?.toLowerCase();
+  switch (ext) {
+    case "tsx":
+      return "tsx";
+    case "ts":
+      return "typescript";
+    case "jsx":
+      return "jsx";
+    case "js":
+      return "javascript";
+    case "json":
+      return "json";
+    case "css":
+      return "css";
+    case "md":
+      return "markdown";
+    default:
+      return "typescript";
+  }
+}

+ 331 - 0
examples/dashboard/lib/codegen/generator.ts

@@ -0,0 +1,331 @@
+import type { UITree } from "@json-render/core";
+import { collectUsedComponents, serializeProps } from "@json-render/codegen";
+import { componentTemplates } from "./templates";
+
+export interface ExportedFile {
+  path: string;
+  content: string;
+}
+
+export interface GeneratorOptions {
+  projectName?: string;
+  data?: Record<string, unknown>;
+}
+
+/**
+ * Generate a complete Next.js project from a UI tree
+ */
+export function generateNextJSProject(
+  tree: UITree,
+  options: GeneratorOptions = {},
+): ExportedFile[] {
+  const { projectName = "generated-dashboard", data = {} } = options;
+  const files: ExportedFile[] = [];
+
+  // Collect what we need
+  const usedComponents = collectUsedComponents(tree);
+
+  // 1. Generate package.json
+  files.push({
+    path: "package.json",
+    content: generatePackageJson(projectName),
+  });
+
+  // 2. Generate next.config.js
+  files.push({
+    path: "next.config.js",
+    content: `/** @type {import('next').NextConfig} */
+module.exports = {
+  reactStrictMode: true,
+};
+`,
+  });
+
+  // 3. Generate tsconfig.json
+  files.push({
+    path: "tsconfig.json",
+    content: JSON.stringify(
+      {
+        compilerOptions: {
+          target: "ES2017",
+          lib: ["dom", "dom.iterable", "esnext"],
+          allowJs: true,
+          skipLibCheck: true,
+          strict: true,
+          noEmit: true,
+          esModuleInterop: true,
+          module: "esnext",
+          moduleResolution: "bundler",
+          resolveJsonModule: true,
+          isolatedModules: true,
+          jsx: "preserve",
+          incremental: true,
+          plugins: [{ name: "next" }],
+          paths: { "@/*": ["./*"] },
+        },
+        include: [
+          "next-env.d.ts",
+          "**/*.ts",
+          "**/*.tsx",
+          ".next/types/**/*.ts",
+        ],
+        exclude: ["node_modules"],
+      },
+      null,
+      2,
+    ),
+  });
+
+  // 4. Generate globals.css
+  files.push({
+    path: "app/globals.css",
+    content: generateGlobalsCss(),
+  });
+
+  // 5. Generate layout.tsx
+  files.push({
+    path: "app/layout.tsx",
+    content: generateLayout(projectName),
+  });
+
+  // 6. Generate component files (only the ones that are used)
+  for (const componentName of usedComponents) {
+    const template = componentTemplates[componentName];
+    if (template) {
+      files.push({
+        path: `components/ui/${componentName.toLowerCase()}.tsx`,
+        content: template,
+      });
+    }
+  }
+
+  // 7. Generate components/ui/index.ts
+  files.push({
+    path: "components/ui/index.ts",
+    content: generateComponentIndex(usedComponents),
+  });
+
+  // 8. Generate the main page with the tree
+  files.push({
+    path: "app/page.tsx",
+    content: generateMainPage(tree, usedComponents, data),
+  });
+
+  // 9. Generate README
+  files.push({
+    path: "README.md",
+    content: generateReadme(projectName),
+  });
+
+  return files;
+}
+
+function generatePackageJson(name: string): string {
+  return JSON.stringify(
+    {
+      name,
+      version: "0.1.0",
+      private: true,
+      scripts: {
+        dev: "next dev",
+        build: "next build",
+        start: "next start",
+        lint: "next lint",
+      },
+      dependencies: {
+        next: "^14.2.0",
+        react: "^18.3.0",
+        "react-dom": "^18.3.0",
+      },
+      devDependencies: {
+        "@types/node": "^20.0.0",
+        "@types/react": "^18.3.0",
+        "@types/react-dom": "^18.3.0",
+        typescript: "^5.4.0",
+      },
+    },
+    null,
+    2,
+  );
+}
+
+function generateGlobalsCss(): string {
+  return `* {
+  box-sizing: border-box;
+}
+
+:root {
+  --background: #000;
+  --foreground: #fafafa;
+  --card: #0a0a0a;
+  --border: #262626;
+  --muted: #a3a3a3;
+  --radius: 8px;
+}
+
+html,
+body {
+  margin: 0;
+  padding: 0;
+  font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  background-color: var(--background);
+  color: var(--foreground);
+  -webkit-font-smoothing: antialiased;
+}
+
+button {
+  font-family: inherit;
+  cursor: pointer;
+}
+
+input,
+select,
+textarea {
+  font-family: inherit;
+}
+
+::selection {
+  background: var(--foreground);
+  color: var(--background);
+}
+`;
+}
+
+function generateLayout(projectName: string): string {
+  return `import type { Metadata } from "next";
+import "./globals.css";
+
+export const metadata: Metadata = {
+  title: "${projectName}",
+  description: "Generated dashboard",
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en">
+      <body>{children}</body>
+    </html>
+  );
+}
+`;
+}
+
+function generateComponentIndex(components: Set<string>): string {
+  const exports = Array.from(components)
+    .sort()
+    .map((name) => `export { ${name} } from "./${name.toLowerCase()}";`)
+    .join("\n");
+
+  return exports + "\n";
+}
+
+function generateMainPage(
+  tree: UITree,
+  components: Set<string>,
+  data: Record<string, unknown>,
+): string {
+  const imports = Array.from(components).sort().join(", ");
+  const jsx = generateJSX(tree, tree.root, 4);
+  const dataStr = JSON.stringify(data, null, 2).replace(/\n/g, "\n  ");
+
+  return `"use client";
+
+import { ${imports} } from "@/components/ui";
+
+const data = ${dataStr};
+
+export default function Page() {
+  return (
+    <div style={{ maxWidth: 960, margin: "0 auto", padding: "48px 24px" }}>
+${jsx}
+    </div>
+  );
+}
+`;
+}
+
+function generateJSX(tree: UITree, key: string, indent: number): string {
+  const element = tree.elements[key];
+  if (!element) return "";
+
+  const spaces = " ".repeat(indent);
+  const componentName = element.type;
+
+  // Filter out null/undefined props and convert data paths to data references
+  const propsObj: Record<string, unknown> = {};
+  for (const [k, v] of Object.entries(element.props)) {
+    if (v === null || v === undefined) continue;
+
+    // Convert *Path props to actual data values
+    if (
+      typeof v === "string" &&
+      (k.endsWith("Path") || k === "bindPath" || k === "dataPath")
+    ) {
+      // Keep as a special marker for the component
+      propsObj[k] = v;
+    } else {
+      propsObj[k] = v;
+    }
+  }
+
+  // Add data prop for components that need it
+  const needsData =
+    Object.keys(propsObj).some(
+      (k) => k.endsWith("Path") || k === "bindPath" || k === "dataPath",
+    ) || ["Chart", "Table", "Metric", "List"].includes(componentName);
+
+  const propsStr = serializeProps(propsObj);
+  const dataAttr = needsData ? " data={data}" : "";
+
+  const hasChildren = element.children && element.children.length > 0;
+
+  if (!hasChildren) {
+    if (propsStr || dataAttr) {
+      return `${spaces}<${componentName}${dataAttr}${propsStr ? " " + propsStr : ""} />`;
+    }
+    return `${spaces}<${componentName} />`;
+  }
+
+  const lines: string[] = [];
+  if (propsStr || dataAttr) {
+    lines.push(
+      `${spaces}<${componentName}${dataAttr}${propsStr ? " " + propsStr : ""}>`,
+    );
+  } else {
+    lines.push(`${spaces}<${componentName}>`);
+  }
+
+  for (const childKey of element.children!) {
+    lines.push(generateJSX(tree, childKey, indent + 2));
+  }
+
+  lines.push(`${spaces}</${componentName}>`);
+
+  return lines.join("\n");
+}
+
+function generateReadme(projectName: string): string {
+  return `# ${projectName}
+
+This dashboard was generated from a json-render UI tree.
+
+## Getting Started
+
+\`\`\`bash
+npm install
+npm run dev
+\`\`\`
+
+Open [http://localhost:3000](http://localhost:3000) to view the dashboard.
+
+## Project Structure
+
+- \`app/page.tsx\` - Main dashboard page
+- \`components/ui/\` - UI components
+- \`app/globals.css\` - Global styles
+`;
+}

+ 1 - 0
examples/dashboard/lib/codegen/index.ts

@@ -0,0 +1 @@
+export { generateNextJSProject, type ExportedFile } from "./generator";

+ 740 - 0
examples/dashboard/lib/codegen/templates.ts

@@ -0,0 +1,740 @@
+/**
+ * Standalone component templates that don't depend on json-render
+ * These components receive data as props instead of using hooks
+ */
+
+export const componentTemplates: Record<string, string> = {
+  Card: `"use client";
+
+import { ReactNode } from "react";
+
+interface CardProps {
+  title?: string | null;
+  description?: string | null;
+  padding?: "sm" | "md" | "lg" | null;
+  children?: ReactNode;
+}
+
+export function Card({ title, description, padding, children }: CardProps) {
+  const paddings: Record<string, string> = {
+    sm: "12px",
+    md: "16px",
+    lg: "24px",
+  };
+
+  return (
+    <div
+      style={{
+        background: "var(--card)",
+        border: "1px solid var(--border)",
+        borderRadius: "var(--radius)",
+      }}
+    >
+      {(title || description) && (
+        <div
+          style={{
+            padding: "16px 20px",
+            borderBottom: "1px solid var(--border)",
+          }}
+        >
+          {title && (
+            <h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
+              {title}
+            </h3>
+          )}
+          {description && (
+            <p style={{ margin: "4px 0 0", fontSize: 14, color: "var(--muted)" }}>
+              {description}
+            </p>
+          )}
+        </div>
+      )}
+      <div style={{ padding: paddings[padding || "md"] || "16px" }}>
+        {children}
+      </div>
+    </div>
+  );
+}
+`,
+
+  Grid: `"use client";
+
+import { ReactNode } from "react";
+
+interface GridProps {
+  columns?: number | null;
+  gap?: "sm" | "md" | "lg" | null;
+  children?: ReactNode;
+}
+
+export function Grid({ columns, gap, children }: GridProps) {
+  const gaps: Record<string, string> = {
+    sm: "8px",
+    md: "16px",
+    lg: "24px",
+  };
+
+  return (
+    <div
+      style={{
+        display: "grid",
+        gridTemplateColumns: \`repeat(\${columns || 2}, 1fr)\`,
+        gap: gaps[gap || "md"],
+      }}
+    >
+      {children}
+    </div>
+  );
+}
+`,
+
+  Stack: `"use client";
+
+import { ReactNode } from "react";
+
+interface StackProps {
+  direction?: "horizontal" | "vertical" | null;
+  gap?: "sm" | "md" | "lg" | null;
+  align?: "start" | "center" | "end" | "stretch" | null;
+  children?: ReactNode;
+}
+
+export function Stack({ direction, gap, align, children }: StackProps) {
+  const gaps: Record<string, string> = {
+    sm: "8px",
+    md: "16px",
+    lg: "24px",
+  };
+  const alignments: Record<string, string> = {
+    start: "flex-start",
+    center: "center",
+    end: "flex-end",
+    stretch: "stretch",
+  };
+
+  return (
+    <div
+      style={{
+        display: "flex",
+        flexDirection: direction === "horizontal" ? "row" : "column",
+        gap: gaps[gap || "md"],
+        alignItems: alignments[align || "stretch"],
+      }}
+    >
+      {children}
+    </div>
+  );
+}
+`,
+
+  Metric: `"use client";
+
+interface MetricProps {
+  label: string;
+  valuePath: string;
+  format?: "number" | "currency" | "percent" | null;
+  trend?: "up" | "down" | "neutral" | null;
+  trendValue?: string | null;
+  data?: Record<string, unknown>;
+}
+
+function getByPath(obj: unknown, path: string): unknown {
+  if (!path) return obj;
+  const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
+  let current: unknown = obj;
+  for (const segment of segments) {
+    if (current === null || current === undefined) return undefined;
+    if (typeof current === "object") {
+      current = (current as Record<string, unknown>)[segment];
+    } else {
+      return undefined;
+    }
+  }
+  return current;
+}
+
+export function Metric({ label, valuePath, format, trend, trendValue, data }: MetricProps) {
+  const rawValue = data ? getByPath(data, valuePath) : undefined;
+
+  let displayValue = String(rawValue ?? "-");
+  if (format === "currency" && typeof rawValue === "number") {
+    displayValue = new Intl.NumberFormat("en-US", {
+      style: "currency",
+      currency: "USD",
+    }).format(rawValue);
+  } else if (format === "percent" && typeof rawValue === "number") {
+    displayValue = new Intl.NumberFormat("en-US", {
+      style: "percent",
+      minimumFractionDigits: 1,
+    }).format(rawValue);
+  } else if (format === "number" && typeof rawValue === "number") {
+    displayValue = new Intl.NumberFormat("en-US").format(rawValue);
+  }
+
+  return (
+    <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
+      <span style={{ fontSize: 14, color: "var(--muted)" }}>{label}</span>
+      <span style={{ fontSize: 32, fontWeight: 600 }}>{displayValue}</span>
+      {(trend || trendValue) && (
+        <span
+          style={{
+            fontSize: 14,
+            color:
+              trend === "up"
+                ? "#22c55e"
+                : trend === "down"
+                  ? "#ef4444"
+                  : "var(--muted)",
+          }}
+        >
+          {trend === "up" ? "+" : trend === "down" ? "-" : ""}
+          {trendValue}
+        </span>
+      )}
+    </div>
+  );
+}
+`,
+
+  Chart: `"use client";
+
+interface ChartProps {
+  type?: "bar" | "line" | "pie" | "area";
+  dataPath: string;
+  title?: string | null;
+  height?: number | null;
+  data?: Record<string, unknown>;
+}
+
+function getByPath(obj: unknown, path: string): unknown {
+  if (!path) return obj;
+  const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
+  let current: unknown = obj;
+  for (const segment of segments) {
+    if (current === null || current === undefined) return undefined;
+    if (typeof current === "object") {
+      current = (current as Record<string, unknown>)[segment];
+    } else {
+      return undefined;
+    }
+  }
+  return current;
+}
+
+export function Chart({ title, dataPath, height, data }: ChartProps) {
+  const chartData = data
+    ? (getByPath(data, dataPath) as Array<{ label: string; value: number }> | undefined)
+    : undefined;
+
+  if (!chartData || !Array.isArray(chartData)) {
+    return <div style={{ padding: 20, color: "var(--muted)" }}>No data</div>;
+  }
+
+  const maxValue = Math.max(...chartData.map((d) => d.value));
+
+  return (
+    <div>
+      {title && (
+        <h4 style={{ margin: "0 0 16px", fontSize: 14, fontWeight: 600 }}>
+          {title}
+        </h4>
+      )}
+      <div
+        style={{ display: "flex", gap: 8, alignItems: "flex-end", height: height || 120 }}
+      >
+        {chartData.map((d, i) => (
+          <div
+            key={i}
+            style={{
+              flex: 1,
+              display: "flex",
+              flexDirection: "column",
+              alignItems: "center",
+              gap: 4,
+            }}
+          >
+            <div
+              style={{
+                width: "100%",
+                height: \`\${(d.value / maxValue) * 100}%\`,
+                background: "var(--foreground)",
+                borderRadius: "4px 4px 0 0",
+                minHeight: 4,
+              }}
+            />
+            <span style={{ fontSize: 12, color: "var(--muted)" }}>
+              {d.label}
+            </span>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+`,
+
+  Table: `"use client";
+
+interface TableProps {
+  dataPath: string;
+  columns: Array<{ key: string; label: string; format?: "text" | "currency" | "date" | "badge" | null }>;
+  data?: Record<string, unknown>;
+}
+
+function getByPath(obj: unknown, path: string): unknown {
+  if (!path) return obj;
+  const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
+  let current: unknown = obj;
+  for (const segment of segments) {
+    if (current === null || current === undefined) return undefined;
+    if (typeof current === "object") {
+      current = (current as Record<string, unknown>)[segment];
+    } else {
+      return undefined;
+    }
+  }
+  return current;
+}
+
+export function Table({ dataPath, columns, data }: TableProps) {
+  const tableData = data
+    ? (getByPath(data, dataPath) as Array<Record<string, unknown>> | undefined)
+    : undefined;
+
+  if (!tableData || !Array.isArray(tableData)) {
+    return <div style={{ padding: 20, color: "var(--muted)" }}>No data</div>;
+  }
+
+  const formatCell = (value: unknown, format?: string | null) => {
+    if (value === null || value === undefined) return "-";
+    if (format === "currency" && typeof value === "number") {
+      return new Intl.NumberFormat("en-US", {
+        style: "currency",
+        currency: "USD",
+      }).format(value);
+    }
+    if (format === "date" && typeof value === "string") {
+      return new Date(value).toLocaleDateString();
+    }
+    if (format === "badge") {
+      return (
+        <span
+          style={{
+            padding: "2px 8px",
+            borderRadius: 12,
+            fontSize: 12,
+            fontWeight: 500,
+            background: "var(--border)",
+            color: "var(--foreground)",
+          }}
+        >
+          {String(value)}
+        </span>
+      );
+    }
+    return String(value);
+  };
+
+  return (
+    <table style={{ width: "100%", borderCollapse: "collapse" }}>
+      <thead>
+        <tr>
+          {columns.map((col) => (
+            <th
+              key={col.key}
+              style={{
+                textAlign: "left",
+                padding: "12px 8px",
+                borderBottom: "1px solid var(--border)",
+                fontSize: 12,
+                fontWeight: 500,
+                color: "var(--muted)",
+                textTransform: "uppercase",
+                letterSpacing: "0.05em",
+              }}
+            >
+              {col.label}
+            </th>
+          ))}
+        </tr>
+      </thead>
+      <tbody>
+        {tableData.map((row, i) => (
+          <tr key={i}>
+            {columns.map((col) => (
+              <td
+                key={col.key}
+                style={{
+                  padding: "12px 8px",
+                  borderBottom: "1px solid var(--border)",
+                  fontSize: 14,
+                }}
+              >
+                {formatCell(row[col.key], col.format)}
+              </td>
+            ))}
+          </tr>
+        ))}
+      </tbody>
+    </table>
+  );
+}
+`,
+
+  Button: `"use client";
+
+interface ButtonProps {
+  label: string;
+  variant?: "primary" | "secondary" | "danger" | "ghost" | null;
+  size?: "sm" | "md" | "lg" | null;
+  action?: string;
+  disabled?: boolean | null;
+  onClick?: () => void;
+}
+
+export function Button({ label, variant, disabled, onClick }: ButtonProps) {
+  const variants: Record<string, React.CSSProperties> = {
+    primary: {
+      background: "var(--foreground)",
+      color: "var(--background)",
+      border: "none",
+    },
+    secondary: {
+      background: "transparent",
+      color: "var(--foreground)",
+      border: "1px solid var(--border)",
+    },
+    danger: { background: "#dc2626", color: "#fff", border: "none" },
+    ghost: { background: "transparent", color: "var(--muted)", border: "none" },
+  };
+
+  return (
+    <button
+      onClick={onClick}
+      disabled={!!disabled}
+      style={{
+        padding: "8px 16px",
+        borderRadius: "var(--radius)",
+        fontSize: 14,
+        fontWeight: 500,
+        opacity: disabled ? 0.5 : 1,
+        ...variants[variant || "primary"],
+      }}
+    >
+      {label}
+    </button>
+  );
+}
+`,
+
+  Heading: `"use client";
+
+interface HeadingProps {
+  text: string;
+  level?: "h1" | "h2" | "h3" | "h4" | null;
+}
+
+export function Heading({ text, level }: HeadingProps) {
+  const sizes: Record<string, { fontSize: number; fontWeight: number }> = {
+    h1: { fontSize: 32, fontWeight: 700 },
+    h2: { fontSize: 24, fontWeight: 600 },
+    h3: { fontSize: 20, fontWeight: 600 },
+    h4: { fontSize: 16, fontWeight: 600 },
+  };
+
+  const style = sizes[level || "h2"];
+  const Tag = (level || "h2") as keyof JSX.IntrinsicElements;
+
+  return (
+    <Tag style={{ margin: 0, ...style }}>
+      {text}
+    </Tag>
+  );
+}
+`,
+
+  Text: `"use client";
+
+interface TextProps {
+  content: string;
+  variant?: "body" | "caption" | "label" | null;
+  color?: "default" | "muted" | "success" | "warning" | "danger" | null;
+}
+
+export function Text({ content, variant, color }: TextProps) {
+  const sizes: Record<string, number> = {
+    body: 14,
+    caption: 12,
+    label: 13,
+  };
+
+  const colors: Record<string, string> = {
+    default: "var(--foreground)",
+    muted: "var(--muted)",
+    success: "#22c55e",
+    warning: "#eab308",
+    danger: "#ef4444",
+  };
+
+  return (
+    <p
+      style={{
+        margin: 0,
+        fontSize: sizes[variant || "body"],
+        color: colors[color || "default"],
+      }}
+    >
+      {content}
+    </p>
+  );
+}
+`,
+
+  Badge: `"use client";
+
+interface BadgeProps {
+  text: string;
+  variant?: "default" | "success" | "warning" | "danger" | "info" | null;
+}
+
+export function Badge({ text, variant }: BadgeProps) {
+  const colors: Record<string, { bg: string; fg: string }> = {
+    default: { bg: "var(--border)", fg: "var(--foreground)" },
+    success: { bg: "#22c55e20", fg: "#22c55e" },
+    warning: { bg: "#eab30820", fg: "#eab308" },
+    danger: { bg: "#ef444420", fg: "#ef4444" },
+    info: { bg: "#3b82f620", fg: "#3b82f6" },
+  };
+
+  const { bg, fg } = colors[variant || "default"];
+
+  return (
+    <span
+      style={{
+        display: "inline-block",
+        padding: "2px 8px",
+        borderRadius: 12,
+        fontSize: 12,
+        fontWeight: 500,
+        background: bg,
+        color: fg,
+      }}
+    >
+      {text}
+    </span>
+  );
+}
+`,
+
+  Alert: `"use client";
+
+interface AlertProps {
+  type: "info" | "success" | "warning" | "error";
+  title: string;
+  message?: string | null;
+  dismissible?: boolean | null;
+}
+
+export function Alert({ type, title, message }: AlertProps) {
+  const colors: Record<string, string> = {
+    info: "var(--muted)",
+    success: "#22c55e",
+    warning: "#eab308",
+    error: "#ef4444",
+  };
+
+  return (
+    <div
+      style={{
+        padding: "12px 16px",
+        borderRadius: "var(--radius)",
+        background: "var(--card)",
+        border: "1px solid var(--border)",
+        borderLeftWidth: 4,
+        borderLeftColor: colors[type],
+      }}
+    >
+      <div style={{ fontWeight: 500, fontSize: 14 }}>{title}</div>
+      {message && (
+        <div style={{ fontSize: 13, color: "var(--muted)", marginTop: 4 }}>
+          {message}
+        </div>
+      )}
+    </div>
+  );
+}
+`,
+
+  Divider: `"use client";
+
+interface DividerProps {
+  label?: string | null;
+}
+
+export function Divider({ label }: DividerProps) {
+  if (label) {
+    return (
+      <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
+        <div style={{ flex: 1, height: 1, background: "var(--border)" }} />
+        <span style={{ fontSize: 12, color: "var(--muted)" }}>{label}</span>
+        <div style={{ flex: 1, height: 1, background: "var(--border)" }} />
+      </div>
+    );
+  }
+
+  return <hr style={{ border: "none", height: 1, background: "var(--border)", margin: "16px 0" }} />;
+}
+`,
+
+  Empty: `"use client";
+
+interface EmptyProps {
+  title: string;
+  description?: string | null;
+  action?: string | null;
+  actionLabel?: string | null;
+}
+
+export function Empty({ title, description }: EmptyProps) {
+  return (
+    <div style={{ textAlign: "center", padding: "40px 20px" }}>
+      <div style={{ fontSize: 16, fontWeight: 500 }}>{title}</div>
+      {description && (
+        <div style={{ fontSize: 14, color: "var(--muted)", marginTop: 8 }}>
+          {description}
+        </div>
+      )}
+    </div>
+  );
+}
+`,
+
+  Select: `"use client";
+
+interface SelectProps {
+  label?: string | null;
+  bindPath: string;
+  options: Array<{ value: string; label: string }>;
+  placeholder?: string | null;
+  value?: string;
+  onChange?: (value: string) => void;
+}
+
+export function Select({ label, options, placeholder, value, onChange }: SelectProps) {
+  return (
+    <div>
+      {label && (
+        <label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "var(--muted)" }}>
+          {label}
+        </label>
+      )}
+      <select
+        value={value || ""}
+        onChange={(e) => onChange?.(e.target.value)}
+        style={{
+          width: "100%",
+          padding: "8px 12px",
+          background: "var(--card)",
+          border: "1px solid var(--border)",
+          borderRadius: "var(--radius)",
+          color: "var(--foreground)",
+          fontSize: 14,
+        }}
+      >
+        {placeholder && <option value="">{placeholder}</option>}
+        {options.map((opt) => (
+          <option key={opt.value} value={opt.value}>
+            {opt.label}
+          </option>
+        ))}
+      </select>
+    </div>
+  );
+}
+`,
+
+  DatePicker: `"use client";
+
+interface DatePickerProps {
+  label?: string | null;
+  bindPath: string;
+  placeholder?: string | null;
+  value?: string;
+  onChange?: (value: string) => void;
+}
+
+export function DatePicker({ label, placeholder, value, onChange }: DatePickerProps) {
+  return (
+    <div>
+      {label && (
+        <label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "var(--muted)" }}>
+          {label}
+        </label>
+      )}
+      <input
+        type="date"
+        value={value || ""}
+        onChange={(e) => onChange?.(e.target.value)}
+        placeholder={placeholder || ""}
+        style={{
+          width: "100%",
+          padding: "8px 12px",
+          background: "var(--card)",
+          border: "1px solid var(--border)",
+          borderRadius: "var(--radius)",
+          color: "var(--foreground)",
+          fontSize: 14,
+        }}
+      />
+    </div>
+  );
+}
+`,
+
+  List: `"use client";
+
+import { ReactNode } from "react";
+
+interface ListProps {
+  dataPath: string;
+  emptyMessage?: string | null;
+  data?: Record<string, unknown>;
+  children?: ReactNode;
+}
+
+function getByPath(obj: unknown, path: string): unknown {
+  if (!path) return obj;
+  const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
+  let current: unknown = obj;
+  for (const segment of segments) {
+    if (current === null || current === undefined) return undefined;
+    if (typeof current === "object") {
+      current = (current as Record<string, unknown>)[segment];
+    } else {
+      return undefined;
+    }
+  }
+  return current;
+}
+
+export function List({ dataPath, emptyMessage, data, children }: ListProps) {
+  const listData = data
+    ? (getByPath(data, dataPath) as unknown[] | undefined)
+    : undefined;
+
+  if (!listData || !Array.isArray(listData) || listData.length === 0) {
+    return (
+      <div style={{ padding: 20, color: "var(--muted)", textAlign: "center" }}>
+        {emptyMessage || "No items"}
+      </div>
+    );
+  }
+
+  return (
+    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
+      {children}
+    </div>
+  );
+}
+`,
+};

+ 2 - 0
examples/dashboard/package.json

@@ -12,11 +12,13 @@
   "dependencies": {
     "@ai-sdk/gateway": "^3.0.13",
     "ai": "^6.0.33",
+    "@json-render/codegen": "workspace:*",
     "@json-render/core": "workspace:*",
     "@json-render/react": "workspace:*",
     "next": "16.1.1",
     "react": "19.2.3",
     "react-dom": "19.2.3",
+    "shiki": "^3.0.0",
     "zod": "^4.0.0"
   },
   "devDependencies": {

+ 115 - 0
packages/codegen/README.md

@@ -0,0 +1,115 @@
+# @json-render/codegen
+
+Utilities for generating code from json-render UI trees.
+
+This package provides framework-agnostic utilities for building code generators. Use these utilities to create custom code exporters for your specific framework (Next.js, Remix, etc.).
+
+## Installation
+
+```bash
+npm install @json-render/codegen
+# or
+pnpm add @json-render/codegen
+```
+
+## Utilities
+
+### Tree Traversal
+
+```typescript
+import { traverseTree, collectUsedComponents, collectDataPaths, collectActions } from '@json-render/codegen';
+
+// Walk the tree depth-first
+traverseTree(tree, (element, depth, parent) => {
+  console.log(`${' '.repeat(depth * 2)}${element.type}`);
+});
+
+// Get all component types used
+const components = collectUsedComponents(tree);
+// Set { 'Card', 'Metric', 'Button' }
+
+// Get all data paths referenced
+const dataPaths = collectDataPaths(tree);
+// Set { 'analytics/revenue', 'user/name' }
+
+// Get all action names
+const actions = collectActions(tree);
+// Set { 'submit_form', 'refresh_data' }
+```
+
+### Serialization
+
+```typescript
+import { serializePropValue, serializeProps, escapeString } from '@json-render/codegen';
+
+// Serialize a single value
+serializePropValue("hello");
+// { value: '"hello"', needsBraces: false }
+
+serializePropValue(42);
+// { value: '42', needsBraces: true }
+
+serializePropValue({ path: 'user/name' });
+// { value: '{ path: "user/name" }', needsBraces: true }
+
+// Serialize props for JSX
+serializeProps({ title: "Dashboard", columns: 3, disabled: true });
+// 'title="Dashboard" columns={3} disabled'
+```
+
+### Types
+
+```typescript
+import type { GeneratedFile, CodeGenerator } from '@json-render/codegen';
+
+// Implement your own code generator
+const myGenerator: CodeGenerator = {
+  generate(tree) {
+    return [
+      { path: 'package.json', content: '...' },
+      { path: 'app/page.tsx', content: '...' },
+    ];
+  }
+};
+```
+
+## Building a Custom Generator
+
+See the `examples/dashboard` for a complete example of building a Next.js code generator using these utilities.
+
+```typescript
+import { 
+  collectUsedComponents, 
+  collectDataPaths,
+  traverseTree,
+  serializeProps,
+  type GeneratedFile 
+} from '@json-render/codegen';
+import type { UITree } from '@json-render/core';
+
+export function generateNextJSProject(tree: UITree): GeneratedFile[] {
+  const files: GeneratedFile[] = [];
+  const components = collectUsedComponents(tree);
+  
+  // Generate package.json
+  files.push({
+    path: 'package.json',
+    content: JSON.stringify({
+      name: 'my-generated-app',
+      dependencies: {
+        next: '^14.0.0',
+        react: '^18.0.0',
+      }
+    }, null, 2)
+  });
+  
+  // Generate component files...
+  // Generate main page...
+  
+  return files;
+}
+```
+
+## License
+
+Apache-2.0

+ 51 - 0
packages/codegen/package.json

@@ -0,0 +1,51 @@
+{
+  "name": "@json-render/codegen",
+  "version": "0.1.0",
+  "license": "Apache-2.0",
+  "description": "Utilities for generating code from json-render UI trees",
+  "keywords": [
+    "json",
+    "ui",
+    "codegen",
+    "code-generation",
+    "export"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/codegen"
+  },
+  "homepage": "https://github.com/vercel-labs/json-render#readme",
+  "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"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*"
+  },
+  "devDependencies": {
+    "@repo/typescript-config": "workspace:*",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5"
+  }
+}

+ 16 - 0
packages/codegen/src/index.ts

@@ -0,0 +1,16 @@
+export {
+  traverseTree,
+  collectUsedComponents,
+  collectDataPaths,
+  collectActions,
+  type TreeVisitor,
+} from "./traverse";
+
+export {
+  serializePropValue,
+  serializeProps,
+  escapeString,
+  type SerializeOptions,
+} from "./serialize";
+
+export type { GeneratedFile, CodeGenerator } from "./types";

+ 130 - 0
packages/codegen/src/serialize.ts

@@ -0,0 +1,130 @@
+/**
+ * Options for serialization
+ */
+export interface SerializeOptions {
+  /** Quote style for strings */
+  quotes?: "single" | "double";
+  /** Indent for objects/arrays */
+  indent?: number;
+}
+
+const DEFAULT_OPTIONS: Required<SerializeOptions> = {
+  quotes: "double",
+  indent: 2,
+};
+
+/**
+ * Escape a string for use in code
+ */
+export function escapeString(
+  str: string,
+  quotes: "single" | "double" = "double",
+): string {
+  const quoteChar = quotes === "single" ? "'" : '"';
+  const escaped = str
+    .replace(/\\/g, "\\\\")
+    .replace(/\n/g, "\\n")
+    .replace(/\r/g, "\\r")
+    .replace(/\t/g, "\\t");
+
+  if (quotes === "single") {
+    return escaped.replace(/'/g, "\\'");
+  }
+  return escaped.replace(/"/g, '\\"');
+}
+
+/**
+ * Serialize a single prop value to a code string
+ *
+ * @returns Object with `value` (the serialized string) and `needsBraces` (whether JSX needs {})
+ */
+export function serializePropValue(
+  value: unknown,
+  options: SerializeOptions = {},
+): { value: string; needsBraces: boolean } {
+  const opts = { ...DEFAULT_OPTIONS, ...options };
+  const q = opts.quotes === "single" ? "'" : '"';
+
+  if (value === null) {
+    return { value: "null", needsBraces: true };
+  }
+
+  if (value === undefined) {
+    return { value: "undefined", needsBraces: true };
+  }
+
+  if (typeof value === "string") {
+    return {
+      value: `${q}${escapeString(value, opts.quotes)}${q}`,
+      needsBraces: false,
+    };
+  }
+
+  if (typeof value === "number") {
+    return { value: String(value), needsBraces: true };
+  }
+
+  if (typeof value === "boolean") {
+    if (value === true) {
+      return { value: "true", needsBraces: false }; // Can use shorthand
+    }
+    return { value: "false", needsBraces: true };
+  }
+
+  if (Array.isArray(value)) {
+    const items = value.map((v) => serializePropValue(v, opts).value);
+    return { value: `[${items.join(", ")}]`, needsBraces: true };
+  }
+
+  if (typeof value === "object") {
+    // Check for path reference
+    if (
+      "path" in value &&
+      typeof (value as { path: unknown }).path === "string"
+    ) {
+      return {
+        value: `{ path: ${q}${escapeString((value as { path: string }).path, opts.quotes)}${q} }`,
+        needsBraces: true,
+      };
+    }
+
+    const entries = Object.entries(value)
+      .filter(([, v]) => v !== undefined)
+      .map(([k, v]) => {
+        const serialized = serializePropValue(v, opts).value;
+        // Use shorthand if key matches value for simple identifiers
+        return `${k}: ${serialized}`;
+      });
+
+    return { value: `{ ${entries.join(", ")} }`, needsBraces: true };
+  }
+
+  return { value: String(value), needsBraces: true };
+}
+
+/**
+ * Serialize props object to JSX attributes string
+ */
+export function serializeProps(
+  props: Record<string, unknown>,
+  options: SerializeOptions = {},
+): string {
+  const parts: string[] = [];
+
+  for (const [key, value] of Object.entries(props)) {
+    if (value === undefined || value === null) continue;
+
+    const serialized = serializePropValue(value, options);
+
+    // Boolean true can be shorthand
+    if (typeof value === "boolean" && value === true) {
+      parts.push(key);
+    } else if (serialized.needsBraces) {
+      parts.push(`${key}={${serialized.value}}`);
+    } else {
+      parts.push(`${key}=${serialized.value}`);
+    }
+  }
+
+  return parts.join(" ");
+}

+ 130 - 0
packages/codegen/src/traverse.test.ts

@@ -0,0 +1,130 @@
+import { describe, it, expect } from "vitest";
+import {
+  traverseTree,
+  collectUsedComponents,
+  collectDataPaths,
+  collectActions,
+} from "./traverse";
+import type { UITree } from "@json-render/core";
+
+describe("traverseTree", () => {
+  it("visits all elements depth-first", () => {
+    const tree: UITree = {
+      root: "root",
+      elements: {
+        root: {
+          key: "root",
+          type: "Card",
+          props: {},
+          children: ["child1", "child2"],
+        },
+        child1: {
+          key: "child1",
+          type: "Text",
+          props: {},
+        },
+        child2: {
+          key: "child2",
+          type: "Button",
+          props: {},
+        },
+      },
+    };
+
+    const visited: string[] = [];
+    traverseTree(tree, (element) => {
+      visited.push(element.key);
+    });
+
+    expect(visited).toEqual(["root", "child1", "child2"]);
+  });
+
+  it("handles empty tree", () => {
+    const visited: string[] = [];
+    traverseTree(null as unknown as UITree, (element) => {
+      visited.push(element.key);
+    });
+    expect(visited).toEqual([]);
+  });
+});
+
+describe("collectUsedComponents", () => {
+  it("collects unique component types", () => {
+    const tree: UITree = {
+      root: "root",
+      elements: {
+        root: {
+          key: "root",
+          type: "Card",
+          props: {},
+          children: ["child1", "child2"],
+        },
+        child1: {
+          key: "child1",
+          type: "Text",
+          props: {},
+        },
+        child2: {
+          key: "child2",
+          type: "Text",
+          props: {},
+        },
+      },
+    };
+
+    const components = collectUsedComponents(tree);
+    expect(components).toEqual(new Set(["Card", "Text"]));
+  });
+});
+
+describe("collectDataPaths", () => {
+  it("collects paths from valuePath props", () => {
+    const tree: UITree = {
+      root: "root",
+      elements: {
+        root: {
+          key: "root",
+          type: "Metric",
+          props: { valuePath: "analytics/revenue" },
+        },
+      },
+    };
+
+    const paths = collectDataPaths(tree);
+    expect(paths).toEqual(new Set(["analytics/revenue"]));
+  });
+
+  it("collects paths from dynamic value objects", () => {
+    const tree: UITree = {
+      root: "root",
+      elements: {
+        root: {
+          key: "root",
+          type: "Text",
+          props: { content: { path: "user/name" } },
+        },
+      },
+    };
+
+    const paths = collectDataPaths(tree);
+    expect(paths).toEqual(new Set(["user/name"]));
+  });
+});
+
+describe("collectActions", () => {
+  it("collects action names from props", () => {
+    const tree: UITree = {
+      root: "root",
+      elements: {
+        root: {
+          key: "root",
+          type: "Button",
+          props: { action: "submit_form" },
+        },
+      },
+    };
+
+    const actions = collectActions(tree);
+    expect(actions).toEqual(new Set(["submit_form"]));
+  });
+});

+ 170 - 0
packages/codegen/src/traverse.ts

@@ -0,0 +1,170 @@
+import type { UITree, UIElement } from "@json-render/core";
+
+/**
+ * Visitor function for tree traversal
+ */
+export interface TreeVisitor {
+  (element: UIElement, depth: number, parent: UIElement | null): void;
+}
+
+/**
+ * Traverse a UI tree depth-first
+ */
+export function traverseTree(
+  tree: UITree,
+  visitor: TreeVisitor,
+  startKey?: string,
+): void {
+  if (!tree || !tree.root) return;
+
+  const rootKey = startKey ?? tree.root;
+  const rootElement = tree.elements[rootKey];
+  if (!rootElement) return;
+
+  function visit(key: string, depth: number, parent: UIElement | null): void {
+    const element = tree.elements[key];
+    if (!element) return;
+
+    visitor(element, depth, parent);
+
+    if (element.children) {
+      for (const childKey of element.children) {
+        visit(childKey, depth + 1, element);
+      }
+    }
+  }
+
+  visit(rootKey, 0, null);
+}
+
+/**
+ * Collect all unique component types used in a tree
+ */
+export function collectUsedComponents(tree: UITree): Set<string> {
+  const components = new Set<string>();
+
+  traverseTree(tree, (element) => {
+    components.add(element.type);
+  });
+
+  return components;
+}
+
+/**
+ * Collect all data paths referenced in a tree
+ */
+export function collectDataPaths(tree: UITree): Set<string> {
+  const paths = new Set<string>();
+
+  traverseTree(tree, (element) => {
+    // Check props for data paths
+    for (const [propName, propValue] of Object.entries(element.props)) {
+      // Check for path props (e.g., valuePath, dataPath, bindPath)
+      if (typeof propValue === "string") {
+        if (
+          propName.endsWith("Path") ||
+          propName === "bindPath" ||
+          propName === "dataPath"
+        ) {
+          paths.add(propValue);
+        }
+      }
+
+      // Check for dynamic value objects with path
+      if (
+        propValue &&
+        typeof propValue === "object" &&
+        "path" in propValue &&
+        typeof (propValue as { path: unknown }).path === "string"
+      ) {
+        paths.add((propValue as { path: string }).path);
+      }
+    }
+
+    // Check visibility conditions for paths
+    if (element.visible && typeof element.visible === "object") {
+      collectPathsFromCondition(element.visible, paths);
+    }
+  });
+
+  return paths;
+}
+
+function collectPathsFromCondition(
+  condition: unknown,
+  paths: Set<string>,
+): void {
+  if (!condition || typeof condition !== "object") return;
+
+  const cond = condition as Record<string, unknown>;
+
+  if ("path" in cond && typeof cond.path === "string") {
+    paths.add(cond.path);
+  }
+
+  if ("and" in cond && Array.isArray(cond.and)) {
+    for (const sub of cond.and) {
+      collectPathsFromCondition(sub, paths);
+    }
+  }
+
+  if ("or" in cond && Array.isArray(cond.or)) {
+    for (const sub of cond.or) {
+      collectPathsFromCondition(sub, paths);
+    }
+  }
+
+  if ("not" in cond) {
+    collectPathsFromCondition(cond.not, paths);
+  }
+
+  // Check comparison operators
+  for (const op of ["eq", "neq", "gt", "gte", "lt", "lte"]) {
+    if (op in cond && Array.isArray(cond[op])) {
+      for (const operand of cond[op] as unknown[]) {
+        if (
+          operand &&
+          typeof operand === "object" &&
+          "path" in operand &&
+          typeof (operand as { path: unknown }).path === "string"
+        ) {
+          paths.add((operand as { path: string }).path);
+        }
+      }
+    }
+  }
+}
+
+/**
+ * Collect all action names used in a tree
+ */
+export function collectActions(tree: UITree): Set<string> {
+  const actions = new Set<string>();
+
+  traverseTree(tree, (element) => {
+    for (const propValue of Object.values(element.props)) {
+      // Check for action prop (string action name)
+      if (typeof propValue === "string" && propValue.startsWith("action:")) {
+        actions.add(propValue.slice(7));
+      }
+
+      // Check for action objects
+      if (
+        propValue &&
+        typeof propValue === "object" &&
+        "name" in propValue &&
+        typeof (propValue as { name: unknown }).name === "string"
+      ) {
+        actions.add((propValue as { name: string }).name);
+      }
+    }
+
+    // Also check direct action prop
+    const actionProp = element.props.action;
+    if (typeof actionProp === "string") {
+      actions.add(actionProp);
+    }
+  });
+
+  return actions;
+}

+ 19 - 0
packages/codegen/src/types.ts

@@ -0,0 +1,19 @@
+import type { UITree } from "@json-render/core";
+
+/**
+ * Represents a generated file
+ */
+export interface GeneratedFile {
+  /** File path relative to project root */
+  path: string;
+  /** File contents */
+  content: string;
+}
+
+/**
+ * Interface for code generators
+ */
+export interface CodeGenerator {
+  /** Generate files from a UI tree */
+  generate(tree: UITree): GeneratedFile[];
+}

+ 9 - 0
packages/codegen/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@repo/typescript-config/base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 10 - 0
packages/codegen/tsup.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+  entry: ["src/index.ts"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: ["@json-render/core"],
+});

+ 25 - 0
pnpm-lock.yaml

@@ -41,6 +41,9 @@ importers:
       '@ai-sdk/gateway':
         specifier: ^3.0.13
         version: 3.0.13(zod@4.3.5)
+      '@json-render/codegen':
+        specifier: workspace:*
+        version: link:../../packages/codegen
       '@json-render/core':
         specifier: workspace:*
         version: link:../../packages/core
@@ -138,6 +141,9 @@ importers:
       '@ai-sdk/gateway':
         specifier: ^3.0.13
         version: 3.0.13(zod@4.3.5)
+      '@json-render/codegen':
+        specifier: workspace:*
+        version: link:../../packages/codegen
       '@json-render/core':
         specifier: workspace:*
         version: link:../../packages/core
@@ -156,6 +162,9 @@ importers:
       react-dom:
         specifier: 19.2.3
         version: 19.2.3(react@19.2.3)
+      shiki:
+        specifier: ^3.0.0
+        version: 3.21.0
       zod:
         specifier: ^4.0.0
         version: 4.3.5
@@ -179,6 +188,22 @@ importers:
         specifier: ^5.7.2
         version: 5.9.2
 
+  packages/codegen:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+    devDependencies:
+      '@repo/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      tsup:
+        specifier: ^8.0.2
+        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.2)(yaml@2.8.2)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.2
+
   packages/core:
     dependencies:
       zod: