Bladeren bron

examples (#229)

* add examples

* live demos

* og's

* r3f mobile

* rate limits

* fix lint
Chris Tate 3 maanden geleden
bovenliggende
commit
4bb1151b6c
62 gewijzigde bestanden met toevoegingen van 2154 en 115 verwijderingen
  1. 7 1
      apps/web/app/(main)/docs/layout.tsx
  2. 133 0
      apps/web/app/(main)/examples/page.tsx
  3. 74 0
      apps/web/app/api/search/route.ts
  4. 4 4
      apps/web/components/docs-chat.tsx
  5. 114 39
      apps/web/components/header.tsx
  6. 266 0
      apps/web/components/search.tsx
  7. 83 0
      apps/web/components/table-of-contents.tsx
  8. 1 57
      apps/web/lib/docs-navigation.ts
  9. 152 0
      apps/web/lib/examples.ts
  10. 1 0
      apps/web/lib/page-titles.ts
  11. 76 0
      apps/web/lib/search-index.ts
  12. 14 4
      apps/web/mdx-components.tsx
  13. 15 0
      examples/chat/.env.example
  14. 26 0
      examples/chat/app/api/generate/route.ts
  15. BIN
      examples/chat/app/opengraph-image.png
  16. 57 0
      examples/chat/lib/rate-limit.ts
  17. 2 0
      examples/chat/package.json
  18. 7 0
      examples/dashboard/.env.example
  19. 26 0
      examples/dashboard/app/api/generate/route.ts
  20. BIN
      examples/dashboard/app/opengraph-image.png
  21. 57 0
      examples/dashboard/lib/rate-limit.ts
  22. 2 0
      examples/dashboard/package.json
  23. 15 0
      examples/image/.env.example
  24. 26 0
      examples/image/app/api/generate/route.ts
  25. BIN
      examples/image/app/opengraph-image.png
  26. 57 0
      examples/image/lib/rate-limit.ts
  27. 2 0
      examples/image/package.json
  28. BIN
      examples/no-ai/app/opengraph-image.png
  29. 7 0
      examples/react-email/.env.example
  30. 26 0
      examples/react-email/app/api/generate/route.ts
  31. BIN
      examples/react-email/app/opengraph-image.png
  32. 57 0
      examples/react-email/lib/rate-limit.ts
  33. 2 0
      examples/react-email/package.json
  34. 7 0
      examples/react-pdf/.env.example
  35. 26 0
      examples/react-pdf/app/api/generate/route.ts
  36. BIN
      examples/react-pdf/app/opengraph-image.png
  37. 57 0
      examples/react-pdf/lib/rate-limit.ts
  38. 2 0
      examples/react-pdf/package.json
  39. BIN
      examples/react-three-fiber/app/opengraph-image.png
  40. 254 2
      examples/react-three-fiber/app/page.tsx
  41. 7 0
      examples/remotion/.env.example
  42. 26 0
      examples/remotion/app/api/generate/route.ts
  43. BIN
      examples/remotion/app/opengraph-image.png
  44. 57 0
      examples/remotion/lib/rate-limit.ts
  45. 2 0
      examples/remotion/package.json
  46. 7 0
      examples/solid/index.html
  47. BIN
      examples/solid/public/og-image.png
  48. 15 0
      examples/svelte-chat/.env.example
  49. 2 0
      examples/svelte-chat/package.json
  50. 7 0
      examples/svelte-chat/src/app.html
  51. 57 0
      examples/svelte-chat/src/lib/rate-limit.ts
  52. 25 0
      examples/svelte-chat/src/routes/api/generate/+server.ts
  53. BIN
      examples/svelte-chat/static/og-image.png
  54. 7 0
      examples/svelte/index.html
  55. BIN
      examples/svelte/public/og-image.png
  56. 7 0
      examples/vite-renderers/index.html
  57. BIN
      examples/vite-renderers/public/og-image.png
  58. 7 0
      examples/vue/index.html
  59. BIN
      examples/vue/public/og-image.png
  60. 5 2
      package.json
  61. 84 6
      pnpm-lock.yaml
  62. 184 0
      scripts/generate-og-images.mts

+ 7 - 1
apps/web/app/(main)/docs/layout.tsx

@@ -1,6 +1,7 @@
 import { DocsMobileNav } from "@/components/docs-mobile-nav";
 import { DocsSidebar } from "@/components/docs-sidebar";
 import { CopyPageButton } from "@/components/copy-page-button";
+import { TableOfContents } from "@/components/table-of-contents";
 
 export default function DocsLayout({
   children,
@@ -10,7 +11,7 @@ export default function DocsLayout({
   return (
     <>
       <DocsMobileNav />
-      <div className="max-w-5xl mx-auto px-6 py-8 lg:py-12 flex gap-16">
+      <div className="max-w-7xl mx-auto px-6 py-8 lg:py-12 flex gap-12">
         {/* Sidebar */}
         <aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
           <DocsSidebar />
@@ -23,6 +24,11 @@ export default function DocsLayout({
           </div>
           <article>{children}</article>
         </div>
+
+        {/* On this page */}
+        <aside className="w-44 shrink-0 hidden xl:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
+          <TableOfContents />
+        </aside>
       </div>
     </>
   );

+ 133 - 0
apps/web/app/(main)/examples/page.tsx

@@ -0,0 +1,133 @@
+"use client";
+
+import { useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { examples, allTags, getGitHubUrl, type Example } from "@/lib/examples";
+import { cn } from "@/lib/utils";
+
+function ExampleCard({ example }: { example: Example }) {
+  return (
+    <div className="group flex flex-col rounded-xl border border-border bg-card text-card-foreground overflow-hidden transition-colors hover:border-foreground/25">
+      <div className="flex flex-1 flex-col gap-3 p-5">
+        <h3 className="font-semibold leading-none">{example.title}</h3>
+
+        <p className="text-sm text-muted-foreground leading-relaxed">
+          {example.description}
+        </p>
+
+        <div className="flex flex-wrap gap-1.5">
+          {example.tags.map((tag) => (
+            <Badge key={tag} variant="secondary" className="text-[11px]">
+              {tag}
+            </Badge>
+          ))}
+        </div>
+
+        <div className="mt-auto flex items-center gap-3 pt-2">
+          {example.demoUrl && (
+            <a
+              href={example.demoUrl}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary transition-colors"
+            >
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                width="14"
+                height="14"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                strokeWidth="2"
+                strokeLinecap="round"
+                strokeLinejoin="round"
+              >
+                <path d="M15 3h6v6" />
+                <path d="M10 14 21 3" />
+                <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
+              </svg>
+              Live Demo
+            </a>
+          )}
+          <a
+            href={getGitHubUrl(example)}
+            target="_blank"
+            rel="noopener noreferrer"
+            className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
+          >
+            <svg
+              viewBox="0 0 16 16"
+              className="h-3.5 w-3.5"
+              fill="currentColor"
+              aria-hidden="true"
+            >
+              <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
+            </svg>
+            Source
+          </a>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export default function ExamplesPage() {
+  const [activeTag, setActiveTag] = useState<string | null>(null);
+
+  const filtered = activeTag
+    ? examples.filter((e) => e.tags.includes(activeTag))
+    : examples;
+
+  return (
+    <section className="mx-auto max-w-6xl px-6 py-16">
+      <div className="mb-10">
+        <h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
+          Examples
+        </h1>
+        <p className="mt-3 text-lg text-muted-foreground">
+          Explore json-render across frameworks, renderers, and use cases.
+        </p>
+      </div>
+
+      <div className="mb-8 flex flex-wrap gap-2">
+        <button
+          onClick={() => setActiveTag(null)}
+          className={cn(
+            "rounded-full border px-3 py-1 text-xs font-medium transition-colors",
+            activeTag === null
+              ? "border-foreground bg-foreground text-background"
+              : "border-border text-muted-foreground hover:text-foreground hover:border-foreground/50",
+          )}
+        >
+          All
+        </button>
+        {allTags.map((tag) => (
+          <button
+            key={tag}
+            onClick={() => setActiveTag(activeTag === tag ? null : tag)}
+            className={cn(
+              "rounded-full border px-3 py-1 text-xs font-medium transition-colors",
+              activeTag === tag
+                ? "border-foreground bg-foreground text-background"
+                : "border-border text-muted-foreground hover:text-foreground hover:border-foreground/50",
+            )}
+          >
+            {tag}
+          </button>
+        ))}
+      </div>
+
+      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
+        {filtered.map((example) => (
+          <ExampleCard key={example.slug} example={example} />
+        ))}
+      </div>
+
+      {filtered.length === 0 && (
+        <p className="py-12 text-center text-muted-foreground">
+          No examples match the selected filter.
+        </p>
+      )}
+    </section>
+  );
+}

+ 74 - 0
apps/web/app/api/search/route.ts

@@ -0,0 +1,74 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getSearchIndex } from "@/lib/search-index";
+
+export async function GET(req: NextRequest) {
+  const q = req.nextUrl.searchParams.get("q")?.trim().toLowerCase();
+
+  if (!q) {
+    return NextResponse.json({ results: [] });
+  }
+
+  const index = await getSearchIndex();
+  const terms = q.split(/\s+/).filter(Boolean);
+
+  const results = index
+    .map((entry) => {
+      const titleLower = entry.title.toLowerCase();
+      const contentLower = entry.content.toLowerCase();
+
+      const titleMatch = terms.every((t) => titleLower.includes(t));
+      const contentMatch = terms.every((t) => contentLower.includes(t));
+
+      if (!titleMatch && !contentMatch) return null;
+
+      let snippet = "";
+      if (contentMatch) {
+        const firstTermIdx = Math.min(
+          ...terms.map((t) => {
+            const idx = contentLower.indexOf(t);
+            return idx === -1 ? Infinity : idx;
+          }),
+        );
+        if (firstTermIdx !== Infinity) {
+          const start = Math.max(0, firstTermIdx - 40);
+          const end = Math.min(entry.content.length, firstTermIdx + 120);
+          snippet =
+            (start > 0 ? "..." : "") +
+            entry.content.slice(start, end).replace(/\n/g, " ") +
+            (end < entry.content.length ? "..." : "");
+        }
+      }
+
+      return {
+        title: entry.title,
+        href: entry.href,
+        section: entry.section,
+        snippet,
+        score: titleMatch ? 2 : 1,
+      };
+    })
+    .filter(
+      (
+        r,
+      ): r is {
+        title: string;
+        href: string;
+        section: string;
+        snippet: string;
+        score: number;
+      } => r !== null,
+    )
+    .sort((a, b) => b.score - a.score)
+    .slice(0, 20)
+    .map(({ title, href, section, snippet }) => ({
+      title,
+      href,
+      section,
+      snippet,
+    }));
+
+  return NextResponse.json(
+    { results },
+    { headers: { "Cache-Control": "public, max-age=60" } },
+  );
+}

+ 4 - 4
apps/web/components/docs-chat.tsx

@@ -261,10 +261,10 @@ export function DocsChat({
     }
   }, [messages, isLoading]);
 
-  // Cmd+K to open sidebar and focus prompt, Escape to close
+  // Cmd+I to open sidebar and focus prompt, Escape to close
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
+      if (e.key === "i" && (e.metaKey || e.ctrlKey)) {
         e.preventDefault();
         setOpen((prev) => {
           if (!prev) {
@@ -497,12 +497,12 @@ export function DocsChat({
       {!open && (
         <button
           onClick={() => setOpen(true)}
-          className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border bg-background text-primary shadow-lg hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
+          className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border border-primary bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors text-sm font-medium"
           aria-label="Ask AI"
         >
           Ask AI
           <kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
-            <span>&#8984;</span>K
+            <span>&#8984;</span>I
           </kbd>
         </button>
       )}

+ 114 - 39
apps/web/components/header.tsx

@@ -1,17 +1,59 @@
 "use client";
 
+import { useState } from "react";
 import Link from "next/link";
 import { usePathname } from "next/navigation";
 import { ThemeToggle } from "./theme-toggle";
+import { Search } from "./search";
+import {
+  Sheet,
+  SheetTrigger,
+  SheetContent,
+  SheetTitle,
+} from "@/components/ui/sheet";
 import { cn } from "@/lib/utils";
 
+const navLinks = [
+  { href: "/playground", label: "Playground" },
+  { href: "/examples", label: "Examples" },
+  { href: "/docs", label: "Docs" },
+];
+
+function GitHubLink({ className }: { className?: string }) {
+  return (
+    <a
+      href="https://github.com/vercel-labs/json-render"
+      target="_blank"
+      rel="noopener noreferrer"
+      className={cn(
+        "flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors",
+        className,
+      )}
+    >
+      <svg
+        viewBox="0 0 16 16"
+        className="h-4 w-4"
+        fill="currentColor"
+        aria-hidden="true"
+      >
+        <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
+      </svg>
+      <span>12k</span>
+    </a>
+  );
+}
+
 export function Header() {
   const pathname = usePathname();
+  const [mobileOpen, setMobileOpen] = useState(false);
 
   const isActive = (href: string) => {
     if (href === "/playground") {
       return pathname === "/playground";
     }
+    if (href === "/examples") {
+      return pathname.startsWith("/examples");
+    }
     if (href === "/docs") {
       return pathname.startsWith("/docs");
     }
@@ -62,48 +104,81 @@ export function Header() {
             </span>
           </Link>
         </div>
-        <nav className="flex items-center gap-4">
-          <Link
-            href="/playground"
-            className={cn(
-              "text-sm transition-colors",
-              isActive("/playground")
-                ? "text-primary font-medium"
-                : "text-muted-foreground hover:text-foreground",
-            )}
-          >
-            <span className="sm:hidden">Play</span>
-            <span className="hidden sm:inline">Playground</span>
-          </Link>
-          <Link
-            href="/docs"
-            className={cn(
-              "text-sm transition-colors",
-              isActive("/docs")
-                ? "text-primary font-medium"
-                : "text-muted-foreground hover:text-foreground",
-            )}
-          >
-            Docs
-          </Link>
-          <a
-            href="https://github.com/vercel-labs/json-render"
-            target="_blank"
-            rel="noopener noreferrer"
-            className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
-          >
-            <svg
-              viewBox="0 0 16 16"
-              className="h-4 w-4"
-              fill="currentColor"
-              aria-hidden="true"
+
+        {/* Desktop nav */}
+        <nav className="hidden sm:flex items-center gap-4">
+          {navLinks.map((link) => (
+            <Link
+              key={link.href}
+              href={link.href}
+              className={cn(
+                "text-sm transition-colors",
+                isActive(link.href)
+                  ? "text-primary"
+                  : "text-muted-foreground hover:text-foreground",
+              )}
             >
-              <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
-            </svg>
-            <span>12k</span>
-          </a>
+              {link.label}
+            </Link>
+          ))}
+          <Search />
+          <GitHubLink />
           <ThemeToggle />
         </nav>
+
+        {/* Mobile nav */}
+        <div className="flex sm:hidden items-center gap-3">
+          <Search />
+          <GitHubLink />
+          <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
+            <SheetTrigger
+              className="flex items-center justify-center"
+              aria-label="Open menu"
+            >
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                width="20"
+                height="20"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                strokeWidth="2"
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                className="text-muted-foreground"
+              >
+                <line x1="4" x2="20" y1="12" y2="12" />
+                <line x1="4" x2="20" y1="6" y2="6" />
+                <line x1="4" x2="20" y1="18" y2="18" />
+              </svg>
+            </SheetTrigger>
+            <SheetContent side="right" className="overflow-y-auto p-6">
+              <SheetTitle className="mb-6">Menu</SheetTitle>
+              <nav className="flex flex-col gap-1">
+                {navLinks.map((link) => (
+                  <Link
+                    key={link.href}
+                    href={link.href}
+                    onClick={() => setMobileOpen(false)}
+                    className={cn(
+                      "block py-2.5 text-sm transition-colors",
+                      isActive(link.href)
+                        ? "text-primary"
+                        : "text-muted-foreground hover:text-foreground",
+                    )}
+                  >
+                    {link.label}
+                  </Link>
+                ))}
+                <div className="my-3 border-t border-border" />
+                <div className="flex items-center justify-between py-2.5">
+                  <span className="text-sm text-muted-foreground">Theme</span>
+                  <ThemeToggle />
+                </div>
+              </nav>
+            </SheetContent>
+          </Sheet>
+        </div>
       </div>
     </header>
   );

+ 266 - 0
apps/web/components/search.tsx

@@ -0,0 +1,266 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+import { cn } from "@/lib/utils";
+
+type SearchResult = {
+  title: string;
+  href: string;
+  section: string;
+  snippet: string;
+};
+
+export function Search() {
+  const router = useRouter();
+  const [open, setOpen] = useState(false);
+  const [query, setQuery] = useState("");
+  const [results, setResults] = useState<SearchResult[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [activeIndex, setActiveIndex] = useState(0);
+  const inputRef = useRef<HTMLInputElement>(null);
+  const listRef = useRef<HTMLDivElement>(null);
+  const abortRef = useRef<AbortController | null>(null);
+
+  const navigate = useCallback(
+    (href: string) => {
+      setOpen(false);
+      setQuery("");
+      setResults([]);
+      router.push(href);
+    },
+    [router],
+  );
+
+  useEffect(() => {
+    function onKeyDown(e: KeyboardEvent) {
+      if ((e.metaKey || e.ctrlKey) && e.key === "k") {
+        e.preventDefault();
+        setOpen((prev) => !prev);
+      }
+    }
+    document.addEventListener("keydown", onKeyDown);
+    return () => document.removeEventListener("keydown", onKeyDown);
+  }, []);
+
+  useEffect(() => {
+    if (open) {
+      setTimeout(() => inputRef.current?.focus(), 0);
+    } else {
+      setQuery("");
+      setResults([]);
+    }
+  }, [open]);
+
+  useEffect(() => {
+    const q = query.trim();
+    if (!q) {
+      setResults([]);
+      setLoading(false);
+      return;
+    }
+
+    setLoading(true);
+    abortRef.current?.abort();
+    const controller = new AbortController();
+    abortRef.current = controller;
+
+    const timeout = setTimeout(async () => {
+      try {
+        const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
+          signal: controller.signal,
+        });
+        if (res.ok) {
+          const data = await res.json();
+          setResults(data.results);
+        }
+      } catch {
+        // aborted or network error
+      } finally {
+        if (!controller.signal.aborted) {
+          setLoading(false);
+        }
+      }
+    }, 150);
+
+    return () => {
+      clearTimeout(timeout);
+      controller.abort();
+    };
+  }, [query]);
+
+  useEffect(() => {
+    setActiveIndex(0);
+  }, [results]);
+
+  function handleKeyDown(e: React.KeyboardEvent) {
+    if (e.key === "ArrowDown") {
+      e.preventDefault();
+      setActiveIndex((i) => Math.min(i + 1, results.length - 1));
+    } else if (e.key === "ArrowUp") {
+      e.preventDefault();
+      setActiveIndex((i) => Math.max(i - 1, 0));
+    } else if (e.key === "Enter" && results[activeIndex]) {
+      e.preventDefault();
+      navigate(results[activeIndex].href);
+    }
+  }
+
+  useEffect(() => {
+    const active = listRef.current?.querySelector("[data-active='true']");
+    active?.scrollIntoView({ block: "nearest" });
+  }, [activeIndex]);
+
+  const hasQuery = query.trim().length > 0;
+
+  return (
+    <>
+      <button
+        onClick={() => setOpen(true)}
+        className="hidden sm:flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:border-foreground/25 transition-colors"
+      >
+        <svg
+          xmlns="http://www.w3.org/2000/svg"
+          width="14"
+          height="14"
+          viewBox="0 0 24 24"
+          fill="none"
+          stroke="currentColor"
+          strokeWidth="2"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+        >
+          <circle cx="11" cy="11" r="8" />
+          <path d="m21 21-4.3-4.3" />
+        </svg>
+        Search docs
+        <kbd className="pointer-events-none ml-1 inline-flex items-center gap-0.5 rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
+          <span>&#8984;</span>K
+        </kbd>
+      </button>
+
+      <button
+        onClick={() => setOpen(true)}
+        className="sm:hidden flex items-center text-muted-foreground hover:text-foreground transition-colors"
+        aria-label="Search docs"
+      >
+        <svg
+          xmlns="http://www.w3.org/2000/svg"
+          width="16"
+          height="16"
+          viewBox="0 0 24 24"
+          fill="none"
+          stroke="currentColor"
+          strokeWidth="2"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+        >
+          <circle cx="11" cy="11" r="8" />
+          <path d="m21 21-4.3-4.3" />
+        </svg>
+      </button>
+
+      <Dialog open={open} onOpenChange={setOpen}>
+        <DialogContent
+          showCloseButton={false}
+          className="gap-0 p-0 sm:max-w-lg"
+        >
+          <DialogTitle className="sr-only">Search documentation</DialogTitle>
+          <div className="flex items-center gap-2 border-b px-3">
+            <svg
+              xmlns="http://www.w3.org/2000/svg"
+              width="16"
+              height="16"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="2"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              className="shrink-0 text-muted-foreground"
+            >
+              <circle cx="11" cy="11" r="8" />
+              <path d="m21 21-4.3-4.3" />
+            </svg>
+            <input
+              ref={inputRef}
+              value={query}
+              onChange={(e) => setQuery(e.target.value)}
+              onKeyDown={handleKeyDown}
+              placeholder="Search docs..."
+              className="flex-1 bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
+            />
+            {query && (
+              <button
+                onClick={() => setQuery("")}
+                className="text-muted-foreground hover:text-foreground"
+              >
+                <svg
+                  xmlns="http://www.w3.org/2000/svg"
+                  width="14"
+                  height="14"
+                  viewBox="0 0 24 24"
+                  fill="none"
+                  stroke="currentColor"
+                  strokeWidth="2"
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                >
+                  <path d="M18 6 6 18" />
+                  <path d="m6 6 12 12" />
+                </svg>
+              </button>
+            )}
+          </div>
+
+          <div
+            ref={listRef}
+            className="max-h-[min(60vh,400px)] overflow-y-auto p-2"
+          >
+            {loading && hasQuery ? (
+              <div className="flex items-center justify-center py-6">
+                <div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
+              </div>
+            ) : hasQuery && results.length === 0 ? (
+              <p className="py-6 text-center text-sm text-muted-foreground">
+                No results found.
+              </p>
+            ) : !hasQuery ? (
+              <p className="py-6 text-center text-sm text-muted-foreground">
+                Type to search documentation...
+              </p>
+            ) : (
+              results.map((item, i) => (
+                <button
+                  key={item.href}
+                  data-active={i === activeIndex}
+                  onClick={() => navigate(item.href)}
+                  onMouseEnter={() => setActiveIndex(i)}
+                  className={cn(
+                    "flex w-full flex-col gap-1 rounded-md px-3 py-2 text-left transition-colors",
+                    i === activeIndex
+                      ? "bg-accent text-accent-foreground"
+                      : "text-foreground",
+                  )}
+                >
+                  <div className="flex items-center justify-between gap-2">
+                    <span className="text-sm font-medium">{item.title}</span>
+                    <span className="shrink-0 text-xs text-muted-foreground">
+                      {item.section}
+                    </span>
+                  </div>
+                  {item.snippet && (
+                    <span className="line-clamp-2 text-xs text-muted-foreground leading-relaxed">
+                      {item.snippet}
+                    </span>
+                  )}
+                </button>
+              ))
+            )}
+          </div>
+        </DialogContent>
+      </Dialog>
+    </>
+  );
+}

+ 83 - 0
apps/web/components/table-of-contents.tsx

@@ -0,0 +1,83 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+
+type Heading = {
+  id: string;
+  text: string;
+  level: number;
+};
+
+function getHeadings(): Heading[] {
+  const article = document.querySelector("article");
+  if (!article) return [];
+  const elements = article.querySelectorAll("h2[id], h3[id]");
+  return Array.from(elements).map((el) => ({
+    id: el.id,
+    text: el.textContent?.replace(/#$/, "").trim() ?? "",
+    level: el.tagName === "H3" ? 3 : 2,
+  }));
+}
+
+export function TableOfContents() {
+  const pathname = usePathname();
+  const [headings, setHeadings] = useState<Heading[]>([]);
+  const [activeId, setActiveId] = useState<string>("");
+
+  useEffect(() => {
+    const timer = setTimeout(() => setHeadings(getHeadings()), 100);
+    return () => clearTimeout(timer);
+  }, [pathname]);
+
+  useEffect(() => {
+    if (headings.length === 0) return;
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        for (const entry of entries) {
+          if (entry.isIntersecting) {
+            setActiveId(entry.target.id);
+          }
+        }
+      },
+      { rootMargin: "0px 0px -75% 0px", threshold: 0.1 },
+    );
+
+    for (const h of headings) {
+      const el = document.getElementById(h.id);
+      if (el) observer.observe(el);
+    }
+
+    return () => observer.disconnect();
+  }, [headings]);
+
+  if (headings.length === 0) return null;
+
+  return (
+    <nav aria-label="On this page">
+      <h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
+        On this page
+      </h4>
+      <ul className="space-y-1">
+        {headings.map((h) => (
+          <li key={h.id}>
+            <a
+              href={`#${h.id}`}
+              className={cn(
+                "block text-xs leading-relaxed py-0.5 transition-colors",
+                h.level === 3 && "pl-3",
+                activeId === h.id
+                  ? "text-foreground"
+                  : "text-muted-foreground hover:text-foreground",
+              )}
+            >
+              {h.text}
+            </a>
+          </li>
+        ))}
+      </ul>
+    </nav>
+  );
+}

+ 1 - 57
apps/web/lib/docs-navigation.ts

@@ -45,63 +45,7 @@ export const docsNavigation: NavSection[] = [
   },
   {
     title: "Examples",
-    items: [
-      {
-        title: "Chat",
-        href: "https://github.com/vercel-labs/json-render/tree/main/examples/chat",
-        external: true,
-      },
-      {
-        title: "Dashboard",
-        href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
-        external: true,
-      },
-      {
-        title: "React Native",
-        href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native",
-        external: true,
-      },
-      {
-        title: "React PDF",
-        href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf",
-        external: true,
-      },
-      {
-        title: "React Email",
-        href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-email",
-        external: true,
-      },
-      {
-        title: "Remotion",
-        href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
-        external: true,
-      },
-      {
-        title: "Image",
-        href: "https://github.com/vercel-labs/json-render/tree/main/examples/image",
-        external: true,
-      },
-      {
-        title: "Svelte",
-        href: "https://github.com/vercel-labs/json-render/tree/main/examples/svelte",
-        external: true,
-      },
-      {
-        title: "Vue",
-        href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
-        external: true,
-      },
-      {
-        title: "Renders with Vite (Vue / React / Svelte / Solid)",
-        href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
-        external: true,
-      },
-      {
-        title: "MCP App",
-        href: "https://github.com/vercel-labs/json-render/tree/main/examples/mcp",
-        external: true,
-      },
-    ],
+    items: [{ title: "Browse All Examples", href: "/examples" }],
   },
   {
     title: "Guides",

+ 152 - 0
apps/web/lib/examples.ts

@@ -0,0 +1,152 @@
+export type Example = {
+  slug: string;
+  title: string;
+  description: string;
+  tags: string[];
+  githubPath: string;
+  demoUrl?: string;
+};
+
+const GITHUB_BASE =
+  "https://github.com/vercel-labs/json-render/tree/main/examples";
+
+export const examples: Example[] = [
+  {
+    slug: "chat",
+    title: "Chat",
+    description:
+      "AI chat app with tool calling, streaming UI, and rich components powered by the AI SDK.",
+    tags: ["React", "Next.js", "AI"],
+    githubPath: "examples/chat",
+    demoUrl: "https://chat-demo.json-render.dev",
+  },
+  {
+    slug: "dashboard",
+    title: "Dashboard",
+    description:
+      "AI-generated dashboard with drag-and-drop, charts, and real-time data binding.",
+    tags: ["React", "Next.js", "AI"],
+    githubPath: "examples/dashboard",
+    demoUrl: "https://dashboard-demo.json-render.dev",
+  },
+  {
+    slug: "no-ai",
+    title: "No AI",
+    description:
+      "Static specs rendered without any AI — forms, cards, tables, and more from hardcoded JSON.",
+    tags: ["React", "Next.js"],
+    githubPath: "examples/no-ai",
+    demoUrl: "https://no-ai-demo.json-render.dev",
+  },
+  {
+    slug: "svelte",
+    title: "Svelte",
+    description:
+      "Svelte renderer demo with counter, todo list, and two-way data binding.",
+    tags: ["Svelte", "Vite"],
+    githubPath: "examples/svelte",
+    demoUrl: "https://svelte-demo.json-render.dev",
+  },
+  {
+    slug: "svelte-chat",
+    title: "Svelte Chat",
+    description: "AI chat app built with SvelteKit and the Svelte renderer.",
+    tags: ["Svelte", "SvelteKit", "AI"],
+    githubPath: "examples/svelte-chat",
+    demoUrl: "https://json-render-svelte-chat-demo.labs.vercel.dev",
+  },
+  {
+    slug: "vue",
+    title: "Vue",
+    description:
+      "Vue renderer demo with counter, todo list, and two-way data binding.",
+    tags: ["Vue", "Vite"],
+    githubPath: "examples/vue",
+    demoUrl: "https://vue-demo.json-render.dev",
+  },
+  {
+    slug: "solid",
+    title: "Solid",
+    description:
+      "Solid renderer demo with counter, todo list, and two-way data binding.",
+    tags: ["Solid", "Vite"],
+    githubPath: "examples/solid",
+    demoUrl: "https://solid-demo.json-render.dev",
+  },
+  {
+    slug: "vite-renderers",
+    title: "Multi-Framework Renderers",
+    description:
+      "Same spec rendered with React, Vue, Svelte, and Solid side by side — hot-swappable at runtime.",
+    tags: ["React", "Vue", "Svelte", "Solid", "Vite"],
+    githubPath: "examples/vite-renderers",
+  },
+  {
+    slug: "react-email",
+    title: "React Email",
+    description:
+      "Generate HTML and plain-text emails from json-render specs using React Email.",
+    tags: ["React", "Email"],
+    githubPath: "examples/react-email",
+    demoUrl: "https://react-email-demo.json-render.dev",
+  },
+  {
+    slug: "react-pdf",
+    title: "React PDF",
+    description:
+      "Generate PDF documents from json-render specs with @react-pdf/renderer.",
+    tags: ["React", "PDF"],
+    githubPath: "examples/react-pdf",
+    demoUrl: "https://react-pdf-demo.json-render.dev",
+  },
+  {
+    slug: "react-three-fiber",
+    title: "React Three Fiber",
+    description:
+      "3D scenes generated from json-render specs using Three.js and React Three Fiber.",
+    tags: ["React", "3D"],
+    githubPath: "examples/react-three-fiber",
+    demoUrl: "https://react-three-fiber-demo.json-render.dev",
+  },
+  {
+    slug: "react-native",
+    title: "React Native",
+    description:
+      "Mobile app rendering json-render specs with Expo and React Native.",
+    tags: ["React Native", "Expo"],
+    githubPath: "examples/react-native",
+  },
+  {
+    slug: "remotion",
+    title: "Remotion",
+    description: "Generate videos from json-render specs using Remotion.",
+    tags: ["React", "Video"],
+    githubPath: "examples/remotion",
+    demoUrl: "https://remotion-demo.json-render.dev",
+  },
+  {
+    slug: "image",
+    title: "Image",
+    description:
+      "Generate OG images and social cards from json-render specs using Satori.",
+    tags: ["React", "Image"],
+    githubPath: "examples/image",
+    demoUrl: "https://image-demo.json-render.dev",
+  },
+  {
+    slug: "mcp",
+    title: "MCP App",
+    description:
+      "MCP server that serves shadcn UIs to Claude, ChatGPT, Cursor, and VS Code.",
+    tags: ["React", "MCP", "Vite"],
+    githubPath: "examples/mcp",
+  },
+];
+
+export const allTags = Array.from(
+  new Set(examples.flatMap((e) => e.tags)),
+).sort();
+
+export function getGitHubUrl(example: Example): string {
+  return `${GITHUB_BASE}/${example.slug}`;
+}

+ 1 - 0
apps/web/lib/page-titles.ts

@@ -11,6 +11,7 @@ export const PAGE_TITLES: Record<string, string> = {
 
   // Top-level
   playground: "Playground",
+  examples: "Examples",
 
   // Docs
   docs: "Introduction",

+ 76 - 0
apps/web/lib/search-index.ts

@@ -0,0 +1,76 @@
+import { readFile } from "fs/promises";
+import { join } from "path";
+import { docsNavigation } from "./docs-navigation";
+import { mdxToCleanMarkdown } from "./mdx-to-markdown";
+
+export type IndexEntry = {
+  title: string;
+  href: string;
+  section: string;
+  content: string;
+};
+
+let cached: IndexEntry[] | null = null;
+
+function stripMarkdown(md: string): string {
+  return (
+    md
+      // Remove fenced code blocks entirely
+      .replace(/```[\s\S]*?```/g, "")
+      // Remove inline code
+      .replace(/`[^`]+`/g, "")
+      // Remove markdown links, keep text
+      .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+      // Remove heading markers
+      .replace(/^#{1,6}\s+/gm, "")
+      // Remove bold/italic markers
+      .replace(/\*{1,3}([^*]+)\*{1,3}/g, "$1")
+      // Remove HTML tags
+      .replace(/<[^>]+>/g, "")
+      // Collapse whitespace
+      .replace(/\n{3,}/g, "\n\n")
+      .trim()
+  );
+}
+
+function mdxFileForSlug(slug: string): string {
+  const docsRoot = join(process.cwd(), "app", "(main)", "docs");
+  if (slug === "/docs") {
+    return join(docsRoot, "page.mdx");
+  }
+  const rest = slug.replace(/^\/docs\/?/, "");
+  return join(docsRoot, ...rest.split("/"), "page.mdx");
+}
+
+export async function getSearchIndex(): Promise<IndexEntry[]> {
+  if (cached) return cached;
+
+  const entries: IndexEntry[] = [];
+
+  for (const section of docsNavigation) {
+    for (const item of section.items) {
+      if (item.external) continue;
+      try {
+        const raw = await readFile(mdxFileForSlug(item.href), "utf-8");
+        const md = mdxToCleanMarkdown(raw);
+        const content = stripMarkdown(md);
+        entries.push({
+          title: item.title,
+          href: item.href,
+          section: section.title,
+          content,
+        });
+      } catch {
+        entries.push({
+          title: item.title,
+          href: item.href,
+          section: section.title,
+          content: "",
+        });
+      }
+    }
+  }
+
+  cached = entries;
+  return entries;
+}

+ 14 - 4
apps/web/mdx-components.tsx

@@ -35,16 +35,26 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
     h2: ({ children }: { children?: React.ReactNode }) => {
       const id = slugify(extractText(children));
       return (
-        <h2 id={id} className="text-xl font-semibold mt-12 mb-4">
-          {children}
+        <h2 id={id} className="group text-xl font-semibold mt-12 mb-4">
+          <a href={`#${id}`} className="no-underline hover:no-underline">
+            {children}
+            <span className="ml-2 text-muted-foreground/0 group-hover:text-muted-foreground transition-colors select-none">
+              #
+            </span>
+          </a>
         </h2>
       );
     },
     h3: ({ children }: { children?: React.ReactNode }) => {
       const id = slugify(extractText(children));
       return (
-        <h3 id={id} className="text-lg font-medium mt-8 mb-3">
-          {children}
+        <h3 id={id} className="group text-lg font-medium mt-8 mb-3">
+          <a href={`#${id}`} className="no-underline hover:no-underline">
+            {children}
+            <span className="ml-2 text-muted-foreground/0 group-hover:text-muted-foreground transition-colors select-none">
+              #
+            </span>
+          </a>
         </h3>
       );
     },

+ 15 - 0
examples/chat/.env.example

@@ -0,0 +1,15 @@
+# Vercel AI Gateway
+# Automatically authenticated when deployed on Vercel
+# For local development, get your key from https://vercel.com/ai-gateway
+AI_GATEWAY_API_KEY=
+
+# AI Model Configuration
+# Default: anthropic/claude-haiku-4.5
+AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
+
+# Rate Limiting (Upstash Redis)
+# Optional - rate limiting is disabled when these are not set
+KV_REST_API_URL=
+KV_REST_API_TOKEN=
+RATE_LIMIT_PER_MINUTE=10
+RATE_LIMIT_PER_DAY=100

+ 26 - 0
examples/chat/app/api/generate/route.ts

@@ -1,4 +1,5 @@
 import { agent } from "@/lib/agent";
+import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
 import {
   convertToModelMessages,
   createUIMessageStream,
@@ -6,10 +7,35 @@ import {
   type UIMessage,
 } from "ai";
 import { pipeJsonRender } from "@json-render/core";
+import { headers } from "next/headers";
 
 export const maxDuration = 60;
 
 export async function POST(req: Request) {
+  const headersList = await headers();
+  const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
+
+  const [minuteResult, dailyResult] = await Promise.all([
+    minuteRateLimit.limit(ip),
+    dailyRateLimit.limit(ip),
+  ]);
+
+  if (!minuteResult.success || !dailyResult.success) {
+    const isMinuteLimit = !minuteResult.success;
+    return new Response(
+      JSON.stringify({
+        error: "Rate limit exceeded",
+        message: isMinuteLimit
+          ? "Too many requests. Please wait a moment before trying again."
+          : "Daily limit reached. Please try again tomorrow.",
+      }),
+      {
+        status: 429,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
   const body = await req.json();
   const uiMessages: UIMessage[] = body.messages;
 

BIN
examples/chat/app/opengraph-image.png


+ 57 - 0
examples/chat/lib/rate-limit.ts

@@ -0,0 +1,57 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+
+// Lazy initialization to avoid errors when Redis env vars are not configured
+let _minuteRateLimit: Ratelimit | null = null;
+let _dailyRateLimit: Ratelimit | null = null;
+
+function getRedis(): Redis | null {
+  const url = process.env.KV_REST_API_URL;
+  const token = process.env.KV_REST_API_TOKEN;
+
+  if (!url || !token) {
+    return null;
+  }
+
+  return new Redis({ url, token });
+}
+
+// No-op rate limiter for when Redis is not configured
+const noopRateLimiter = {
+  limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
+};
+
+const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
+const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
+
+// Requests per minute (sliding window)
+export const minuteRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_minuteRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _minuteRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
+        prefix: "ratelimit:chat:minute",
+      });
+    }
+    return _minuteRateLimit.limit(identifier);
+  },
+};
+
+// Requests per day (fixed window)
+export const dailyRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_dailyRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _dailyRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
+        prefix: "ratelimit:chat:daily",
+      });
+    }
+    return _dailyRateLimit.limit(identifier);
+  },
+};

+ 2 - 0
examples/chat/package.json

@@ -34,6 +34,8 @@
     "streamdown": "^2.2.0",
     "tailwind-merge": "^3.4.0",
     "three": "^0.182.0",
+    "@upstash/ratelimit": "^2.0.8",
+    "@upstash/redis": "^1.37.0",
     "zod": "4.3.6"
   },
   "devDependencies": {

+ 7 - 0
examples/dashboard/.env.example

@@ -4,3 +4,10 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/json_render_dashboard
 # AI (optional - for UI generation)
 AI_GATEWAY_API_KEY=
 AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
+
+# Rate Limiting (Upstash Redis)
+# Optional - rate limiting is disabled when these are not set
+KV_REST_API_URL=
+KV_REST_API_TOKEN=
+RATE_LIMIT_PER_MINUTE=10
+RATE_LIMIT_PER_DAY=100

+ 26 - 0
examples/dashboard/app/api/generate/route.ts

@@ -1,6 +1,8 @@
 import { streamText } from "ai";
 import { buildUserPrompt } from "@json-render/core";
 import { dashboardCatalog } from "@/lib/render/catalog";
+import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
+import { headers } from "next/headers";
 
 export const maxDuration = 30;
 
@@ -9,6 +11,30 @@ const SYSTEM_PROMPT = dashboardCatalog.prompt();
 const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
 
 export async function POST(req: Request) {
+  const headersList = await headers();
+  const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
+
+  const [minuteResult, dailyResult] = await Promise.all([
+    minuteRateLimit.limit(ip),
+    dailyRateLimit.limit(ip),
+  ]);
+
+  if (!minuteResult.success || !dailyResult.success) {
+    const isMinuteLimit = !minuteResult.success;
+    return new Response(
+      JSON.stringify({
+        error: "Rate limit exceeded",
+        message: isMinuteLimit
+          ? "Too many requests. Please wait a moment before trying again."
+          : "Daily limit reached. Please try again tomorrow.",
+      }),
+      {
+        status: 429,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
   const { prompt, context } = await req.json();
 
   const userPrompt = buildUserPrompt({

BIN
examples/dashboard/app/opengraph-image.png


+ 57 - 0
examples/dashboard/lib/rate-limit.ts

@@ -0,0 +1,57 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+
+// Lazy initialization to avoid errors when Redis env vars are not configured
+let _minuteRateLimit: Ratelimit | null = null;
+let _dailyRateLimit: Ratelimit | null = null;
+
+function getRedis(): Redis | null {
+  const url = process.env.KV_REST_API_URL;
+  const token = process.env.KV_REST_API_TOKEN;
+
+  if (!url || !token) {
+    return null;
+  }
+
+  return new Redis({ url, token });
+}
+
+// No-op rate limiter for when Redis is not configured
+const noopRateLimiter = {
+  limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
+};
+
+const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
+const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
+
+// Requests per minute (sliding window)
+export const minuteRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_minuteRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _minuteRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
+        prefix: "ratelimit:dashboard:minute",
+      });
+    }
+    return _minuteRateLimit.limit(identifier);
+  },
+};
+
+// Requests per day (fixed window)
+export const dailyRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_dailyRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _dailyRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
+        prefix: "ratelimit:dashboard:daily",
+      });
+    }
+    return _dailyRateLimit.limit(identifier);
+  },
+};

+ 2 - 0
examples/dashboard/package.json

@@ -40,6 +40,8 @@
     "sonner": "^2.0.7",
     "tailwind-merge": "^3.4.0",
     "vaul": "^1.1.2",
+    "@upstash/ratelimit": "^2.0.8",
+    "@upstash/redis": "^1.37.0",
     "zod": "^4.3.6"
   },
   "devDependencies": {

+ 15 - 0
examples/image/.env.example

@@ -0,0 +1,15 @@
+# Vercel AI Gateway
+# Automatically authenticated when deployed on Vercel
+# For local development, get your key from https://vercel.com/ai-gateway
+AI_GATEWAY_API_KEY=
+
+# AI Model Configuration
+# Default: anthropic/claude-haiku-4.5
+AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
+
+# Rate Limiting (Upstash Redis)
+# Optional - rate limiting is disabled when these are not set
+KV_REST_API_URL=
+KV_REST_API_TOKEN=
+RATE_LIMIT_PER_MINUTE=10
+RATE_LIMIT_PER_DAY=100

+ 26 - 0
examples/image/app/api/generate/route.ts

@@ -1,6 +1,8 @@
 import { streamText } from "ai";
 import { buildUserPrompt, type Spec } from "@json-render/core";
 import { imageCatalog } from "@/lib/catalog";
+import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
+import { headers } from "next/headers";
 
 export const maxDuration = 60;
 
@@ -9,6 +11,30 @@ const SYSTEM_PROMPT = imageCatalog.prompt();
 const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
 
 export async function POST(req: Request) {
+  const headersList = await headers();
+  const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
+
+  const [minuteResult, dailyResult] = await Promise.all([
+    minuteRateLimit.limit(ip),
+    dailyRateLimit.limit(ip),
+  ]);
+
+  if (!minuteResult.success || !dailyResult.success) {
+    const isMinuteLimit = !minuteResult.success;
+    return new Response(
+      JSON.stringify({
+        error: "Rate limit exceeded",
+        message: isMinuteLimit
+          ? "Too many requests. Please wait a moment before trying again."
+          : "Daily limit reached. Please try again tomorrow.",
+      }),
+      {
+        status: 429,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
   const { prompt, startingSpec } = (await req.json()) as {
     prompt: string;
     startingSpec?: Spec | null;

BIN
examples/image/app/opengraph-image.png


+ 57 - 0
examples/image/lib/rate-limit.ts

@@ -0,0 +1,57 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+
+// Lazy initialization to avoid errors when Redis env vars are not configured
+let _minuteRateLimit: Ratelimit | null = null;
+let _dailyRateLimit: Ratelimit | null = null;
+
+function getRedis(): Redis | null {
+  const url = process.env.KV_REST_API_URL;
+  const token = process.env.KV_REST_API_TOKEN;
+
+  if (!url || !token) {
+    return null;
+  }
+
+  return new Redis({ url, token });
+}
+
+// No-op rate limiter for when Redis is not configured
+const noopRateLimiter = {
+  limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
+};
+
+const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
+const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
+
+// Requests per minute (sliding window)
+export const minuteRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_minuteRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _minuteRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
+        prefix: "ratelimit:image:minute",
+      });
+    }
+    return _minuteRateLimit.limit(identifier);
+  },
+};
+
+// Requests per day (fixed window)
+export const dailyRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_dailyRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _dailyRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
+        prefix: "ratelimit:image:daily",
+      });
+    }
+    return _dailyRateLimit.limit(identifier);
+  },
+};

+ 2 - 0
examples/image/package.json

@@ -28,6 +28,8 @@
     "react-resizable-panels": "^4.4.1",
     "tailwind-merge": "^3.5.0",
     "tailwindcss": "^4.1.18",
+    "@upstash/ratelimit": "^2.0.8",
+    "@upstash/redis": "^1.37.0",
     "zod": "4.3.6"
   },
   "devDependencies": {

BIN
examples/no-ai/app/opengraph-image.png


+ 7 - 0
examples/react-email/.env.example

@@ -7,3 +7,10 @@ AI_GATEWAY_API_KEY=
 # Override the default model used for email generation
 # Default: anthropic/claude-haiku-4.5
 AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
+
+# Rate Limiting (Upstash Redis)
+# Optional - rate limiting is disabled when these are not set
+KV_REST_API_URL=
+KV_REST_API_TOKEN=
+RATE_LIMIT_PER_MINUTE=10
+RATE_LIMIT_PER_DAY=100

+ 26 - 0
examples/react-email/app/api/generate/route.ts

@@ -1,6 +1,8 @@
 import { streamText } from "ai";
 import { buildUserPrompt, type Spec } from "@json-render/core";
 import { emailCatalog } from "@/lib/catalog";
+import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
+import { headers } from "next/headers";
 
 export const maxDuration = 60;
 
@@ -9,6 +11,30 @@ const SYSTEM_PROMPT = emailCatalog.prompt();
 const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
 
 export async function POST(req: Request) {
+  const headersList = await headers();
+  const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
+
+  const [minuteResult, dailyResult] = await Promise.all([
+    minuteRateLimit.limit(ip),
+    dailyRateLimit.limit(ip),
+  ]);
+
+  if (!minuteResult.success || !dailyResult.success) {
+    const isMinuteLimit = !minuteResult.success;
+    return new Response(
+      JSON.stringify({
+        error: "Rate limit exceeded",
+        message: isMinuteLimit
+          ? "Too many requests. Please wait a moment before trying again."
+          : "Daily limit reached. Please try again tomorrow.",
+      }),
+      {
+        status: 429,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
   const { prompt, startingSpec } = (await req.json()) as {
     prompt: string;
     startingSpec?: Spec | null;

BIN
examples/react-email/app/opengraph-image.png


+ 57 - 0
examples/react-email/lib/rate-limit.ts

@@ -0,0 +1,57 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+
+// Lazy initialization to avoid errors when Redis env vars are not configured
+let _minuteRateLimit: Ratelimit | null = null;
+let _dailyRateLimit: Ratelimit | null = null;
+
+function getRedis(): Redis | null {
+  const url = process.env.KV_REST_API_URL;
+  const token = process.env.KV_REST_API_TOKEN;
+
+  if (!url || !token) {
+    return null;
+  }
+
+  return new Redis({ url, token });
+}
+
+// No-op rate limiter for when Redis is not configured
+const noopRateLimiter = {
+  limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
+};
+
+const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
+const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
+
+// Requests per minute (sliding window)
+export const minuteRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_minuteRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _minuteRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
+        prefix: "ratelimit:react-email:minute",
+      });
+    }
+    return _minuteRateLimit.limit(identifier);
+  },
+};
+
+// Requests per day (fixed window)
+export const dailyRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_dailyRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _dailyRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
+        prefix: "ratelimit:react-email:daily",
+      });
+    }
+    return _dailyRateLimit.limit(identifier);
+  },
+};

