I started building Kudzu while making static websites with AI.
AI coding tools have become very good at producing React-shaped TSX, and I have become used to reviewing code in that form. Function components, props, JSX, and event handlers are often easier for me to understand and verify than scattered DOM queries and imperative JavaScript mutations.
But I was still building static pages.
I wanted to keep TSX as the authoring and code-review format without automatically shipping React, a virtual DOM, hydration, or a browser-side component tree.
Kudzu grew from that idea:
Write familiar TSX, execute components during the build, and ship ordinary HTML with only the JavaScript each route actually needs.
Kudzu is an experimental, HTML-first TSX framework.
Website: kudzujs.cloud
GitHub: github.com/kudzujs/kudzu
Consider a blog, documentation site, newsletter, or product landing page.
Most of the page is already known during the build:
TSX is a convenient way to author and review that structure.
function PostCard({ title, description, href }: {
title: string
description: string
href: string
}) {
return (
<article>
<h2><a href={href}>{title}</a></h2>
<p>{description}</p>
</article>
)
}
The component model is useful for authoring, but that does not necessarily mean the browser needs a component runtime.
For a static page, I wanted the output to remain ordinary HTML.
<article>
<h2><a href="/posts/hello">Hello</a></h2>
<p>My first article.</p>
</article>
I also wanted interactive pages to receive only the JavaScript required for their actual behavior.
Kudzu treats components as build-time authoring units.
React-shaped TSX
β
Kudzu compiler
β
Static HTML + CSS + capability-specific ESM
Function components execute during the build.
The browser does not receive:
A completely static route receives no client JavaScript.
A Kudzu page lives in src/pages
.
// src/pages/index.tsx
export default function HomePage() {
return (
<main>
<h1>Hello from Kudzu</h1>
<p>This page becomes static HTML.</p>
</main>
)
}
Build the project:
npm run build
The output is written to dist
.
dist/
ββ index.html
ββ assets/
ββ style.css
Because the page has no browser behavior, Kudzu does not add a JavaScript runtime.
Kudzu supports a React-shaped useState
API.
import { useState } from "@kudzujs/core"
export default function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}
The compiler identifies:
The component is executed during the build to produce the initial HTML.
The browser receives a small behavior plan that updates the relevant DOM node directly.
click
β update logical state
β queue a DOM commit
β patch the bound text node
The browser does not rerun the component.
Kudzu deliberately differs from React's state snapshot behavior.
function increaseTwice() {
setCount(count + 1)
setCount(count + 1)
}
In Kudzu:
The handler above increments the value by two and patches the DOM once.
This model gives event handlers normal sequential JavaScript behavior without requiring a browser component rerender.
Kudzu has two event compilation paths.
Simple setter-only handlers compile to compact behavior commands.
<button onClick={() => setCount(count + 1)}>
Increase
</button>
Normal synchronous or asynchronous handlers compile to external ESM.
async function loadStatus() {
setStatus("")
try {
const response = await fetch("/api/status")
const result = await response.json()
setStatus(result.status)
} catch {
setStatus("failed")
}
}
Kudzu does not use:
eval
;new Function
;Relative TypeScript helpers can also be bundled into the generated handler module.
import { normalizeStatus } from "../lib/status"
async function loadStatus() {
const response = await fetch("/api/status")
const result = await response.json()
setStatus(normalizeStatus(result))
}
State can be used in text, attributes, styles, and form properties.
const [open, setOpen] = useState(false)
return (
<button
className={open ? "active" : "idle"}
aria-expanded={open}
onClick={() => setOpen(!open)}
>
{open ? "Close" : "Open"}
</button>
)
Kudzu compiles the expressions into direct DOM updates.
open changes
β patch class
β patch aria-expanded
β patch text
The entire component is not rerendered.
Inline conditional children compile to bounded DOM ranges.
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(!open)}>
Toggle menu
</button>
{open && (
<nav>
<a href="/docs">Docs</a>
<a href="/example">Examples</a>
</nav>
)}
</>
)
Both branches are prepared during the build. The browser mounts or removes only the relevant DOM range.
There is still no virtual DOM or component tree.
Kudzu also supports a constrained form of keyed list rendering.
const [items, setItems] = useState([
{ id: 1, name: "Oak" },
{ id: 2, name: "Pine" }
])
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name}
<button
onClick={() =>
setItems(items.filter(entry => entry.id !== item.id))
}
>
Remove
</button>
</li>
))}
</ul>
)
The initial list is emitted as static HTML.
When state changes, Kudzu moves, inserts, updates, or removes the keyed DOM nodes directly.
Existing keyed nodes are preserved instead of remounted.
Async components can load data during the build.
export default async function ProductsPage() {
const products = await fetch(
"https://example.com/api/products"
).then(response => response.json())
return (
<main>
<h1>Products</h1>
<ul>
{products.map(product => (
<li>{product.name}</li>
))}
</ul>
</main>
)
}
When the route and data are available during the build, the result is complete static HTML.
No browser fetch or hydration is required.
Build-known dynamic routes use getStaticPaths()
.
// src/pages/posts/[slug].tsx
export function getStaticPaths() {
return [
{
params: { slug: "hello-kudzu" },
props: { title: "Hello Kudzu" }
},
{
params: { slug: "static-html" },
props: { title: "Static HTML" }
}
]
}
export default function Post({
title
}: {
title: string
}) {
return <h1>{title}</h1>
}
The build produces:
dist/posts/hello-kudzu/index.html
dist/posts/static-html/index.html
The result can be deployed to an ordinary static host.
Sometimes data or browser APIs are only available after the document loads.
Kudzu supports a deliberately constrained useEffect
form.
import { useEffect, useState } from "@kudzujs/core"
export default function StatusPage() {
const [status, setStatus] = useState("")
useEffect(() => {
fetch("/api/status")
.then(response => response.json())
.then(result => setStatus(result.status))
}, [])
return <p>Status: {status}</p>
}
The effect is not executed during static rendering.
Kudzu emits a route-specific effect entry that updates the existing logical state and bound DOM nodes.
The component itself is not shipped or rerun.
Kudzu does not give every interactive route the same runtime.
A route receives only the capabilities it uses.
Static route β HTML only
State commands β compact behavior runtime
Reactive bindings β binding capability
Conditional DOM β bounded DOM operations
Keyed lists β list capability
Normal handlers β external handler ESM
Effects β route-specific effect entry
Runtime parameters β route-specific pathname reader
A static route remains JavaScript-free even if another route in the same project is interactive.
Kudzu writes deployable artifacts to dist
.
A minimal Cloudflare configuration looks like this:
{
"name": "my-kudzu-site",
"compatibility_date": "2026-07-20",
"assets": {
"directory": "dist"
}
}
Build and deploy:
npm run build
npx wrangler deploy
The Kudzu website itself is deployed using Cloudflare static assets.
The project also uses build-time generation for:
Vanilla JavaScript is not the problem.
For small behavior, it is often the smallest and best browser output.
The problem I was trying to solve was authoring and review.
When working with AI-generated code, I found this form easier to inspect:
<PostCard
title={post.title}
description={post.description}
href={`/posts/${post.slug}`}
/>
than a collection of unrelated operations:
const article = document.createElement("article")
const heading = document.createElement("h2")
const link = document.createElement("a")
link.href = `/posts/${post.slug}`
link.textContent = post.title
heading.append(link)
article.append(heading)
The browser may ultimately need ordinary DOM operations.
I just did not want to author and review the entire site at that level.
Kudzu uses TSX as the input language and ordinary HTML and DOM operations as the output.
Kudzu is not a drop-in React replacement.
It supports a statically analyzable React-shaped subset.
Unsupported patterns fail during the build instead of silently causing Kudzu to ship a general component runtime.
Kudzu currently does not provide:
The goal is not to run every React application unchanged.
The goal is to preserve familiar TSX authoring where it can be compiled safely into static HTML and direct browser behavior.
Kudzu is experimental, and the supported TSX surface may still change.
The current implementation includes:
I am especially interested in feedback about:
Create a project:
npm create kudzu@latest my-app
cd my-app
npm run dev
Build the static output:
npm run build
The generated files are available in:
dist/
The build execution plan is available in:
.kudzu/kudzu-plan.json
Website: https://kudzujs.cloud/
Examples: https://kudzujs.cloud/example
Documentation: https://kudzujs.cloud/docs
GitHub: https://github.com/kudzujs/kudzu