Single-page app front ends
A React, Vue, Svelte or Angular app that builds with npm can live inside an ASP.NET host, be built by
dotnet build, ship inside dotnet publish, and be served by one call:
app.MapControllers(); // your API first
app.MapRaskSpa();
Rask.Spa.Hosting does this on any ASP.NET app, with or without the rest of Rask, and has no opinion
about the framework of the front end — it runs the client's own npm scripts and serves what they
wrote. A host with remote messages also gets them as TypeScript.
To put a React or Vue component inside a Rask page instead, see Islands.
Scaffolding one
rask new Shop --template react
cd Shop
rask dev
That writes one project: a Rask.Server host — RaskApp.Create(args).Serve(), every battery on — and
a React + TypeScript app in client/, built, checked and tested with Vite+
and styled with Tailwind. rask dev installs the
client's dependencies the first time, then runs the host and Vite together; Vite forwards /_rask and
/api/auth to the host.
--template takes seven frameworks: react, preact, vue, angular, solid, svelte and lit.
The host is the same in all of them, and so is the starter — one page and one sign-in screen, drawn with
the same Tailwind utilities in each framework's own idiom. No component library is installed.
--template |
The starter page | The sign-in screen |
|---|---|---|
react |
src/App.tsx |
src/Auth.tsx |
preact |
src/app.tsx |
src/auth.tsx |
vue |
src/App.vue |
src/Auth.vue |
solid |
src/App.tsx |
src/Auth.tsx |
svelte |
src/lib/Greeting.svelte |
src/lib/Auth.svelte |
lit |
src/my-element.ts |
the same element |
angular |
src/app/app.ts + app.html |
src/app/auth.ts |
Lit's element renders into the light DOM, so the page's Tailwind sheet reaches it.
Angular is the one that is not a plain Vite project, and its host says so in three places:
- the dev server is
ng serveon port 4200, started bynpm start— the host's csproj sets<RaskSpaDevServerUrl>http://localhost:4200</RaskSpaDevServerUrl>, which is whatrask devopens; - the proxy is
client/proxy.conf.json, whichangular.jsonpoints at, instead of aserver.proxyblock invite.config.ts; - the bundle lands in
dist/<app>-client/browser, so the csproj setsRaskSpaDistDirto it.
In every template:
- A query and a command.
Features/Hello/holds the starter's messages and handlers; the starter page dispatches them through the typed client the build writes intoclient/src/rask/. - Sign-in screens.
/loginand/register, over the host's accounts at/api/auth. They callloginandregisterfromclient/src/rask/browser/auth.ts, which the build copies beside the typed client. The session is an HttpOnly cookie, so the page never holds a token. - Web Push.
client/src/push.tsexportssubscribeToPush()andunsubscribeFromPush(): the browser's ownPushManager, the worker inclient/public/rask-sw.js, and the host's/_rask/pushendpoints.--no-pushleaves it out;--no-pwaleaves out the manifest and the worker too.
Batteries are turned off the same way as on any template — --no-data, --no-ops — except --no-cqrs:
the typed client is the CQRS wire. See the CLI.
The client's toolchain: Vite+
Every template but Angular is a Vite+ project: one vite-plus package
bundles Vite, Vitest, Oxlint and Oxfmt behind the vp command, and one client/vite.config.ts
configures all of them. There is no ESLint, Prettier or Vitest config beside it.
| Script | Runs | What it does |
|---|---|---|
npm run dev |
vp dev |
The dev server rask dev starts, with the /_rask proxy. |
npm run build |
type check, then vp build |
What dotnet build runs. |
npm run check |
vp check |
Format, lint and type-check in one pass. |
npm run lint · npm run format · npm run format:check |
vp lint · vp fmt · vp fmt --check |
One of those at a time. |
npm test |
vp test |
Vitest, once. The starter ships src/push.test.ts. |
The config's blocks, in the order the file has them:
fmt— Oxfmt with the starter's style (no semicolons, single quotes, 100 columns), Tailwind classes sorted against the client's stylesheet.lint— Oxlint, close to the recommended sets, type-aware sovp checkis also the type check. React and Preact use Oxlint's built-in hook rules and Vue its Vue plugin; Solid and Lit, which Oxlint has no plugin for, runeslint-plugin-solidandeslint-plugin-litinside it as JS plugins. Vue's and Svelte's templates are checked byvue-tscandsvelte-check, whichcheckandbuildrun first.test—passWithNoTests, so a--no-pushapp with no test yet still passes.staged— what the pre-commit hook runs on the files being committed:vp check --fix.
Both fmt and lint ignore src/rask/: the typed client is rewritten on every build, so formatting it
is a diff the next build undoes.
The pre-commit hook. npm install runs the prepare script, vp config, which points the
repository's git hooks at client/.vite-hooks/; its pre-commit runs vp staged. Outside a git
repository it does nothing, so a CI build is unaffected. VP_GIT_HOOKS=0 skips it.
The editor. client/.vscode/ recommends the Vite+ extension pack and makes Oxfmt the formatter on save.
Angular keeps the Angular CLI and angular-eslint: its build is the CLI's builder, not a Vite config.
Where the front end lives
Reference the package from the host project, and put the front end in a client folder inside it:
Shop/
Shop.csproj <PackageReference Include="Rask.Spa.Hosting" />
Program.cs
client/
package.json
src/
A client folder holding a package.json is found by convention. A front end that lives anywhere
else is named:
<PropertyGroup>
<RaskSpaClientDir>app</RaskSpaClientDir> <!-- relative to the project -->
<RaskSpaDistDir>build</RaskSpaDistDir> <!-- where its build writes; dist by default -->
</PropertyGroup>
A source file is one matched by RaskSpaClientInputs, globs relative to the client: by default
*.json;*.ts;*.js;*.mjs;*.html;src/**/*;public/**/*. A client whose sources live elsewhere names them,
or its bundle is built once and never again — a repository-root package with its apps under apps/,
served by a host in apps/web/host:
<PropertyGroup>
<RaskSpaClientDir>../../..</RaskSpaClientDir>
<RaskSpaClientInputs>*.json;*.js;apps/**/*;libs/**/*</RaskSpaClientInputs>
</PropertyGroup>
node_modules, every bin/ and obj/, and the dist directory are never counted, so a glob that
covers the host project itself does not rebuild the bundle on every build.
An app on Rask.Server already has MapRaskSpa, but not the build steps: reference
Rask.Spa.Hosting directly to get them.
Building and publishing
dotnet build runs the client's own toolchain: npm ci (or npm install when there is no
lockfile), then npm run build. A new app has no lockfile, so its first build runs npm install
and writes one — commit it, and every build after that is npm ci. A yarn.lock or pnpm-lock.yaml
counts as the lockfile too, and the default install follows it: yarn install --frozen-lockfile or
pnpm install --frozen-lockfile. Both steps are incremental — an install re-runs when package.json
or the lockfile changes (or node_modules is gone), a build when a source file does. dotnet publish copies the bundle into
wwwroot next to the app, so a deployed container carries the front end rather than a path that only
existed on the build machine.
-p:RaskSpaBuild=false skips node entirely. The app still compiles, its API still works, and the site
serves a page saying there is nothing built yet. Use it on a machine with no node, or in a CI job that
only cares about the C#.
Which versions a new app gets
The templates name no versions: every package in their package.json is latest. rask new asks the
npm registry for the newest stable releases that install together — peer dependencies that agree
with each other, and an engines.node that accepts the newest Node LTS — and writes them as ^x.y.z.
So when Vite ships a major its React plugin does not accept yet, a new app gets the newest Vite the
plugin does; once the plugin catches up, the next app gets both. The only ranges the templates keep are
tailwindcss / @tailwindcss/*, which must match the Tailwind binary the host downloads,
@types/node, which follows the Node LTS major, and the Vue client's typescript, held on 6.x because
vue-tsc drives TypeScript's JavaScript API, which TypeScript 7 no longer ships. Offline, rask new
leaves latest in place with one warning line, and npm resolves it on the first build.
The Vite+ toolchain is three entries that move as one: vite-plus, written as an exact version, and
vite aliased to the core that release ships (npm:@voidzero-dev/vite-plus-core@x.y.z), in
devDependencies and again in overrides so every plugin resolves the same one. The plugins' peer ranges
on vite are held to the Vite that core bundles, so a plugin that already wants the next Vite major waits
for a Vite+ that ships it.
Which Node
Use the current LTS. The build's own floor is 24.11.0, the first release of the Active LTS line,
and it moves with that line. It is enforced: an older Node fails with RASKSPA005 naming the version
it found, rather than reaching the bundler and failing there with an engines error. Set
RaskSpaMinimumNode to move the bar — it is a real comparison, in both directions, so a front end on
an older toolchain can lower it. A toolchain that wants more than the floor (Angular's CLI, say)
reports its own requirement.
Development
rask dev starts two processes: dotnet watch for the host, and the client's own dev server — the
dev script in its package.json, or start where that is what it has. The browser talks to the
dev server, which is what serves the front end and what its hot reload reaches, and the dev server
forwards API calls to the host. That proxy belongs to the client — for Vite:
// client/vite.config.ts
export default defineConfig({
server: { proxy: { "/api": "http://localhost:5000" } },
});
The browser only ever sees one origin, so there is no CORS to configure. The host, asked for a page before anything is built, answers 200 with a page naming the dev server rather than a 503.
rask dev opens http://localhost:5173, Vite's default. A dev server that listens elsewhere is named
in the host's project file — <RaskSpaDevServerUrl>http://localhost:3000</RaskSpaDevServerUrl> — and
both rask dev and that page follow it. A dev session does not build the production bundle.
Under VS Code's F5 a host that calls AddRaskSpaHost() starts the client's dev server itself — the dev (or start) script from
the client's package.json — because nothing else runs beside an app the debugger launched.
What MapRaskSpa does
More than UseStaticFiles and a fallback:
- A missing asset stays a 404. A naive SPA fallback answers every unmatched request with
index.html, so a missing module import arrives as HTML and the browser reportsFailed to load module script. Requests under a content-hashed prefix, and requests whoseAcceptasks for something other than HTML, are refused instead. - Cache headers follow what the bundler guarantees. Files under a hashed prefix are cached for
ever;
index.htmlnever is — freezing it strands a visitor on the deploy they first saw. - Precompressed siblings. A
.bror.gzbeside a file is served when the client accepts it, keeping the real content type. - Your endpoints win. The fallback has the lowest precedence, so a controller, a minimal API or a health check answers the paths it names whichever side of the call it is mapped on.
MapRaskSpa(pathBase: "/app") serves the bundle under a prefix and leaves the rest of the site alone.
A Rask WebAssembly client is served by the same call — see
deployment.
Options
// appsettings.json
{
"Rask": {
"Spa": {
"DevServerUrl": "http://localhost:5173",
"ImmutablePathPrefixes": [ "/static/" ] // added to the default /assets/, not replacing it
}
}
}
/assets/ is where Vite puts its hashed files. A bundler that hashes somewhere else — Create React
App writes /static/ — is told so here. The two delegates, ExcludeFromFallback and
OnPrepareResponse, can only be set in code:
app.MapRaskSpa(configure: options => options.ImmutablePathPrefixes.Add("/static/"));
MSBuild properties
| Property | Default | |
|---|---|---|
RaskSpaClientDir |
a client folder in the host project |
Where the front end lives. |
RaskSpaDistDir |
dist |
The bundler's output. Angular nests it: dist/<app>/browser. |
RaskSpaBuild |
true |
false skips node entirely. |
RaskSpaInstallCommand |
npm ci |
Run when a lockfile exists; left at npm ci, a yarn.lock or pnpm-lock.yaml runs that manager's frozen install instead. |
RaskSpaFirstInstallCommand |
npm install |
Run when there is no lockfile yet. |
RaskSpaBuildCommand |
npm run build |
What produces the bundle. |
RaskSpaClientInputs |
*.json;*.ts;*.js;*.mjs;*.html;src/**/*;public/**/* |
The client sources, relative to it, whose change rebuilds the bundle. |
RaskSpaMinimumNode |
24.11.0 |
The Node floor the build enforces, as RASKSPA005. |
RaskSpaPublishDir |
wwwroot |
Where publish puts the bundle. |
RaskSpaDevServerUrl |
none | Named on the "nothing built yet" page in Development. |
RaskEmitTypeScript |
on | false generates nothing, whatever the host declares. |
RaskSpaGeneratedDir |
src/rask |
Where the generated client lands, inside the front end. |
RaskSpaTypeScriptConfig |
tsconfig.json |
The client's TypeScript config; its presence is what RASKSPA004 checks. |
A typed client for your messages
A host that declares remote messages gets their TypeScript written into its front end on every build — you write the records once, in C#, and there is no schema file to keep in sync:
File in client/src/rask/ |
|
|---|---|
contracts.ts |
The types: one per record that crosses the wire. |
messages.ts |
A factory per message, carrying its wire name and its result type. |
client.ts |
The dispatcher: rask.dispatch, uploads, downloads. Refreshed from the package. |
query.ts |
Options helpers for TanStack Query, importing nothing from it. |
browser/auth.ts |
Sign-in against /api/auth: login, register, logout, me, passkeys. |
The directory is generated; ignore it in git. Its files are written only when they change, so a
watching bundler is not woken for nothing, and they are written under rask dev too — a dev server
compiling the previous build's contracts is exactly the failure this exists to prevent.
Only a host with messages gets any of this. One that declares none is a static-file host for any
front end, in any language, and the build leaves its sources alone. The first remote message is what
asks the front end to be TypeScript: without a tsconfig.json the build stops with RASKSPA004,
because a JavaScript client would import the contracts, have nothing checked, and find a renamed
property on the wire instead of at the compiler. A client that keeps its config elsewhere names it —
<RaskSpaTypeScriptConfig>tsconfig.app.json</RaskSpaTypeScriptConfig> — and RaskEmitTypeScript=false
turns the generation off outright.
The call site
A message factory carries its own wire name and its own result type, so dispatch infers what comes
back:
import { rask } from './rask/client'
import { getGreeting } from './rask/messages'
const greeting = await rask.dispatch(getGreeting({ name: 'Ada' }))
// ^? Greeting — inferred from the message, no cast
Rename a property on the C# record and this line stops compiling. That is the whole point of generating the types rather than describing them.
Adding a cache
rask.dispatch is a call, not a cache. If you want one, Rask.Spa.Hosting vendors src/rask/query.ts beside the client — raskQuery and
raskMutation return plain options objects and import nothing from TanStack, so they work under
every adapter:
const { data, isPending } = useQuery(raskQuery(getGreeting({ name })))
const visit = useMutation({
...raskMutation(recordVisit),
onSuccess: () => queryClient.invalidateQueries({ queryKey: [getGreeting.messageName] }),
})
raskQuery accepts only a query. Handing it a command is a compile error — the same thing the
server enforces by answering 405 to a command sent as a GET. Invalidation uses
getGreeting.messageName rather than a string literal, so renaming the record moves the cache key
with it.
Solid, Svelte, Vue, Angular and Lit want the options wrapped in a thunk, and that is not a formality: it is what lets them re-read the signal, the ref, the rune or the reactive property and refetch when it changes. Pass the object directly and it reads the value once, at setup, and never again.
Dates
The generated types give you real Date objects, and only where the C# type actually said so.
| C# | TypeScript | Why |
|---|---|---|
DateTimeOffset |
Date |
A true instant, which is exactly what Date is. |
DateTime |
Date |
Unambiguous only if its Kind is Utc or Local — see the warning below. |
DateOnly |
DateOnly (a string) |
A calendar fact, not an instant. |
TimeOnly |
TimeOnly (a string) |
A time of day. Seven fractional digits, which Date cannot parse. |
TimeSpan |
Duration (a string) |
A length, not a point. [-][d.]hh:mm:ss[.fffffff], not ISO-8601. |
byte[] |
Base64 (a string) |
Base64, as the wire carries it. |
DateOnly stays a string on purpose. new Date("2026-08-25") is parsed as UTC midnight, so
anyone west of UTC renders it as the 24th. A date somebody picked in a calendar is not a point
in time, and making it one reintroduces a bug this repo has already fixed once.
Prefer DateTimeOffset to DateTime on anything a front end reads. A DateTime with
DateTimeKind.Unspecified writes an ISO string with no suffix, and modern JavaScript parses that as
local time — so the same payload means a different instant on every machine that reads it.
How the revival works
Not with a regex. The usual JSON.parse reviver tests every string against a date-shaped pattern
and converts anything that matches — including a product code, an ETag, or a free-text field that
happens to look like a timestamp, silently.
Rask does not have to guess. The generator walks the same wire model the C# codec is built from and emits a descriptor naming exactly the date-bearing properties:
export const shapes = {
Order: { instants: ['placedAt'], nested: { lines: ['Line', 1] } },
Line: { instants: ['shippedAt'], nested: {} },
} as const
The client revives precisely those. The number beside a nested shape is how many arrays or
dictionaries stand between the property and it — Dictionary<string, Line> and Line both arrive
as plain objects, and without the count the walk would revive a dictionary's own keys as if they
were the shape's properties.
Sending one back
Nothing is needed. JSON.stringify already writes a Date through toJSON, which is
toISOString(): always UTC, always with a Z. So a value sent from the browser is never ambiguous.
One consequence worth knowing: a round trip normalises a DateTime with an unspecified Kind
into UTC.
Displaying one
That is your app's job, and the browser already does it well:
new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(order.placedAt)
undefined means the visitor's own locale, and the browser's own time zone is the default — which
is the right answer on a front end, and the reason none of the C#-side timezone machinery applies.
Adding a message
Add a record and a handler:
public sealed record Order(Guid Id, DateTimeOffset PlacedAt, DateOnly DeliverBy);
public sealed record GetOrder(Guid Id) : IQuery<Order>;
public sealed class GetOrderHandler : IQueryHandler<GetOrder, Order>
{
public Task<Order> Handle(GetOrder query) => /* … */;
}
The next build writes getOrder into src/rask/messages.ts and Order into contracts.ts. If a
property has no wire encoding, the build fails with RASK053 naming it — a shape that cannot cross
is reported at compile time rather than on the wire.
A message that is never sent anywhere — a job payload, an outbox event — should say so with
[LocalOnly], which exempts it from all of this.
Moving an existing app onto Rask, a page at a time
An app that already has an API and a single-page front end does not have to move at once. Give Rask a prefix, and leave everything else to the front end it already has:
builder.Services.AddRask();
var app = builder.Build();
app.UseWebSockets();
app.MapControllers(); // the API, unchanged
app.MapRask<App>(pathBase: "/new"); // pages that have moved
app.MapRaskSpa(); // every other path: the old front end
AddRask() and MapRask<App>() need no using: referencing Rask.Server imports its namespace for the
project. A page file imports routing itself, because [Route] lives in Rask.Core.Routing:
using Rask.Core.Routing;
[Route("/orders")]
public sealed partial class OrdersPage : Component { … }
Without that line the Web SDK's own Microsoft.AspNetCore.Routing.Route class answers to [Route], and
the build stops with CS0616: 'Route' is not an attribute class. Rask does not import the namespace for
the whole project, because the app's controllers already use [Route] from Microsoft.AspNetCore.Mvc, and
two RouteAttributes in scope break every one of them (CS0104).
The prefix reaches the browser by itself. The runtime's <script> tag carries it
(data-rask-base="/new/"), and the runtime builds its connection, the addresses it pushes to history and the
paths it routes from that, so the app writes no <base href>. Rask leaves <base> alone on purpose: one would
re-resolve every relative URL of the page — href="#reviews" would lead to /new/#reviews. An app that writes
its own <base href> keeps it; the runtime still takes the prefix from its tag.
Files under wwwroot are the host's to serve. MapRask serves its own runtime and scoped assets under the
prefix, never a wwwroot file, so a stylesheet linked as LiveOptions.PathBase + "/css/app.css"
(/new/css/app.css) falls through to Rask's page catch-all and answers with a page. Link the address the host
already serves the file at — /css/app.css, from its MapStaticAssets() or UseStaticFiles():
protected override Component? HeadAssets => Link.Rel("stylesheet").Href("/css/app.css");
The app's own compiled sheet is the exception: the server answers css/app.css under the prefix as well, so
AppStylesheet (HeadAssets => [AppStylesheet]) links /new/css/app.css?v={hash}, the link RaskApp writes.
To serve wwwroot under the prefix instead, the host calls app.UseStaticFiles(new StaticFileOptions { RequestPath = "/new" })
BEFORE an explicit app.UseRouting(): once routing has matched Rask's catch-all, static files step aside.
Each answers only its own paths — a route the old front end owns still gets its index.html, a page
under /new is rendered by Rask, and the Rask runtime is served under the prefix and nowhere else. The
old front end links to a moved page with an ordinary <a href="/new/…">, a full page load.
Two things the move runs into first:
- Sign-in. A Rask page is rendered from the request, so it sees who is signed in only when the
browser sends that with a navigation — a cookie. A front end that keeps its token in
localStoragerenders every Rask page signed out until sign-in also sets one. See authentication. - Per-request services. After the first response a Rask page works over its connection, where there
is no
HttpContext. A service that reads the tenant or the user fromIHttpContextAccessorhas to get it some other way — from the signed-in principal, for instance.
A component of the old front end can also move as it is, as an island inside a Rask page.
See also
- Islands — a front-end component inside a Rask page.
- Deployment — a Rask WebAssembly client behind the same call.
- Build errors —
RASKSPA001–009.