+ 2 - 0
examples/react-email/package.json

@@ -25,6 +25,8 @@
     "react-resizable-panels": "^4.4.1",
     "tailwind-merge": "^3.5.0",
     "tailwindcss": "^4.1.18",
+    "@upstash/ratelimit": "^2.0.8",
+    "@upstash/redis": "^1.37.0",
     "zod": "4.3.6"
   },
   "devDependencies": {

+ 7 - 0
examples/react-pdf/.env.example

@@ -3,3 +3,10 @@ AI_GATEWAY_API_KEY=
 
 # Optional: override the default model (default: anthropic/claude-haiku-4.5)
 # AI_GATEWAY_MODEL=openai/gpt-4o-mini
+
+# Rate Limiting (Upstash Redis)
+# Optional - rate limiting is disabled when these are not set
+KV_REST_API_URL=
+KV_REST_API_TOKEN=
+RATE_LIMIT_PER_MINUTE=10
+RATE_LIMIT_PER_DAY=100

+ 26 - 0
examples/react-pdf/app/api/generate/route.ts

@@ -2,6 +2,8 @@ import { streamText } from "ai";
 import { gateway } from "@ai-sdk/gateway";
 import { buildUserPrompt, type Spec } from "@json-render/core";
 import { pdfCatalog } from "@/lib/catalog";
+import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
+import { headers } from "next/headers";
 
 export const maxDuration = 60;
 
@@ -10,6 +12,30 @@ const SYSTEM_PROMPT = pdfCatalog.prompt();
 const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
 
 export async function POST(req: Request) {
+  const headersList = await headers();
+  const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
+
+  const [minuteResult, dailyResult] = await Promise.all([
+    minuteRateLimit.limit(ip),
+    dailyRateLimit.limit(ip),
+  ]);
+
+  if (!minuteResult.success || !dailyResult.success) {
+    const isMinuteLimit = !minuteResult.success;
+    return new Response(
+      JSON.stringify({
+        error: "Rate limit exceeded",
+        message: isMinuteLimit
+          ? "Too many requests. Please wait a moment before trying again."
+          : "Daily limit reached. Please try again tomorrow.",
+      }),
+      {
+        status: 429,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
   const { prompt, startingSpec } = (await req.json()) as {
     prompt: string;
     startingSpec?: Spec | null;

BIN
examples/react-pdf/app/opengraph-image.png


+ 57 - 0
examples/react-pdf/lib/rate-limit.ts

@@ -0,0 +1,57 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+
+// Lazy initialization to avoid errors when Redis env vars are not configured
+let _minuteRateLimit: Ratelimit | null = null;
+let _dailyRateLimit: Ratelimit | null = null;
+
+function getRedis(): Redis | null {
+  const url = process.env.KV_REST_API_URL;
+  const token = process.env.KV_REST_API_TOKEN;
+
+  if (!url || !token) {
+    return null;
+  }
+
+  return new Redis({ url, token });
+}
+
+// No-op rate limiter for when Redis is not configured
+const noopRateLimiter = {
+  limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
+};
+
+const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
+const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
+
+// Requests per minute (sliding window)
+export const minuteRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_minuteRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _minuteRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
+        prefix: "ratelimit:react-pdf:minute",
+      });
+    }
+    return _minuteRateLimit.limit(identifier);
+  },
+};
+
+// Requests per day (fixed window)
+export const dailyRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_dailyRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _dailyRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
+        prefix: "ratelimit:react-pdf:daily",
+      });
+    }
+    return _dailyRateLimit.limit(identifier);
+  },
+};

