create-dotnetspa
create-dotnetspa
Scaffold an ASP.NET Core (.NET 10) + SPA solution from the command line, the way create-vite or create-hono do it.
npm create dotnetspa@latest
Why this exists
There is no good official template for this combination, and it is worth being precise about why:
dotnet new reactanddotnet new angularwere discontinued in .NET 8 and no longer ship with the SDK.dotnet new vuenever existed.- The SPA templates Microsoft still maintains only exist inside Visual Studio and rely on
.esproj, so they are unusable from a CLI, from CI, or on macOS and Linux. - The third-party alternatives are either stale (the old SPA templates are pinned to Create React App) or paid.
So this CLI generates the whole thing: a solution with src/<Name>.API and src/<Name>.Client, wired together correctly for whichever combination you pick.
Requirements
- .NET SDK 10 — the CLI fails fast with a clear message otherwise
- Node.js 20+
Usage
Interactive:
npm create dotnetspa@latest
Non-interactive, for CI or scripting:
npm create dotnetspa@latest -- \
--name MyProject \
--framework vue \
--coupling coupled \
--api minimal \
-y
Options
| Option | Values | Default | Notes |
|---|---|---|---|
[directory] |
path | ./<project-name> |
Where to generate the project |
--name <name> |
valid C# identifier | (prompted) | Drives every other name |
-f, --framework |
vue, react, angular |
(prompted) | |
-c, --coupling |
coupled, decoupled |
(prompted) | |
-a, --api |
minimal, controllers |
(prompted) | |
-p, --pm |
npm, pnpm, yarn, bun |
detected from the invoking PM | |
--sln |
slnx, sln |
slnx |
.slnx is the .NET 10 default |
--no-scalar |
scalar enabled | Skips the Scalar API reference UI | |
--no-install |
install enabled | Skips dotnet restore and the SPA install |
|
-y, --yes |
Never prompt; requires --name |
||
--force |
Allow a non-empty target directory |
--yes is genuinely non-interactive: it never prompts, and it errors instead of hanging when stdin is not a TTY.
Naming convention
You provide one name; everything else derives from it following the usual .NET convention.
| Input | Result |
|---|---|
--name MyProject |
MyProject.slnx |
src/MyProject.API |
|
src/MyProject.Client |
|
npm package my-project-client |
What gets generated
MyProject/
├── MyProject.slnx
├── .editorconfig
├── .gitignore
├── README.md
└── src/
├── MyProject.API/ # ASP.NET Core, plus the coupling wiring
└── MyProject.Client/ # Vue / React / Angular
Backend — generated by dotnet new webapi, so the target framework, launch settings, implicit usings and OpenAPI wiring always match the installed SDK. The CLI then replaces the throw-away weather sample with a small products slice that follows the structure you chose:
minimal: endpoint groups inEndpoints/ProductEndpoints.cscontrollers:Controllers/ProductsController.cswithModels/andServices/
Frontend — our own Vite (or Angular CLI) templates, pre-wired: dev-server proxy for /api, a typed API client, two routed pages, and one page that actually calls the backend.
Coupled vs decoupled
Both modes use the same layout. Only the wiring differs, so you can move between them later without reorganising anything.
Coupled
The API hosts the SPA. dotnet run starts the Vite dev server for you:
dotnet run --project src/MyProject.API --launch-profile https
This works through Microsoft.AspNetCore.SpaProxy. One detail is worth knowing, because it explains the whole design:
SpaProxyMiddlewarecompares the request path againstnew Uri(ServerUrl).LocalPath. WithSpaProxyServerUrl = http://localhost:5173that is just/. It only ever intercepts the root path — it does not proxy/api.
So the flow is: the browser is redirected to the Vite dev server, and from then on the SPA runs there. Its /api/* calls go to Vite, and the Vite dev server proxies them back to Kestrel. That is why the generated client always uses a relative /api base URL, and why coupled and decoupled behave identically in development.
In production the API serves the SPA from wwwroot:
dotnet publish src/MyProject.API -c Release
A publish target runs the SPA install and build and packs the bundle into wwwroot, giving you one deployable unit.
Decoupled
Two independent apps. Run them side by side:
# terminal 1
dotnet run --project src/MyProject.API --launch-profile https
# terminal 2
cd src/MyProject.Client && npm run dev
No SpaProxy, no static files in the API, no publish target. A ClientApp CORS policy allows the dev origin in case you call the API directly; the dev-server proxy means you usually do not need it.
How it is put together
create-hono's shape: a thin CLI over a set of templates.@clack/promptsfor the interactive flow (theintro/spinner/tasks/notelook).dotnet newscaffolds the .NET shell; the CLI post-processes only the parts that no template provides.- Templates live in
templates/.templates/*/_sharedholds files common to a variant; the variant folder is copied afterwards and wins, so it can override anything. - Token replacement uses
__TOKEN__, never{{ }}— Vue and Angular use{{ }}for interpolation and would be corrupted.
Development
npm install
npm run build # bundle the CLI into dist/
npm run dev # rebuild on change
npm test # vitest
npm run typecheck # tsc --noEmit
Templates are read from templates/ at runtime, so editing a template does not require a rebuild — only changes under src/ do.
To regenerate a project into a scratch directory:
node dist/cli.js --name Teste -f vue -c coupled -a minimal -y --force
Verified combinations
Each cell was generated and then built for real (dotnet build, the SPA production build, and dotnet publish for the coupled rows):
| Framework | Coupling | API |
|---|---|---|
| Vue | coupled | minimal |
| React | decoupled | controllers |
| Angular | coupled | minimal |
Releasing
Releases are automatic. On every push to main, .github/workflows/release.yml
runs semantic-release, which reads the
commit messages to work out the next version, updates CHANGELOG.md, tags the
release, opens a GitHub Release and publishes to npm.
The commit message is the only input to that decision:
| Commit | Release |
|---|---|
fix: ... |
patch — 1.0.0 → 1.0.1 |
perf: ... |
patch |
feat: ... |
minor — 1.0.0 → 1.1.0 |
feat!: ... or a BREAKING CHANGE: footer |
major — 1.0.0 → 2.0.0 |
docs:, chore:, ci:, test:, refactor:, style:, build: |
nothing published |
Not every merge publishes a version, and that is intended — otherwise the number would stop meaning anything.
Because the project uses squash merges, the pull request title becomes the
commit message on main, so that is what actually decides the release. CI lints
both every commit in the PR and the PR title itself; local commits are checked by
a commit-msg git hook installed by npm install.
Publishing authenticates with npm through OIDC trusted publishing, so there is
no NPM_TOKEN secret in this repository and every published version carries a
provenance attestation. See CONTRIBUTING.md for the contributor workflow and
the required npm settings.
Known limitations
- Template dependency versions are pinned by hand, so they drift. Run a generated project's build before releasing a new framework major.
- Angular is pinned to the 22.x line, which is validated; newer majors may change the
angular.jsonschema. - The coupled publish runs the package manager's
installon every publish. Point it atnpm ciif you want lockfile-exact installs in CI.
Ideas that are not built yet
- Publish the same templates as a NuGet template package, so
dotnet new dotnetspaworks for people who do not use Node. - Fetch templates from a git ref (like
create-honodoes withgiget) so template fixes ship without an npm release. - Optional
--force-style migration between coupled and decoupled after the fact.
Related Packages
Create a SIT web app (Vite + React or Vue + Tailwind v4 + SIT Design System) wit...
Scaffold a working RichTextEditor example app for React, Vue, Angular, or Svelte...
Version History
| Version | Released | License |
|---|---|---|
| 1.0.0 | MIT |