A small static site generator that turns React and TypeScript into HTML.
dodai uses TSX components as build-time templates. Write a shared document layout,
add pages under src/pages, and export arrays of data to generate multiple pages
from one template. The result is a dist/ directory of HTML and static files that
you can publish to a static web host.
- React templates: compose layouts, page content, and document metadata with TSX.
- File-based pages: turn page files into HTML at predictable paths.
- Data-driven generation: render one page per record using explicit output URLs.
- Static assets: copy CSS, images, and browser scripts alongside generated HTML.
- Local development: preview the site with rebuilds and full-page reloads.
- Native ESM: use ESM imports throughout the library, CLI, and site modules.
- Static output: no dodai or Node.js process is needed to serve the built site.
dodai renders with React's renderToStaticMarkup. It does not create a browser
JavaScript bundle or hydrate React components. Add browser scripts explicitly when
a page needs interaction.
- Quick start
- Native ESM and migration
- Project structure
- Layouts and document metadata
- Static pages and routes
- Data-driven pages
- Styles, scripts, and static files
- Development and automatic reload
- Production builds and deployment
- Command reference
- Programmatic API
- How the build works
- Troubleshooting
- Developing dodai
- Releasing
- License
You need Node.js and npm. The repository does not declare a supported Node.js version range. The following setup was verified with Node.js 24.14.1 and npm 11.11.0, using the repository's React 18 and TypeScript 4.9 dependency baseline.
Run these commands in a new project directory:
mkdir my-dodai-site
cd my-dodai-site
npm init -y
npm install --save-dev @potato4d/dodai@0.8.0
npx dodai init
npx dodai devdodai init creates the starter, installs compatible React and TypeScript
dependencies with npm, and sets "type": "module" in your site's package.json.
Keep the generated lockfile in version control. npm is used here because the
initializer invokes it directly; development of dodai itself uses pnpm.
Open the preview URL printed in the terminal, usually
http://localhost:3000. The starter includes a home page and two generated item
pages at /items/1/ and /items/2/.
Run dodai init only once per new project. It appends template contents to
existing files, including tsconfig.json and .gitignore; it does not merge or
replace them safely. Initialization also invokes npm to install dependencies.
For convenient commands, add these entries to your site's package.json scripts:
{
"scripts": {
"dev": "dodai dev",
"typecheck": "tsc --noEmit",
"build": "dodai build"
}
}Then use npm run dev, npm run typecheck, and
NODE_ENV=production npm run build. See production builds
for the clean build procedure and PowerShell syntax.
As of 0.8.0, dodai is ESM-only. The published library, CLI, and temporary site modules all run as native ESM. The public package entry points are:
import { build, dev, init } from '@potato4d/dodai';
import { HotReload } from '@potato4d/dodai/hotreload';Use these exports instead of importing private files from dist/. The package
does not provide a CommonJS require entry point.
For an existing site migrating from 0.7.x or earlier:
- Set
"type": "module"in the site'spackage.json. - Use
"module": "NodeNext"and"moduleResolution": "NodeNext"intsconfig.json. The starter also uses"target": "ES2022"and"jsx": "react". - Replace CommonJS
require()andmodule.exportswith ESM imports and exports. - Include the emitted
.jsextension in relative TypeScript imports, such asimport { Card } from './components/Card.js'forCard.tsx. - Replace old
@potato4d/dodai/dist/hotreloadimports with@potato4d/dodai/hotreload. - Remove old build output and run a production build to verify the migration.
Apply these changes directly to existing files; rerunning init appends templates
and is not a migration command. New projects receive the ESM settings from init.
The initializer creates the source files below; npm creates the package files:
my-dodai-site/
├── src/
│ ├── layouts/
│ │ └── default.tsx # Shared HTML document; exports Layout
│ ├── pages/
│ │ ├── index.tsx # Home page; exports Page and optionally Head
│ │ └── items/
│ │ └── [item].tsx # Template for the item pages
│ ├── data/
│ │ └── items/
│ │ └── [item].tsx # Matching template data; exports data
│ └── static/
│ └── robots.txt # Copied to /static/robots.txt
├── .gitignore
├── package.json
├── package-lock.json
└── tsconfig.json
Builds also create .dodai-build/build-*/ for temporary JavaScript and dist/ for
the publishable site. Each build removes its temporary directory on completion;
the .dodai-build/ parent may remain. The generated .gitignore excludes both
output directories and node_modules/.
All commands resolve paths from the current working directory. Run them from your site's project root. Source paths, the layout filename, and output directories are fixed; there is no dodai configuration file or CLI flag to change them.
Every page uses the named Layout export from src/layouts/default.tsx. The layout
owns the entire HTML document and receives two props:
| Prop | Value |
|---|---|
head |
The page's rendered Head component, or null when it has no Head export. |
children |
The page's rendered Page component. |
For example, replace src/layouts/default.tsx with:
import * as React from 'react';
import { HotReload } from '@potato4d/dodai/hotreload';
type LayoutProps = {
head: JSX.Element | null;
children?: React.ReactNode;
};
export const Layout: React.FC<LayoutProps> = ({ head, children }) => (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{head}
<HotReload />
</head>
<body>
<header>
<a href="/">My site</a>
</header>
<main>{children}</main>
</body>
</html>
);Keep shared metadata in the layout and page-specific titles or descriptions in
each page's Head. dodai prepends <!DOCTYPE html> to the rendered document.
The layout does not receive the dynamic page's url or data props directly.
The generated starter already contains an inline reload script. When adopting
<HotReload />, replace that script so that there is only one reload subscriber.
A page must export a named Page component. A named Head component is optional;
default exports are not used. Static pages receive no props from dodai.
For example, create src/pages/about.tsx:
import * as React from 'react';
export const Head: React.FC = () => (
<>
<title>About | My site</title>
<meta name="description" content="A small site built with dodai." />
</>
);
export const Page: React.FC = () => (
<article>
<h1>About this site</h1>
<p>This page is rendered to HTML at build time.</p>
</article>
);Static output preserves the path beneath src/pages and changes the extension to
.html:
| Source file | Generated file | Preview URL |
|---|---|---|
src/pages/index.tsx |
dist/index.html |
/ |
src/pages/about.tsx |
dist/about.html |
/about.html |
src/pages/guide/index.tsx |
dist/guide/index.html |
/guide/ |
src/pages/guide/install.tsx |
dist/guide/install.html |
/guide/install.html |
Use about/index.tsx if you want /about/. dodai's preview server does not rewrite
/about to about.html; configure any additional URL rewrites on your host.
Shared components can live outside src/pages, for example in src/components.
Files under src/pages are treated as page modules, so keep helper components
elsewhere and import them from your pages or layout.
A page path containing [ is treated as a dynamic template. dodai loads a data
module at the same relative path under src/data and renders one page for each
entry in its named data export.
For example, these files form a pair:
src/pages/entry/[single].tsx
src/data/entry/[single].tsx
Create the data module at src/data/entry/[single].tsx:
export type EntryProps = {
url: string;
data: {
title: string;
date: string;
body: string;
};
};
export const data: EntryProps[] = [
{
url: '/entry/hello',
data: {
title: 'Hello, dodai',
date: '2026-09-12',
body: 'This entry was generated from a data record.',
},
},
{
url: '/entry/next',
data: {
title: 'The next entry',
date: '2026-09-13',
body: 'One template can generate many pages.',
},
},
];Then create the page at src/pages/entry/[single].tsx:
import * as React from 'react';
import type { EntryProps } from '../../data/entry/[single].js';
export const Head: React.FC<EntryProps> = ({ data }) => (
<title>{data.title} | My site</title>
);
export const Page: React.FC<EntryProps> = ({ url, data }) => (
<article>
<h1>{data.title}</h1>
<time dateTime={data.date}>{data.date}</time>
<p>{data.body}</p>
<a href={url}>Permalink</a>
</article>
);This generates dist/entry/hello/index.html and dist/entry/next/index.html.
Both Head and Page receive { url, data } for the current record.
- Export an array named
data. An empty array generates no pages. Export the resolved array itself, rather than a Promise or a loader function. - Data modules may use
.tsor.tsx; the build discovers both extensions. - Match the template's relative path and basename. Nested templates such as
pages/docs/[section]/[slug].tsxusedata/docs/[section]/[slug].tsx. - Set
urlto a site path beginning with/, such as/docs/start. The output isdist${url}/index.html; use a directory path without an.htmlextension. - Bracket names identify templates. dodai does not extract route parameters or
substitute
[single]into a URL; the record'surlspecifies the full path. - Keep output URLs unique across all templates and static pages. dodai does not
detect output collisions. Use site-local paths without
.., query strings, or fragments because URLs are used directly as filesystem paths.
For content from a CMS, API, or database, prepare a local data file before running
dodai, or use top-level await in an ESM data module and export the resulting
array. Module loading is asynchronous; rendering still happens at build time.
There is no framework-specific data-loader API, Markdown processor, or
request-time data fetching. React escapes string content by default; use
dangerouslySetInnerHTML only with HTML you trust or have sanitized.
When reading local content, resolve paths from process.cwd() to refer to your
site project. import.meta.url inside a compiled module points into the temporary
build directory, where source content files are not automatically copied.
Everything in src/static/ is copied to dist/static/. Keep this directory
present, even for a site without assets: the build expects it to exist.
| Source file | Generated file | Reference in HTML |
|---|---|---|
src/static/site.css |
dist/static/site.css |
/static/site.css |
src/static/images/logo.svg |
dist/static/images/logo.svg |
/static/images/logo.svg |
src/static/site.js |
dist/static/site.js |
/static/site.js |
Reference files directly from your TSX:
// In the layout's <head>:
<link rel="stylesheet" href="/static/site.css" />
// In a page:
<img src="/static/images/logo.svg" alt="My site" />
// In the layout's <body>:
<script src="/static/site.js" defer />Assets are copied as supplied. There is no CSS pipeline, image optimization,
asset hashing, or browser bundler. Compile Sass or browser TypeScript separately
and place their output in src/static/. React event handlers such as onClick
do not become interactive in the generated HTML; attach browser behavior from
your own script.
Put files such as robots.txt, favicon.ico, or sitemap.xml in
src/static/root/ to copy them to the root of dist/ as well:
src/static/root/robots.txt → dist/robots.txt
→ dist/static/root/robots.txt
This extra copy applies to non-hidden files directly inside root/ whose names
contain a dot. It is not a recursive directory copy to the site root.
The starter's src/static/robots.txt is served at /static/robots.txt. Move it to
src/static/root/robots.txt when you need the conventional /robots.txt URL.
Start the development server from the site root:
npx dodai devIt builds the site, serves dist/, and watches src/ for changes. The preview
prefers port 3000; set PORT to request another port:
PORT=4000 npx dodai devThe port finder may select a different available port, so use the printed preview
URL. The preview binds to 0.0.0.0.
Automatic reload uses a separate HTTP long-poll server, defaulting to port
10020. The starter layout or HotReload component subscribes to it and reloads the whole
page after a rebuild. This is a full browser refresh, so local page state resets.
HotReload prop |
Behavior |
|---|---|
| Omitted | Include the script when NODE_ENV !== 'production'. |
dev={true} |
Always include the reload script. |
dev={false} |
Never include the reload script. |
The import path is @potato4d/dodai/hotreload. Use it once in your layout.
Set DODAI_RELOAD_PORT to change the polling port for both the server and the
generated reload script:
PORT=4000 DODAI_RELOAD_PORT=10021 npx dodai devUse distinct, available preview and polling ports for each dev server. Set these variables before starting the process, including when using the programmatic API.
Current development constraints:
- Changes, additions, and removals under
src/trigger queued rebuilds. Each build uses a fresh temporary module directory so imported source modules are reloaded. - If a rebuild fails, the error is logged and the watcher continues. A successful rebuild is required before browsers receive a reload notification.
- Builds do not remove old files from
dist/. After deleting or renaming pages, stop the server, removedist/, and restart to discard their old output. - Changes outside
src/, such as dependency or configuration changes, require a restart. - The polling port must be free; it does not automatically fall back to another
port. Changing
PORTalone does not change the polling port. - The reload script uses
http://localhost:<polling-port>. Reloading from another device or an HTTPS preview needs a custom setup; the supplied script targets local HTTP development.
Build with NODE_ENV=production so that the starter layout and the default
HotReload behavior omit the development script. dodai build does not set this
environment variable for you.
From your site project root, run a clean build on macOS or Linux:
rm -rf .dodai-build dist
npx tsc --noEmit
NODE_ENV=production npx dodai buildRun these commands in order and resolve type errors before building. On PowerShell, the equivalent is:
Remove-Item -Recurse -Force .dodai-build, dist -ErrorAction SilentlyContinue
npx tsc --noEmit
$env:NODE_ENV = 'production'
npx dodai buildTo return to development in the same PowerShell session, clear the variable with
Remove-Item Env:NODE_ENV.
Publish the contents of dist/ to your static host. Neither .dodai-build/,
the source files, nor Node.js dependencies are needed on the serving host.
For CI, install the committed dependency lockfile with npm ci --include=dev
before type checking and building. The starter's build tools are development
dependencies, so they must be available during the build even when the output is
for production.
Compilation, module-loading, and page-rendering failures cause the CLI build to
exit with an error. Check the build log and expected HTML files before uploading;
a failed build can leave copied assets or partial HTML in dist/. A clean build
also prevents removed pages and assets from lingering in the deployed site.
Configure your host to serve index.html for directory URLs. Root-relative URLs
in these examples assume deployment at the domain root; adjust links and assets
yourself for a subdirectory deployment because dodai has no base-path setting.
Use npx dodai <command> with a local installation, or dodai <command> inside an
npm script.
| Command | Purpose |
|---|---|
dodai init |
Append starter files and install template dependencies with npm. |
dodai dev |
Build, serve dist/, watch src/, and start the reload server. |
dodai build |
Copy assets, compile TSX, and render HTML into dist/. |
dodai --help |
Show the available commands. |
dodai --version |
Show the installed package version. |
| Environment variable | Effect |
|---|---|
PORT |
Preferred preview port for dev; defaults to 3000. |
DODAI_RELOAD_PORT |
Polling server and reload-script port; defaults to 10020. |
NODE_ENV |
Controls the starter and HotReload development script; set to production for deployment. |
The package root exports the same asynchronous operations as the CLI. For example,
save this as build-site.mjs in your site project root:
import { build } from '@potato4d/dodai';
await build();Run it with NODE_ENV=production node build-site.mjs.
| Export | Behavior |
|---|---|
await build() |
Compile and render the site in the current working directory. Rejects on compilation or rendering failures. |
await init() |
Generate starter files, install dependencies, and configure the site for ESM. |
await dev() |
Perform the initial build and start the preview server, polling server, and watcher. |
These functions use process.cwd() and the same environment variables as the CLI.
dev() leaves servers and a watcher running; it does not return a shutdown handle.
For ordinary site development, use the CLI and stop it with Ctrl+C.
- Require
"type": "module"in the site'spackage.jsonand create an isolated temporary directory under.dodai-build/. - Copy
src/static/todist/static/, then copy matchingstatic/rootfiles to the output root. - Discover
src/**/*.{ts,tsx}, compile with ES2022 and NodeNext settings, and reject TypeScript compilation errors. - Load the shared layout and compiled pages with native dynamic
import(). For dynamic templates, load the matching data array too. - Render
HeadandPageinsideLayoutwithrenderToStaticMarkup, prepend the doctype, and write the HTML files. - Remove that build's temporary directory, including when the build fails.
The build invokes TypeScript with fixed compiler options; it does not read
your tsconfig.json. That file remains useful for editor tooling and a separate
tsc --noEmit check, but changing its output paths or JSX settings does not
configure dodai. Path aliases and other custom compiler behavior are not wired
into the build.
The separate tsc --noEmit command applies your site's own settings, including
the starter's strict checks. Keep it as an additional check before deployment.
dodai does not clean dist/ or provide a production server; output cleanup and
hosting remain explicit steps around the HTML generator.
| Symptom | What to check |
|---|---|
The build requires "type": "module" |
Set it in the site's package.json, and follow the ESM migration steps for an existing site. |
| A relative import cannot be resolved | Include the emitted .js extension in local imports, including imports written in .ts or .tsx files. |
Importing dist/hotreload fails |
Use the public @potato4d/dodai/hotreload export. |
| React or JSX type errors after changing dependencies | The starter targets React 18, @types/react 18, and TypeScript 4.9; keep these versions compatible. |
| Initialization leaves invalid TSX or JSON | init appends to existing files. Start in a fresh directory or manually repair the appended contents. |
| The build cannot copy static files | Make sure src/static/ exists and run the command from the site root. |
| A page is missing or fails to render | Confirm the named Page export, the named Layout export in src/layouts/default.tsx, and the build log. |
| A dynamic page has no output | Check the matching .ts or .tsx data path, the named data array, and each record's url. |
/about returns 404 |
pages/about.tsx produces /about.html. Use pages/about/index.tsx for /about/. |
| The browser does not update after an edit | Check the terminal for a failed rebuild and confirm that the layout includes one reload script. |
| Removed pages still appear | Stop the server and remove dist/ before rebuilding. |
Reload fails or port 10020 is busy |
Choose a free DODAI_RELOAD_PORT before starting the server, and confirm that the layout uses the same port. |
A deployed page requests localhost:10020 |
Rebuild with NODE_ENV=production and remove any forced dev={true} setting. |
onClick does nothing |
React templates render to static HTML. Add the interaction through an explicit browser script. |
To work on the generator itself, use Node.js and pnpm 11.10.0, the version
declared in package.json. Clone the repository and install its locked
dependencies:
git clone https://github.com/potato4d/dodai.git
cd dodai
pnpm install --frozen-lockfile
pnpm testpnpm test builds the package and runs its Node.js integration tests. Coverage
includes the packed package's ESM exports and type declarations, CLI version and
failure handling, starter configuration, static and dynamic output, ESM-only
dependencies, and module refresh during repeated builds and development.
Use pnpm build to compile the package on its own, or pnpm test:integration to
run the tests against an already built package. The package build clears its own
dist/, emits JavaScript and declarations, and marks dist/cli.js executable.
A site's dodai build instead produces HTML in the site's dist/.
| File | Responsibility |
|---|---|
src/index.ts |
Public ESM library exports. |
src/cli.ts |
CLI command registration and package-version reporting. |
src/commands/init.ts |
Starter templates and dependency installation. |
src/commands/build.ts |
Asset copying, TSX compilation, and HTML generation. |
src/commands/dev.ts |
Preview server, source watcher, and reload notifications. |
src/hotreload.tsx |
Optional development reload component. |
tests/native-esm.test.mjs |
Package, CLI, build, and dev-server integration tests. |
When changing user-facing behavior, also smoke-test a separate starter site: check a static nested route, a data-driven route, copied assets, and production output without the reload script.
Report reproducible bugs and propose changes through GitHub Issues and pull requests. Include the package and Node.js versions, the command you ran, and a minimal example of the relevant layout, page, or data file.
Releases are published by the Publish to npm workflow
using npm Trusted Publishing and GitHub Actions OIDC. A push to master alone does
not publish the package.
The workflow runs for a published, non-prerelease GitHub Release. It installs
locked dependencies, verifies that the tag is v<package.json version>, runs
pnpm test, and publishes the package. The
release runbook documents the one-time npm configuration and
the release steps; no NPM_TOKEN GitHub secret is needed for this workflow.
MIT.