+ 2 - 0
examples/react-pdf/package.json

@@ -26,6 +26,8 @@
     "react-resizable-panels": "^4.4.1",
     "tailwind-merge": "^3.5.0",
     "tailwindcss": "^4.1.18",
+    "@upstash/ratelimit": "^2.0.8",
+    "@upstash/redis": "^1.37.0",
     "zod": "4.3.6"
   },
   "devDependencies": {

BIN
examples/react-three-fiber/app/opengraph-image.png


+ 254 - 2
examples/react-three-fiber/app/page.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useState } from "react";
+import { useState, useSyncExternalStore, useCallback } from "react";
 import { defineCatalog } from "@json-render/core";
 import type { ComputedFunction } from "@json-render/core";
 import { schema, defineRegistry } from "@json-render/react";
@@ -109,9 +109,27 @@ function highlightJson(json: string): string {
   );
 }
 
+const MOBILE_BREAKPOINT = 768;
+
+function subscribeToResize(cb: () => void) {
+  window.addEventListener("resize", cb);
+  return () => window.removeEventListener("resize", cb);
+}
+
+function getIsMobile() {
+  return typeof window !== "undefined"
+    ? window.innerWidth < MOBILE_BREAKPOINT
+    : false;
+}
+
+function useIsMobile() {
+  return useSyncExternalStore(subscribeToResize, getIsMobile, () => false);
+}
+
 const LIST_WIDTH = 220;
 const JSON_WIDTH = 380;
 const HEADER_HEIGHT = 40;
