Create a ShadowJS app
Scaffold a ShadowJS app with npm create shadow-app - the flags, the spa and dusk templates, the add-ons, the package manager choice, adding features later, upgrading, and the fixes for the usual failures.
After this page you can create a ShadowJS app in a new folder, pick the template and add-ons you need on the command line or in the prompts, choose the package manager, run the app, add a feature to an app that already exists, keep the app up to date, and fix the errors the scaffolder prints. The scaffolder is create-shadow-app. It has no runtime dependencies and runs on Node 22.12.0 or newer.
Create an app#
npm create shadow-app@latest my-app
On a terminal the command asks for the project name, the template, the add-ons (arrows to move, space to toggle, a for all) and whether to install dependencies. A flag on the command line always wins over an answer. Then it writes the files, runs git init when git is installed and the folder is not already in a repository, installs the dependencies, and prints the next steps:
cd my-app
npm run dev
Flags go after --, because npm reads the flags before that marker as its own:
npm create shadow-app@latest my-app -- --template dusk --tailwind --vitest
Run the scaffolder with --yes, or without a terminal, and nothing else is asked. The folder name is still required without a terminal. The template is spa, Biome is on, and every other add-on stays off unless a flag turns it on.
Flags#
The help text prints every option. Run npm create shadow-app@latest -- --help to see it, or create-shadow-app --help after a global install.
| Flag | What it does |
|---|---|
-t, --template <name> |
spa (a Vite single-page app), dusk (a full-stack app), or a starter built on Dusk: content, dashboard or api. Default spa. |
--adapter <name> |
dusk only (the starters are built for Node). The deployment target: node, bun, deno, cloudflare, vercel, netlify, tarout or static. Default node. |
--name <package> |
The npm package name. Default: derived from the folder name. Use --name @you/my-app for a scoped name. |
-y, --yes |
Accept the defaults and ask nothing. |
--no-install |
Write the files and skip the dependency install. |
--no-git |
Skip git init. |
--pm <name> |
The package manager: npm, pnpm, yarn or bun. Default: the manager that started the command, else a lockfile in the folder, else npm. |
--force |
Write into a folder that is not empty. Files with the same names are overwritten. |
--dry-run |
Print what would be written and write nothing. |
--allow-outside |
Allow a folder outside the current directory. |
--json |
Print one JSON document on stdout instead of words, and ask nothing. See "Scripting the scaffolder". |
-h, --help |
Print the help and exit. |
-v, --version |
Print the version and exit. |
Add-ons take --x to add and --no-x to leave out: --tailwind, --biome, --vitest, --convex, --clerk, --ui, --eslint, --devtools and --router. The next section explains each one. A starter (content, dashboard, api) builds some add-ons in (Vitest and ESLint, and for dashboard also shadow-ui with Tailwind) and refuses the ones that do not fit it, such as --convex or --clerk; its lint script runs ESLint and the Biome check, when chosen, is check.
Templates#
spa#
The spa template is a Vite single-page app that renders with @shadow-js/web. It has index.html, vite.config.ts, src/main.tsx, src/App.tsx, src/index.css and a README. The scripts are dev, build (type-check, then vite build), preview and typecheck.
Add --router for client-side routing with @shadow-js/router. Then src/App.tsx mounts a RouterProvider, the routes live in src/router.tsx as createAppRouter(history?), and the pages live in src/pages. Passing a memory history to createAppRouter lets a test render a route without a browser. Clerk and Convex imply --router.
dusk#
The dusk template is a full-stack app built on @shadow-js/dusk. It has file-based routes, layouts, loaders, server functions, API routes, an error page and a not-found page:
src/routes/_layout.tsxis the shell with the navigation. It exports theerrorpage.src/routes/index.tsx,about.tsxandposts/index.tsxare pages.posts/[id].tsxreads a parameter.src/routes/[...rest].tsxanswers every unknown URL with status 404 and the not-found page.src/routes/api/health.tsis an API route.src/server/posts.tshas server functions made withserverFn. The POST create checks itsinputschema before the handler runs. The schema is a small Standard Schema inschema.ts, and Zod, Valibot or ArkType can replace it.src/entry-server.tssets the handler options:allowedHosts, which makes production answer only for the host ofAPP_URL, HSTS and, with Clerk, the auth middleware. CSRF checks, secure headers and body limits are on by default.dusk.config.tsholds the redirect, the header rule and the prerendered routes..env.exampledocuments the variables..env.publicholds the values that are safe to ship to the browser.
The --adapter flag picks the deployment target and the start script:
| Adapter | Start script runs | Output |
|---|---|---|
node (default) |
dusk start |
The Node server. |
bun |
the Bun server file | The Bun server. |
deno |
the Deno server file | The Deno server. |
tarout |
its server file | The tarout server. |
static |
serve-static.mjs |
dist/static, served by its own server. |
cloudflare |
the platform's local dev server | A deploy script for the platform. |
vercel |
the platform's local dev server | A deploy script for the platform. |
netlify |
the platform's local dev server | A deploy script for the platform. |
Use static only for pages that are fully prerendered. The static target prerenders / and /about. The posts pages read data on the server, so they need a server target. Do not use vite preview for the static target. It answers /about with 404, so the generated serve-static.mjs serves dist/static instead.
Cloudflare, Vercel and Netlify need their own CLI and an account to run locally.
content#
The content template is a site of Markdown files on Dusk: a blog, documentation or a portfolio. Every post is a file in content/; the build reads the folder, prerenders /, /posts and /about, follows their links and writes a static page for every post, and serves /rss.xml, /sitemap.xml and /robots.txt.
src/lib/front-matter.tsandsrc/lib/markdown.tsare a small, tested front-matter parser and Markdown reader (headings, lists, code, quotes, links, emphasis). Raw HTML in a post is shown as text and ajavascript:link is dropped. Both files are yours to change, or to replace with a library.- A post's front matter has
titleanddate(required),summary,tags,langanddraft: true. A post withlang: aris drawn right to left inside any page. SITE_LOCALE(for examplear) sets<html lang dir>for the whole site. The stylesheet uses logical properties, so both directions look right.- It takes
--tailwind,--biomeand--devtools. Vitest and ESLint are built in.
dashboard#
The dashboard template is an admin dashboard on Dusk: a sidebar shell, an overview, a customers table and the forms that create, edit and delete a customer.
- The overview and the table read through
@shadow-js/query. The table is a@shadow-js/tableover a server-side page: sorting, search, the status filter and paging run in a server function, and the previous page stays on screen while the next one loads. - The form is a
@shadow-js/formsform. The schema lives insrc/lib/customers.tsand runs in the browser and again on the server, so both agree; a problem only the server can see (an email another customer has) is shown on its field. - The data is in memory (
src/server/customers.server.ts). Replace that file with your database; the routes, the components and the server functions keep their signatures. There is no sign-in: guard the routes with@shadow-js/dusk/authbefore real data goes in. - The shell has a skip link, a labeled navigation and one main region. Colors come from the
@shadow-js/uitheme and the classes use logical properties, soAPP_LOCALE=argives a right-to-left dashboard and thedarkclass on<html>gives the dark theme. shadow-uiand Tailwind are built in; it takes--biomeand--devtools. Add more components withnpx shadow-ui add dialog.
api#
The api template is a JSON API on Dusk, with no pages: API routes, typed server functions, environment validation, rate limiting, CORS and an OpenAPI description.
src/routes/api/taskshas a paged list (GET),POST, andGET,PATCHandDELETEby id. Input is validated before a handler runs (a wrong body answers 422 with the issues, an oversized one 413), and the tasks are in memory (src/server/tasks.server.ts): replace that file with your database.src/server/limits.tsrate-limits reads and writes with separate buckets and answers 429 withRetry-After. The buckets belong to one process; pass a sharedstorewhen you run several.src/entry-server.tsallows only the host ofAPP_URLin production. A browser app on another origin can call the API only when its origin is inCORS_ORIGINS(comma separated). A request withoutOrigin(curl, a server, a mobile app) is allowed because the API has no cookies; removecsrf.allowMissingOriginif you add sessions.GET /api/openapi.jsonservessrc/api/spec.ts, and a test fails when a route file and the description disagree.- It takes
--biome. Vitest and ESLint are built in.
Add-ons#
| Flag | What it adds |
|---|---|
--tailwind |
Tailwind CSS 4 with @tailwindcss/vite, and @import "tailwindcss"; in src/index.css. |
--biome |
Biome lint and format with biome.json, plus lint and format scripts. On by default. |
--vitest |
Vitest, happy-dom, @shadow-js/testing, a Vitest config, a setup file, a test script and example tests. The example tests also check accessibility: one level-1 heading, an accessible name for every button and link, and alt text for every image. |
--convex |
The Convex provider, a schema, sample query and mutation functions, a Tasks page, VITE_CONVEX_URL in .env.example and a convex script. Run convex dev to create the deployment. |
--clerk |
The Clerk provider, sign-in and sign-up pages, a protected /dashboard and VITE_CLERK_PUBLISHABLE_KEY. For dusk it also adds a middleware that verifies each request, a gate that sends signed-out visitors away from /dashboard, and a requireUser server function. |
--ui |
The shadow-ui setup: components.json, src/lib/utils.ts with cn(), the @/ import alias and the @shadow-js/ui theme. Add components with npx shadow-ui add button. It needs Tailwind, so --ui --no-tailwind is refused. |
--eslint |
@shadow-js/eslint-plugin with its flat config and a lint:eslint script. The dusk template gets the Dusk config, and spa gets the recommended config. |
--devtools |
@shadow-js/devtools, mounted only in development. The production build leaves it out. |
--router |
Client-side routing for spa. See the spa section. |
Clerk loads its bundles in the default script mode, so the Clerk add-on does not install @clerk/clerk-js, @clerk/ui or React. For an offline or strict-CSP app, pass mode="bundled" to ClerkProvider and install both packages yourself.
Choose the package manager#
--pm accepts npm, pnpm, yarn or bun. Without it, the scaffolder uses the manager that started the command, then a lockfile in the folder, and falls back to npm. The README and the next steps of the new app then use that manager's commands. The generated files are the same for every manager.
npm create shadow-app@latest my-app -- --pm pnpm
Scripting the scaffolder#
--json turns the output into one document on stdout and nothing else there. The words (Created my-app ..., and the output of
the install, which is sent to stderr) stay on stderr. The scaffolder never asks a question under --json, with or without a
terminal, so name the directory. The exit code is the one it has without the flag.
npm create shadow-app@latest my-app -- --yes --json --no-install --no-git
{
"schema": 1,
"command": "create-shadow-app",
"ok": true,
"data": {
"name": "my-app",
"directory": "/work/my-app",
"template": "spa",
"adapter": null,
"addons": ["biome"],
"packageManager": "npm",
"local": false,
"dryRun": false,
"files": [".gitignore", "README.md", "biome.json", "index.html", "package.json", "src/App.tsx", "..."],
"dependencies": { "@shadow-js/core": "^1.0.0", "@shadow-js/web": "^1.0.0" },
"devDependencies": { "@shadow-js/vite": "^1.0.0", "typescript": "7.0.2", "vite": "8.3.3", "@biomejs/biome": "2.5.15" },
"installed": false,
"git": "disabled",
"nextSteps": ["cd my-app", "npm install", "npm run dev"]
},
"warnings": []
}
The keys around data are the same for dusk and shadow-ui: schema is 1, command names the command that ran, ok is true
exactly when the exit code is 0, warnings holds { code, message } objects, and error ({ code, message }) exists only when ok is
false. A failure keeps the facts in data: after a failed install the files are listed and installed is false. A command line that
does not parse is a document as well, with exit code 2.
files is sorted and relative to directory; with --dry-run it lists what would be written and dryRun is true. git is
initialized, existing-repository, git-missing, failed or disabled (--no-git, and a dry run). nextSteps are the commands to
type, in order. The error codes, all starting with CREATE_SHADOW_APP_: UNKNOWN_OPTION, INVALID_OPTION, MISSING_DIRECTORY,
INVALID_DIRECTORY, INVALID_NAME, INVALID_SHELL and UNKNOWN_CODEMOD (exit code 2), DIRECTORY_NOT_EMPTY, LOCAL_PACK, WRITE_FAILED,
INSTALL_FAILED, CODEMOD_REFUSED, FAILED and UNEXPECTED (exit code 1), and CANCELED (130 or 143).
create-shadow-app my-app --yes --json --no-install | jq -r '.data.nextSteps[]'
Shell completion#
create-shadow-app completion <bash|zsh|fish> prints a completion script for the flags, the values of --template, --pm and
--adapter, and the directory:
source <(create-shadow-app completion bash)
create-shadow-app completion fish | source
For zsh, save the output as _create-shadow-app in a folder on your $fpath. A project folder called completion is written
./completion. create-shadow-app completion zsh --json prints { shell, script } as the data of a document.
Migrate code with codemods#
create-shadow-app codemod <name> [paths...] edits the source of an existing project for the mappings that need no judgment. There are
three codemods, one for each migration guide:
| Name | Guide | What it changes |
|---|---|---|
react-to-shadow |
React to ShadowJS | useState to useStore or useSignal (and every read of the value), useReducer, useMemo, useEffect dependency arrays, useCallback, imports from react |
preact-to-shadow |
Preact to ShadowJS | the hooks of preact/hooks and preact/compat with the React rules, createContext and Fragment from preact; render, Component and @preact/signals are reported |
react-router-to-shadow |
React Router to ShadowJS | Link and NavLink to A, Outlet, Navigate, useNavigate, useLocation and useParams; the route table is reported |
solid-to-shadow |
Solid to ShadowJS | createSignal, createEffect, createMemo and the other renames, imports from solid-js, solid-js/web and solid-js/store |
next-router-to-shadow |
Next.js to ShadowJS | next/link to A, useRouter and its methods, usePathname |
npx create-shadow-app codemod react-to-shadow src
npx create-shadow-app codemod react-to-shadow src --write
The first command prints a unified diff and writes nothing (--dry-run is the default and may be spelled out). The second applies it.
paths are files or folders under the current directory; without any, the current directory is walked. Running a codemod twice changes
nothing the second time, so it is safe to run again after you fix what it reported.
Each codemod parses the file with oxc-parser and edits it by source range, so comments and formatting outside the edited ranges
stay as written. It follows scopes: a local variable that has the same name as a state variable is left alone, and a local alias
(useState as useLocal) is kept. A binding is migrated completely or not at all. When a use of it cannot be translated, the codemod keeps
the old import and prints a warning with the place:
warning src/todos.tsx:1: `useRef` from "react" is not migrated automatically. Use a plain `let` variable, or `createRef()` for an element. Guide: https://shadowjs.net/guides/migrate/react/
note src/todos.tsx:10: `useEffect(fn, [])` became `onMount(fn)`: it runs once, untracked.
Warnings are what you still have to do; notes are changes that deserve a look, such as a removed dependency array. Both go to stderr and neither changes the exit code.
- Refused paths. A path outside the current directory (by
.., an absolute path or a symbolic link) or a path that does not exist is refused with exit code 1, and then nothing at all is written, not even the files of the other paths. - Skipped files.
node_modules,distand.gitare not entered; declaration files (.d.ts) and files over 1 MiB are skipped; a file that does not parse, is not valid UTF-8 or is nested too deeply to analyze is skipped and named in a warning. Folder walks read.ts,.tsx,.mts,.cts,.js,.jsx,.mjsand.cjs. - Line endings. A file keeps its line endings and its byte order mark; the import lines the codemod adds copy the quotes and the semicolons of the file.
--json. One document on stdout:datahascodemod,mode(dry-runorwrite),files(file,status,reason,written,diffandreportsfor each) and asummaryof counts. The warnings and notes are inwarningsas{ code, message }with the message startingfile:line:; the codes areCREATE_SHADOW_APP_CODEMOD_UNSUPPORTED,CREATE_SHADOW_APP_CODEMOD_NOTEandCREATE_SHADOW_APP_CODEMOD_SKIPPED.- Exit codes. 0 when the run finished, even with warnings; 1 when a path was refused; 2 for a command line that does not parse (an
unknown codemod or flag,
--dry-runwith--write).
oxc-parser is the only dependency of the package and is loaded when codemod runs, so scaffolding is as fast as before. The
documentation guides show the exact before and after of one input for each codemod; the test suite of the package checks those code blocks against the
files it runs the codemods on.
Add a feature to an existing app#
The scaffolder only writes new folders. It has no command that adds an add-on to an app you already have. To add one later:
- Create a second app with the same template and the add-on, in a scratch folder:
npm create shadow-app@latest scratch -- --template dusk --tailwind --no-install --no-git. - Compare the add-on table above with the files it lists, and copy the ones you need into your app.
- Install the packages the add-on depends on, and run
npm run buildand the tests.
Do not run the scaffolder with --force on your own app to add a feature. --force overwrites every file whose name matches a generated file, including the ones you edited.
For UI components, use npx shadow-ui add <component> after --ui. That command is the supported way to add a component.
Upgrade an app#
The scaffolder pins the versions of the third-party tools it writes, such as Vite, TypeScript, Biome and Vitest. The ShadowJS packages are written with the ^1.0.0 range. To move an app to a newer ShadowJS release:
npm update @shadow-js/web @shadow-js/dusk
Replace the package names with the ones your app uses. Read the changelog of each package before you update, and run the type check and tests after the update. To move a pinned third-party tool, change its version in package.json, install, and run the same checks.
A new scaffold is useful as a reference for a newer layout, but it is not an upgrade path. Copy only the changes you need.
Troubleshooting#
The command exits with an error about the Node version. The scaffolder needs Node 22.12.0 or newer. Check the version with node --version, and install a current Node release if it is older.
The command exits with status 2 and names a flag. The flag is not one the scaffolder knows, or it is spelled wrong. Run npm create shadow-app@latest -- --help to see the list. Exit status 2 means nothing was written.
The command exits with status 1 and says the directory is not empty. Pick a new folder name, or run again with --force if you want the matching files overwritten.
The name is refused. npm reserves some names, such as node_modules and favicon.ico, and Node reserves the names of its built-in modules, such as http. Use a scoped name instead: --name @you/http. Windows also refuses some folder names, such as CON, NUL and names with a colon, on every platform, so a project made on macOS can still be cloned on Windows.
The folder is outside the current directory. The scaffolder refuses that by default. Run it from the parent folder, or add --allow-outside if you mean to write there.
The install fails, or you are offline. The files are written before the install starts. The scaffolder exits with status 1 and prints the command to run. Go online and run that install command in the new folder. To write the files with no install at all, pass --no-install and run the install later. Creating the files does not need the network.
Ctrl-C or SIGTERM stopped the command. The scaffolder exits with status 130 for Ctrl-C and 143 for SIGTERM. Whatever this run created is removed. A folder that already existed keeps the files that were there before the run.
The next steps do not match your package manager. Run again with --pm <name> in a new folder, or change the commands in the README of the app.
| Exit status | Meaning |
|---|---|
| 0 | The app was created. |
| 1 | The run failed. The message says why and what to do next. |
| 2 | The command line was wrong. Nothing was written. |
| 130 | Canceled with Ctrl-C. This run's files were removed. |
| 143 | Terminated with SIGTERM. This run's files were removed. |