+const MOBILE_HEADER_HEIGHT = 48;
 
 const headerStyle: React.CSSProperties = {
   height: HEADER_HEIGHT,
@@ -132,7 +150,236 @@ const headerStyle: React.CSSProperties = {
   boxSizing: "border-box",
 };
 
-export default function Page() {
+function MobileLayout() {
+  const [selectedIndex, setSelectedIndex] = useState(0);
+  const [showJson, setShowJson] = useState(false);
+  const [showScenes, setShowScenes] = useState(false);
+  const selected = scenes[selectedIndex]!;
+
+  const closePanels = useCallback(() => {
+    setShowJson(false);
+    setShowScenes(false);
+  }, []);
+
+  return (
+    <div
+      style={{
+        height: "100dvh",
+        display: "flex",
+        flexDirection: "column",
+        background: "#0a0a0a",
+        overflow: "hidden",
+      }}
+    >
+      <div
+        style={{
+          height: MOBILE_HEADER_HEIGHT,
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "space-between",
+          padding: "0 12px",
+          borderBottom: "1px solid #1e1e1e",
+          background: "#0f0f0f",
+          flexShrink: 0,
+          gap: 8,
+        }}
+      >
+        <button
+          onClick={() => {
+            setShowScenes((v) => !v);
+            setShowJson(false);
+          }}
+          style={{
+            background: showScenes ? "rgba(255,255,255,0.1)" : "transparent",
+            border: "1px solid #333",
+            borderRadius: 6,
+            color: "#ccc",
+            fontSize: 12,
+            fontWeight: 500,
+            padding: "6px 10px",
+            cursor: "pointer",
+            fontFamily: "inherit",
+            whiteSpace: "nowrap",
+            overflow: "hidden",
+            textOverflow: "ellipsis",
+            maxWidth: "50%",
+          }}
+        >
+          {selected.name}
+        </button>
+
+        <span
+          style={{
+            flex: 1,
+            fontSize: 10,
+            color: "#555",
+            textAlign: "center",
+            overflow: "hidden",
+            textOverflow: "ellipsis",
+            whiteSpace: "nowrap",
+            fontFamily: "ui-monospace, monospace",
+            textTransform: "uppercase",
+            letterSpacing: "0.06em",
+          }}
+        >
+          {selected.description}
+        </span>
+
+        <button
+          onClick={() => {
+            setShowJson((v) => !v);
+            setShowScenes(false);
+          }}
+          style={{
+            background: showJson ? "rgba(255,255,255,0.1)" : "transparent",
+            border: "1px solid #333",
+            borderRadius: 6,
+            color: "#ccc",
+            fontSize: 11,
+            fontWeight: 500,
+            padding: "6px 10px",
+            cursor: "pointer",
+            fontFamily: "ui-monospace, monospace",
+            whiteSpace: "nowrap",
+            letterSpacing: "0.04em",
+          }}
+        >
+          JSON
+        </button>
+      </div>
+
+      <div style={{ flex: 1, position: "relative", minHeight: 0 }}>
+        <ThreeCanvas
+          key={selectedIndex}
+          spec={selected.spec}
+          registry={registry}
+          functions={computedFunctions}
+          shadows
+          camera={{ position: [0, 0, 5], fov: 50 }}
+          style={{ width: "100%", height: "100%" }}
+        />
+
+        {showScenes && (
+          <>
+            <div
+              onClick={closePanels}
+              style={{
+                position: "absolute",
+                inset: 0,
+                background: "rgba(0,0,0,0.5)",
+                zIndex: 10,
+              }}
+            />
+            <div
+              style={{
+                position: "absolute",
+                top: 0,
+                left: 0,
+                bottom: 0,
+                width: "75%",
+                maxWidth: 280,
+                background: "#0f0f0f",
+                borderRight: "1px solid #1e1e1e",
+                zIndex: 11,
+                display: "flex",
+                flexDirection: "column",
+                overflowY: "auto",
+                WebkitOverflowScrolling: "touch",
+              }}
+            >
+              <div style={{ ...headerStyle, height: 36 }}>Scenes</div>
+              <div style={{ padding: "6px 0" }}>
+                {scenes.map((scene, i) => (
+                  <button
+                    key={scene.name}
+                    onClick={() => {
+                      setSelectedIndex(i);
+                      setShowScenes(false);
+                    }}
+                    style={{
+                      display: "block",
+                      width: "100%",
+                      padding: "10px 16px",
+                      fontSize: 14,
+                      border: "none",
+                      textAlign: "left",
+                      background:
+                        i === selectedIndex
+                          ? "rgba(255,255,255,0.08)"
+                          : "transparent",
+                      color: i === selectedIndex ? "#fff" : "#888",
+                      fontWeight: i === selectedIndex ? 500 : 400,
+                      cursor: "pointer",
+                      borderLeft:
+                        i === selectedIndex
+                          ? "2px solid #fff"
+                          : "2px solid transparent",
+                      fontFamily: "inherit",
+                    }}
+                  >
+                    {scene.name}
+                  </button>
+                ))}
+              </div>
+            </div>
+          </>
+        )}
+
+        {showJson && (
+          <>
+            <div
+              onClick={closePanels}
+              style={{
+                position: "absolute",
+                inset: 0,
+                background: "rgba(0,0,0,0.5)",
+                zIndex: 10,
+              }}
+            />
+            <div
+              style={{
+                position: "absolute",
+                top: 0,
+                right: 0,
+                bottom: 0,
+                width: "85%",
+                maxWidth: 400,
+                background: "#0d0d0d",
+                borderLeft: "1px solid #1e1e1e",
+                zIndex: 11,
+                display: "flex",
+                flexDirection: "column",
+              }}
+            >
+              <div style={{ ...headerStyle, height: 36 }}>Spec JSON</div>
+              <pre
+                style={{
+                  flex: 1,
+                  margin: 0,
+                  padding: 14,
+                  overflowY: "auto",
+                  overflowX: "auto",
+                  WebkitOverflowScrolling: "touch",
+                  fontSize: 11,
+                  lineHeight: 1.6,
+                  fontFamily:
+                    "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
+                  color: "#EDEDED",
+                  tabSize: 2,
+                }}
+                dangerouslySetInnerHTML={{
+                  __html: highlightJson(JSON.stringify(selected.spec, null, 2)),
+                }}
+              />
+            </div>
+          </>
+        )}
+      </div>
+    </div>
+  );
+}
+
+function DesktopLayout() {
   const [selectedIndex, setSelectedIndex] = useState(0);
   const selected = scenes[selectedIndex]!;
 
@@ -236,3 +483,8 @@ export default function Page() {
     </div>
   );
 }
+
+export default function Page() {
+  const isMobile = useIsMobile();
+  return isMobile ? <MobileLayout /> : <DesktopLayout />;
+}

+ 7 - 0
examples/remotion/.env.example

@@ -7,3 +7,10 @@ AI_GATEWAY_API_KEY=
 # Override the default model used for video generation
 # Default: anthropic/claude-haiku-4.5
 AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
+
+# Rate Limiting (Upstash Redis)
+# Optional - rate limiting is disabled when these are not set
+KV_REST_API_URL=
+KV_REST_API_TOKEN=
+RATE_LIMIT_PER_MINUTE=10
+RATE_LIMIT_PER_DAY=100

+ 26 - 0
examples/remotion/app/api/generate/route.ts

@@ -1,5 +1,7 @@
 import { streamText } from "ai";
 import { getVideoPrompt } from "@/lib/catalog";
+import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
+import { headers } from "next/headers";
 
 export const maxDuration = 30;
 
@@ -10,6 +12,30 @@ const MAX_PROMPT_LENGTH = 500;
 const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
 
 export async function POST(req: Request) {
+  const headersList = await headers();
+  const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
+
+  const [minuteResult, dailyResult] = await Promise.all([
+    minuteRateLimit.limit(ip),
+    dailyRateLimit.limit(ip),
+  ]);
+
+  if (!minuteResult.success || !dailyResult.success) {
+    const isMinuteLimit = !minuteResult.success;
+    return new Response(
+      JSON.stringify({
+        error: "Rate limit exceeded",
+        message: isMinuteLimit
+          ? "Too many requests. Please wait a moment before trying again."
+          : "Daily limit reached. Please try again tomorrow.",
+      }),
+      {
+        status: 429,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
   const { prompt } = await req.json();
 
   const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);

BIN
examples/remotion/app/opengraph-image.png


+ 57 - 0
examples/remotion/lib/rate-limit.ts

@@ -0,0 +1,57 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+
+// Lazy initialization to avoid errors when Redis env vars are not configured
+let _minuteRateLimit: Ratelimit | null = null;
+let _dailyRateLimit: Ratelimit | null = null;
+
+function getRedis(): Redis | null {
+  const url = process.env.KV_REST_API_URL;
+  const token = process.env.KV_REST_API_TOKEN;
+
+  if (!url || !token) {
+    return null;
+  }
+
+  return new Redis({ url, token });
+}
+
+// No-op rate limiter for when Redis is not configured
+const noopRateLimiter = {
+  limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
+};
+
+const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
+const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
+
+// Requests per minute (sliding window)
+export const minuteRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_minuteRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _minuteRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
+        prefix: "ratelimit:remotion:minute",
+      });
+    }
+    return _minuteRateLimit.limit(identifier);
+  },
+};
+
+// Requests per day (fixed window)
+export const dailyRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_dailyRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _dailyRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
+        prefix: "ratelimit:remotion:daily",
+      });
+    }
+    return _dailyRateLimit.limit(identifier);
+  },
+};

+ 2 - 0
examples/remotion/package.json

@@ -27,6 +27,8 @@
     "remotion": "4.0.418",
     "shiki": "^3.21.0",
     "tailwind-merge": "^3.4.0",
+    "@upstash/ratelimit": "^2.0.8",
+    "@upstash/redis": "^1.37.0",
     "zod": "^4.3.6"
   },
   "devDependencies": {

+ 7 - 0
examples/solid/index.html

@@ -4,6 +4,13 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>json-render solid example</title>
+    <meta property="og:title" content="Solid Example | json-render" />
+    <meta property="og:image" content="/og-image.png" />
+    <meta property="og:image:width" content="1200" />
+    <meta property="og:image:height" content="630" />
+    <meta name="twitter:card" content="summary_large_image" />
+    <meta name="twitter:title" content="Solid Example | json-render" />
+    <meta name="twitter:image" content="/og-image.png" />
   </head>
   <body>
     <div id="app"></div>

BIN
examples/solid/public/og-image.png


+ 15 - 0
examples/svelte-chat/.env.example

@@ -0,0 +1,15 @@
+# Vercel AI Gateway
+# Automatically authenticated when deployed on Vercel
+# For local development, get your key from https://vercel.com/ai-gateway
+AI_GATEWAY_API_KEY=
+
+# AI Model Configuration
+# Default: anthropic/claude-haiku-4.5
+AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
+
+# Rate Limiting (Upstash Redis)
+# Optional - rate limiting is disabled when these are not set
+KV_REST_API_URL=
+KV_REST_API_TOKEN=
+RATE_LIMIT_PER_MINUTE=10
+RATE_LIMIT_PER_DAY=100

+ 2 - 0
examples/svelte-chat/package.json

@@ -34,6 +34,8 @@
     "clsx": "^2.1.1",
     "lucide-svelte": "^0.500.0",
     "tailwind-merge": "^3.2.0",
+    "@upstash/ratelimit": "^2.0.8",
+    "@upstash/redis": "^1.37.0",
     "zod": "4.3.6"
   }
 }

+ 7 - 0
examples/svelte-chat/src/app.html

@@ -3,6 +3,13 @@
 	<head>
 		<meta charset="utf-8" />
 		<meta name="viewport" content="width=device-width, initial-scale=1" />
+		<meta property="og:title" content="Svelte Chat Example | json-render" />
+		<meta property="og:image" content="/og-image.png" />
+		<meta property="og:image:width" content="1200" />
+		<meta property="og:image:height" content="630" />
+		<meta name="twitter:card" content="summary_large_image" />
+		<meta name="twitter:title" content="Svelte Chat Example | json-render" />
+		<meta name="twitter:image" content="/og-image.png" />
 		%sveltekit.head%
 	</head>
 	<body data-sveltekit-preload-data="hover">

+ 57 - 0
examples/svelte-chat/src/lib/rate-limit.ts

@@ -0,0 +1,57 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+
+// Lazy initialization to avoid errors when Redis env vars are not configured
+let _minuteRateLimit: Ratelimit | null = null;
+let _dailyRateLimit: Ratelimit | null = null;
+
+function getRedis(): Redis | null {
+  const url = process.env.KV_REST_API_URL;
+  const token = process.env.KV_REST_API_TOKEN;
+
+  if (!url || !token) {
+    return null;
+  }
+
+  return new Redis({ url, token });
+}
+
+// No-op rate limiter for when Redis is not configured
+const noopRateLimiter = {
+  limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
+};
+
+const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
+const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
+
+// Requests per minute (sliding window)
+export const minuteRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_minuteRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _minuteRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
+        prefix: "ratelimit:svelte-chat:minute",
+      });
+    }
+    return _minuteRateLimit.limit(identifier);
+  },
+};
+
+// Requests per day (fixed window)
+export const dailyRateLimit = {
+  limit: async (identifier: string) => {
+    if (!_dailyRateLimit) {
+      const redis = getRedis();
+      if (!redis) return noopRateLimiter.limit();
+      _dailyRateLimit = new Ratelimit({
+        redis,
+        limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
+        prefix: "ratelimit:svelte-chat:daily",
+      });
+    }
+    return _dailyRateLimit.limit(identifier);
+  },
+};

+ 25 - 0
examples/svelte-chat/src/routes/api/generate/+server.ts

@@ -1,4 +1,5 @@
 import { agent } from "$lib/agent";
+import { minuteRateLimit, dailyRateLimit } from "$lib/rate-limit";
 import {
   convertToModelMessages,
   createUIMessageStream,
@@ -9,6 +10,30 @@ import { pipeJsonRender } from "@json-render/core";
 import type { RequestHandler } from "./$types";
 
 export const POST: RequestHandler = async ({ request }) => {
+  const ip =
+    request.headers.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
+
+  const [minuteResult, dailyResult] = await Promise.all([
+    minuteRateLimit.limit(ip),
+    dailyRateLimit.limit(ip),
+  ]);
+
+  if (!minuteResult.success || !dailyResult.success) {
+    const isMinuteLimit = !minuteResult.success;
+    return new Response(
+      JSON.stringify({
+        error: "Rate limit exceeded",
+        message: isMinuteLimit
+          ? "Too many requests. Please wait a moment before trying again."
+          : "Daily limit reached. Please try again tomorrow.",
+      }),
+      {
+        status: 429,
+        headers: { "Content-Type": "application/json" },
+      },
+    );
+  }
+
   const body = await request.json();
   const uiMessages: UIMessage[] = body.messages;
 

BIN
examples/svelte-chat/static/og-image.png


+ 7 - 0
examples/svelte/index.html

@@ -4,6 +4,13 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>json-render svelte example</title>
+    <meta property="og:title" content="Svelte Example | json-render" />
+    <meta property="og:image" content="/og-image.png" />
+    <meta property="og:image:width" content="1200" />
+    <meta property="og:image:height" content="630" />
+    <meta name="twitter:card" content="summary_large_image" />
+    <meta name="twitter:title" content="Svelte Example | json-render" />
+    <meta name="twitter:image" content="/og-image.png" />
   </head>
   <body>
     <div id="app"></div>

BIN
examples/svelte/public/og-image.png


+ 7 - 0
examples/vite-renderers/index.html

@@ -4,6 +4,13 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>@json-render multi-renderer demo</title>
+    <meta property="og:title" content="Multi-Renderer Example | json-render" />
+    <meta property="og:image" content="/og-image.png" />
+    <meta property="og:image:width" content="1200" />
+    <meta property="og:image:height" content="630" />
+    <meta name="twitter:card" content="summary_large_image" />
+    <meta name="twitter:title" content="Multi-Renderer Example | json-render" />
+    <meta name="twitter:image" content="/og-image.png" />
     <style>
       * {
         box-sizing: border-box;

BIN
examples/vite-renderers/public/og-image.png


+ 7 - 0
examples/vue/index.html

@@ -4,6 +4,13 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>json-render vue example</title>
+    <meta property="og:title" content="Vue Example | json-render" />
+    <meta property="og:image" content="/og-image.png" />
+    <meta property="og:image:width" content="1200" />
+    <meta property="og:image:height" content="630" />
+    <meta name="twitter:card" content="summary_large_image" />
+    <meta name="twitter:title" content="Vue Example | json-render" />
+    <meta name="twitter:image" content="/og-image.png" />
     <style>
       * {
         box-sizing: border-box;

BIN
examples/vue/public/og-image.png


+ 5 - 2
package.json

@@ -25,14 +25,16 @@
     "prepare": "husky",
     "changeset": "changeset",
     "ci:version": "changeset version && pnpm install --no-frozen-lockfile",
-    "ci:publish": "pnpm run build && changeset publish"
+    "ci:publish": "pnpm run build && changeset publish",
+    "generate:og": "npx tsx scripts/generate-og-images.mts"
   },
   "devDependencies": {
     "@changesets/cli": "2.29.8",
+    "@resvg/resvg-js": "2.6.2",
+    "@solidjs/testing-library": "^0.8.10",
     "@sveltejs/vite-plugin-svelte": "^6.2.4",
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.1",
-    "@solidjs/testing-library": "^0.8.10",
     "@testing-library/svelte": "^5.2.0",
     "@types/react": "^19.2.3",
     "husky": "^9.1.7",
@@ -41,6 +43,7 @@
     "prettier": "^3.7.4",
     "react": "^19.2.4",
     "react-dom": "^19.2.4",
+    "satori": "0.25.0",
     "solid-js": "^1.9.11",
     "svelte": "^5.0.0",
     "turbo": "^2.7.4",

+ 84 - 6
pnpm-lock.yaml

@@ -11,6 +11,9 @@ importers:
       '@changesets/cli':
         specifier: 2.29.8
         version: 2.29.8(@types/node@22.19.6)
+      '@resvg/resvg-js':
+        specifier: 2.6.2
+        version: 2.6.2
       '@solidjs/testing-library':
         specifier: ^0.8.10
         version: 0.8.10(solid-js@1.9.11)
@@ -47,6 +50,9 @@ importers:
       react-dom:
         specifier: ^19.2.4
         version: 19.2.4(react@19.2.4)
+      satori:
+        specifier: 0.25.0
+        version: 0.25.0
       solid-js:
         specifier: ^1.9.11
         version: 1.9.11
@@ -255,6 +261,12 @@ importers:
       '@streamdown/code':
         specifier: ^1.0.2
         version: 1.0.2(react@19.2.4)
+      '@upstash/ratelimit':
+        specifier: ^2.0.8
+        version: 2.0.8(@upstash/redis@1.37.0)
+      '@upstash/redis':
+        specifier: ^1.37.0
+        version: 1.37.0
       ai:
         specifier: ^6.0.33
         version: 6.0.103(zod@4.3.6)
@@ -358,6 +370,12 @@ importers:
       '@json-render/react':
         specifier: workspace:*
         version: link:../../packages/react
+      '@upstash/ratelimit':
+        specifier: ^2.0.8
+        version: 2.0.8(@upstash/redis@1.37.0)
+      '@upstash/redis':
+        specifier: ^1.37.0
+        version: 1.37.0
       ai:
         specifier: ^6.0.33
         version: 6.0.103(zod@4.3.6)
@@ -369,7 +387,7 @@ importers:
         version: 2.1.1
       drizzle-orm:
         specifier: ^0.45.1
-        version: 0.45.1(@opentelemetry/api@1.9.0)(@upstash/redis@1.36.1)(postgres@3.4.8)(sql.js@1.13.0)
+        version: 0.45.1(@opentelemetry/api@1.9.0)(@upstash/redis@1.37.0)(postgres@3.4.8)(sql.js@1.13.0)
       lucide-react:
         specifier: ^0.562.0
         version: 0.562.0(react@19.2.3)
@@ -464,6 +482,12 @@ importers:
       '@tailwindcss/postcss':
         specifier: ^4.1.18
         version: 4.1.18
+      '@upstash/ratelimit':
+        specifier: ^2.0.8
+        version: 2.0.8(@upstash/redis@1.37.0)
+      '@upstash/redis':
+        specifier: ^1.37.0
+        version: 1.37.0
       ai:
         specifier: 6.0.103
         version: 6.0.103(zod@4.3.6)
@@ -680,6 +704,12 @@ importers:
       '@tailwindcss/postcss':
         specifier: ^4.1.18
         version: 4.1.18
+      '@upstash/ratelimit':
+        specifier: ^2.0.8
+        version: 2.0.8(@upstash/redis@1.37.0)
+      '@upstash/redis':
+        specifier: ^1.37.0
+        version: 1.37.0
       ai:
         specifier: 6.0.94
         version: 6.0.94(zod@4.3.6)
@@ -814,6 +844,12 @@ importers:
       '@tailwindcss/postcss':
         specifier: ^4.1.18
         version: 4.1.18
+      '@upstash/ratelimit':
+        specifier: ^2.0.8
+        version: 2.0.8(@upstash/redis@1.37.0)
+      '@upstash/redis':
+        specifier: ^1.37.0
+        version: 1.37.0
       ai:
         specifier: 6.0.94
         version: 6.0.94(zod@4.3.6)
@@ -948,6 +984,12 @@ importers:
       '@remotion/renderer':
         specifier: 4.0.418
         version: 4.0.418(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      '@upstash/ratelimit':
+        specifier: ^2.0.8
+        version: 2.0.8(@upstash/redis@1.37.0)
+      '@upstash/redis':
+        specifier: ^1.37.0
+        version: 1.37.0
       ai:
         specifier: ^6.0.70
         version: 6.0.103(zod@4.3.6)
@@ -1158,6 +1200,12 @@ importers:
       '@json-render/svelte':
         specifier: workspace:*
         version: link:../../packages/svelte
+      '@upstash/ratelimit':
+        specifier: ^2.0.8
+        version: 2.0.8(@upstash/redis@1.37.0)
+      '@upstash/redis':
+        specifier: ^1.37.0
+        version: 1.37.0
       ai:
         specifier: ^6.0.86
         version: 6.0.103(zod@4.3.6)
@@ -6334,6 +6382,9 @@ packages:
   '@upstash/redis@1.36.1':
     resolution: {integrity: sha512-N6SjDcgXdOcTAF+7uNoY69o7hCspe9BcA7YjQdxVu5d25avljTwyLaHBW3krWjrP0FfocgMk94qyVtQbeDp39A==}
 
+  '@upstash/redis@1.37.0':
+    resolution: {integrity: sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw==}
+
   '@urql/core@5.2.0':
     resolution: {integrity: sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==}
 
@@ -11398,6 +11449,10 @@ packages:
     resolution: {integrity: sha512-dKr8TNYSyceWqBoTHWntjy25xaiWMw5GF+f8QOqFsov9OpTswLs7xdbvZudGRp9jkzbhv/4mVjVZYFtpruGKiA==}
     engines: {node: '>=16'}
 
+  satori@0.25.0:
+    resolution: {integrity: sha512-utINfLxrYrmSnLvxFT4ZwgwWa8KOjrz7ans32V5wItgHVmzESl/9i33nE38uG0miycab8hUqQtDlOpqrIpB/iw==}
+    engines: {node: '>=16'}
+
   sax@1.4.4:
     resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
     engines: {node: '>=11.0.0'}
@@ -18323,7 +18378,7 @@ snapshots:
   '@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))':
     dependencies:
       '@types/jest': 28.1.8
-      '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
+      '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
       eslint: 8.57.1
       eslint-plugin-react: 7.37.5(eslint@8.57.1)
@@ -18861,7 +18916,7 @@ snapshots:
       '@types/node': 22.19.6
     optional: true
 
-  '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
+  '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
     dependencies:
       '@eslint-community/regexpp': 4.12.2
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
@@ -19040,17 +19095,26 @@ snapshots:
 
   '@upstash/core-analytics@0.0.10':
     dependencies:
-      '@upstash/redis': 1.36.1
+      '@upstash/redis': 1.37.0
 
   '@upstash/ratelimit@2.0.8(@upstash/redis@1.36.1)':
     dependencies:
       '@upstash/core-analytics': 0.0.10
       '@upstash/redis': 1.36.1
 
+  '@upstash/ratelimit@2.0.8(@upstash/redis@1.37.0)':
+    dependencies:
+      '@upstash/core-analytics': 0.0.10
+      '@upstash/redis': 1.37.0
+
   '@upstash/redis@1.36.1':
     dependencies:
       uncrypto: 0.1.3
 
+  '@upstash/redis@1.37.0':
+    dependencies:
+      uncrypto: 0.1.3
+
   '@urql/core@5.2.0(graphql@16.12.0)':
     dependencies:
       '@0no-co/graphql.web': 1.2.0(graphql@16.12.0)
@@ -20507,10 +20571,10 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@upstash/redis@1.36.1)(postgres@3.4.8)(sql.js@1.13.0):
+  drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@upstash/redis@1.37.0)(postgres@3.4.8)(sql.js@1.13.0):
     optionalDependencies:
       '@opentelemetry/api': 1.9.0
-      '@upstash/redis': 1.36.1
+      '@upstash/redis': 1.37.0
       postgres: 3.4.8
       sql.js: 1.13.0
 
@@ -25759,6 +25823,20 @@ snapshots:
       postcss-value-parser: 4.2.0
       yoga-layout: 3.2.1
 
+  satori@0.25.0:
+    dependencies:
+      '@shuding/opentype.js': 1.4.0-beta.0
+      css-background-parser: 0.1.0
+      css-box-shadow: 1.0.0-3
+      css-gradient-parser: 0.0.17
+      css-to-react-native: 3.2.0
+      emoji-regex-xs: 2.0.1
+      escape-html: 1.0.3
+      linebreak: 1.1.0
+      parse-css-color: 0.2.1
+      postcss-value-parser: 4.2.0
+      yoga-layout: 3.2.1
+
   sax@1.4.4: {}
 
   saxes@5.0.1:

+ 184 - 0
scripts/generate-og-images.mts

@@ -0,0 +1,184 @@
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import satori from "satori";
+import { Resvg } from "@resvg/resvg-js";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dirname, "..");
+const FONTS_DIR = join(ROOT, "apps/web/public");
+
+const WIDTH = 1200;
+const HEIGHT = 630;
+
+type Framework = "nextjs" | "vite" | "sveltekit";
+
+interface Example {
+  dir: string;
+  title: string;
+  framework: Framework;
+}
+
+const EXAMPLES: Example[] = [
+  { dir: "chat", title: "Chat Example", framework: "nextjs" },
+  { dir: "dashboard", title: "Dashboard Example", framework: "nextjs" },
+  { dir: "image", title: "Image Example", framework: "nextjs" },
+  { dir: "no-ai", title: "No AI Example", framework: "nextjs" },
+  { dir: "react-email", title: "React Email Example", framework: "nextjs" },
+  { dir: "react-pdf", title: "React PDF Example", framework: "nextjs" },
+  { dir: "react-three-fiber", title: "React Three Fiber Example", framework: "nextjs" },
+  { dir: "remotion", title: "Remotion Example", framework: "nextjs" },
+  { dir: "solid", title: "Solid Example", framework: "vite" },
+  { dir: "svelte", title: "Svelte Example", framework: "vite" },
+  { dir: "svelte-chat", title: "Svelte Chat Example", framework: "sveltekit" },
+  { dir: "vue", title: "Vue Example", framework: "vite" },
+  { dir: "vite-renderers", title: "Multi-Renderer Example", framework: "vite" },
+];
+
+function getOutputPath(example: Example): string {
+  const base = join(ROOT, "examples", example.dir);
+  switch (example.framework) {
+    case "nextjs":
+      return join(base, "app", "opengraph-image.png");
+    case "vite":
+      return join(base, "public", "og-image.png");
+    case "sveltekit":
+      return join(base, "static", "og-image.png");
+  }
+}
+
+function buildMarkup(title: string) {
+  return {
+    type: "div",
+    props: {
+      style: {
+        width: "100%",
+        height: "100%",
+        display: "flex",
+        flexDirection: "column",
+        backgroundColor: "black",
+        padding: "60px 80px",
+      },
+      children: [
+        {
+          type: "div",
+          props: {
+            style: {
+              display: "flex",
+              alignItems: "center",
+              gap: "16px",
+            },
+            children: [
+              {
+                type: "svg",
+                props: {
+                  width: 36,
+                  height: 36,
+                  viewBox: "0 0 16 16",
+                  fill: "white",
+                  children: {
+                    type: "path",
+                    props: {
+                      fillRule: "evenodd",
+                      clipRule: "evenodd",
+                      d: "M8 1L16 15H0L8 1Z",
+                    },
+                  },
+                },
+              },
+              {
+                type: "span",
+                props: {
+                  style: {
+                    fontSize: 36,
+                    color: "#666",
+                    fontFamily: "Geist",
+                    fontWeight: 400,
+                  },
+                  children: "/",
+                },
+              },
+              {
+                type: "span",
+                props: {
+                  style: {
+                    fontSize: 36,
+                    fontFamily: "Geist Pixel Square",
+                    fontWeight: 500,
+                    color: "white",
+                  },
+                  children: "json-render",
+                },
+              },
+            ],
+          },
+        },
+        {
+          type: "div",
+          props: {
+            style: {
+              display: "flex",
+              flex: 1,
+              flexDirection: "column",
+              alignItems: "center",
+              justifyContent: "center",
+            },
+            children: title.split("\n").map((line: string) => ({
+              type: "span",
+              props: {
+                style: {
+                  fontSize: 72,
+                  fontFamily: "Geist",
+                  fontWeight: 400,
+                  color: "white",
+                  letterSpacing: "-0.02em",
+                  textAlign: "center",
+                  lineHeight: 1.2,
+                },
+                children: line,
+              },
+            })),
+          },
+        },
+      ],
+    },
+  };
+}
+
+async function main() {
+  const [geistRegular, geistPixelSquare] = await Promise.all([
+    readFile(join(FONTS_DIR, "Geist-Regular.ttf")),
+    readFile(join(FONTS_DIR, "GeistPixel-Square.ttf")),
+  ]);
+
+  const fonts = [
+    { name: "Geist", data: geistRegular, weight: 400 as const, style: "normal" as const },
+    { name: "Geist Pixel Square", data: geistPixelSquare, weight: 500 as const, style: "normal" as const },
+  ];
+
+  for (const example of EXAMPLES) {
+    const svg = await satori(buildMarkup(example.title), {
+      width: WIDTH,
+      height: HEIGHT,
+      fonts,
+    });
+
+    const resvg = new Resvg(svg, {
+      fitTo: { mode: "width", value: WIDTH },
+    });
+    const png = resvg.render().asPng();
+
+    const outPath = getOutputPath(example);
+    await mkdir(dirname(outPath), { recursive: true });
+    await writeFile(outPath, png);
+
+    console.log(`  ${example.dir} -> ${outPath.replace(ROOT + "/", "")}`);
+  }
+
+  console.log(`\nGenerated ${EXAMPLES.length} OG images.`);
+}
+
+main().catch((err) => {
+  console.error(err);
+  process.exit(1);
+});