Getting started with Rask
Rask is the full-stack .NET web framework — UI, data, auth, background work, realtime and deploy, all
in C#, for a team of one or fifty (read the philosophy). This guide starts with
the UI; the tutorial adds the rest of the stack. You build the UI as plain C#
classes — no .razor, no JSX, no JavaScript to write. A component is a class that returns a tree
of HTML from Render(), and the same component runs either server-rendered (live updates over a
WebSocket) or fully client-side in the browser on WebAssembly.
This is a zero-to-running guide for someone new to Rask. By the end you'll have an app on screen, you'll understand the files the template gave you, and you'll have written your own component, an event handler that updates the UI, and a route. It assumes you're comfortable with C# — we explain the Rask-specific ideas, not the language.
Coming from Blazor? Skim migrating from Blazor for the concept mapping (
@page→[Route],[Parameter]→ a property,EventCallback→Callback). Just want to look first? Click through the live demo — a full multi-page Rask app, no install needed.
Before you start
Rask requires the .NET 10 SDK — every package ships for .NET 10, the LTS release. Confirm you have it:
dotnet --version # must be ≥ 10.0
If that prints an older version (or errors), install the .NET 10 SDK from dotnet.microsoft.com first.
WASM only: the
wasmandwasm-hostedtemplates also need the browser WebAssembly tooling — install it once withdotnet workload install wasm-tools. If you're starting with the server template (recommended below), you can skip this.
1. Scaffold a project
Scaffolding is done by the rask CLI (Rask.Cli, a global .NET tool). Not sure which host
to pick? Choose the default server template — it's a single ASP.NET project that runs with no extra
setup, and the components you write are identical across hosts, so nothing you learn here is wasted if
you switch later.
curl -sSL https://rask.sh/rask.sh | sh # one-time: the rask CLI + what it needs
rask new MyApp # create a server app in ./MyApp (server is the default)
The installer adds the .NET SDK if you don't have one, plus dotnet-ef, the wasm-tools
workload and Node — all under $HOME, no sudo. With the SDK already in place,
dotnet tool install -g Rask.Cli is enough. See Installing Rask.
rask new ships three C# templates and a front-end template per framework:
--template |
What you get |
|---|---|
server (default) |
One ASP.NET project. Components render on the server; live updates ship over a WebSocket. Best default. |
wasm |
One browser-WASM project that publishes to a static wwwroot/ you can host anywhere (GitHub Pages, S3, nginx). Bring your own API. |
wasm-hosted |
A WebAssembly app in Client/, served by its own ASP.NET host — the browser runs the UI, the server keeps the database, the handlers and every battery. |
react, preact, vue, angular, solid, svelte, lit |
That framework's TypeScript app in client/, on the same kind of host, calling your C# messages through a generated typed client — see single-page app front ends. |
The first three emit the same starter pages in C#, so the rest of this guide applies whichever of them you chose.
Each arrives with every battery it can carry. On server that is a SQLite database, the
Rask.Cqrs mediator, background jobs, transactional email, a cache, a transactional outbox,
file storage for uploads, scheduled backups, a durable log store, the operator dashboard, an installable
PWA with Web Push, a Dockerfile for rask deploy, and the localization machinery —
wiring, not sample pages. wasm takes the PWA and the Dockerfile; the rest need a host to put a
database in. Languages are configured rather than chosen on the command line — a server app starts
with English in appsettings.json (Rask:Cultures), and adding another is an entry in
that list. A browser-WASM
app registers none, because a language there means shipping ICU: roughly a megabyte of extra download
that an app formatting nothing culture-sensitive should not pay by default. See
localization.
Almost nothing is left to you. Sign-in comes with the app — register, sign in and sign out work out of
the box, and the first account you create is the administrator (see authentication).
Styling is not a flag either: every project is Tailwind. To leave a battery out, name it:
rask new MyApp --no-push --no-ops. The full flag list is in the CLI reference.
2. Run it
cd MyApp
rask dev
Open the URL printed in the console. You should see a single "Hello, Rask! 👋" welcome card that
lists the rask commands you'll use next. The starter app is deliberately minimal — a clean shell with one
page — so there's nothing to delete before you start building.
Edit-and-refresh with hot reload.
rask devis the live inner loop (dotnet runstarts the app without it): edit a component'sRender()(or its scoped.css/.ts), a[Route]template, or a CQRS handler and save — C# Hot Reload applies the change to the running app and Rask re-renders the open session in place, no manual rebuild or browser refresh. A small "Hot reload applied" pill confirms it landed. Edits the runtime can't apply (adding a type, changing a signature) restart the app instead, and the page reloads itself. The full list is in what hot-reloads.
First build is slower, and the IDE may look broken — that's expected. The first build is when Rask's source generators run. Until then your IDE may flag
HomePageorRoutes.HomePage()as undefined — they're generated members that don't exist until you build. Build once, then reload the solution so IntelliSense picks them up. (More on this in Troubleshooting below.)
3. Tour of what the template generated
Before writing code, here's what's in the project and why. The server template is small on purpose (the
WASM templates differ mainly in Program.cs):
Program.cs— the host, in one line:using MyApp.Features.Shared; RaskApp.Create(args).Run<App>();RaskAppis every battery the template supports, on by default — the database, mediator, accounts, background jobs, transactional email, cache, outbox, file storage, operator dashboard, durable logs, Web Push, snapshots and continuous backup (inert untilRask:Litestream:ReplicaUrlis set) — plus the host work every app repeats: health checks at/health, the exception handler and HSTS outside Development, HTTPS redirection, static assets, authentication,Appmounted as the whole site, and the document around it — charset, viewport, the UI kit's stylesheet and theme, yourStyles/app.css— soApp.csis a title andRender() => Router. An app that does without a battery says so here, and a scaffold made with--no-<battery>(rask new) has that line written for it:var app = RaskApp.Create(args); app.Configure(c => c.Jobs.Off()); app.Run<App>();Your own services go on
app.Servicesand your own endpoints inapp.MapEndpoints(e => …), betweenCreateandRun.Settings are not code. A From address, a connection string, a session cap — every Rask setting lives in
appsettings.jsonunderRask, each battery reading its own section (mail readsRask:Mail), and an environment variable overrides any of them with double underscores (Rask__Mail__From). A callback —app.Configure(c => c.Mail.Configure(o => …))— runs after configuration and wins over both, for the rare value that has to be code. The scaffoldedappsettings.jsonstarts on development defaults — a localapp.dbatRask:ConnectionStrings:App— that every one of those sources overrides. See Configuration.No database context to write.
RaskAppDbContextmaps every aggregate you declare plus every battery's tables; code that needs the context itself injectsIDbContextFactory<RaskAppDbContext>(Rask.Data).Features/Shared/App.cs— the root componentApp: it renders straight into<body>— Rask builds the document around it — and drops aRouterwhere the current page appears.<head>is framework-managed — Rask writes the charset, viewport and stylesheets, and the tab's title — the page'sPageTitle, then the app's name — is its oneHeadAssetsline (more in section 7). Beside it,Features/Shared/User.csis the app's account type.Features/Home/HomePage.cs— the/route, a small welcome card. Edit or replace it; it's your starting point.{Project}.csprojandProperties/launchSettings.json— the project file (framework package references, source generators) and the local run profile (URLs, environment).{Project}.Tests/— a test project with one passing test,Home_page_greets_the_visitor, which rendersHomePagein-process withRask.Testingand checks its greeting.dotnet testruns it;{Project}.slnxlists both projects.rask new --no-testsleaves it out.
That's the whole starter app — no example Counter or Weather pages to clean up. You'll add your own
screens next; a scoped .css or .ts file is as easy as dropping {Component}.css next to a
{Component}.cs (same folder, same base name) — its selectors apply only to that component, no leaks.
4. Your first component
Every component is a sealed partial class : Component. Override Render() and return a tree of HTML
written as a chain: name a component and dot onto it — Div.Class("greeting"). The name is the
component, so pressing . lists everything it has, each step carrying its own doc comment. A tag you set
nothing on needs no parentheses at all (H1["Hi"]). Children attach through an indexer on every
component — Div[ ... ] — and strings, other components, and value types all convert to a child node
automatically:
public sealed partial class Greeting : Component
{
protected override Component? Render() =>
Div.Class("greeting")[
H1["Hello, world!"],
P["Welcome to your new Rask app — ", Strong["it's all C#"], "."],
Span[42] // value types convert too — no .ToString()
];
}
Render() returns Component?, which accepts three shapes — you'll mostly use the first two:
- a single node —
Render() => Div[...]; - a collection expression for several top-level nodes with no wrapper —
Render() => [H1["Title"], P["Body"]]; null— render nothing.
Safe by default — good to know, not needed yet. Two security defaults are worth knowing about but won't get in your way:
- Strings are HTML-encoded. A plain string becomes a
Textnode, soP["<b>hi</b>"]shows the angle brackets as text. When you genuinely need verbatim markup, useRaw("<b>hi</b>").- URL attributes are scheme-sanitized.
href/src/etc. neutralize dangerous schemes (javascript:→about:blank) so a user-supplied URL can't run script on click. For a URL you fully control, opt out per-call withRaskUrl.Trusted(...).See best practices for the full security picture.
5. Add interactivity
Keep local state in fields and wire event handlers as lambdas. After the handler runs, the
component that owns it re-renders automatically — you never call StateHasChanged() by hand for a
local update. A click does a server round-trip (server host) or a local re-render (WASM host); the same
code works for both.
[Route("/counter")]
public sealed partial class Counter : Component
{
private int _count;
protected override Component? Render() =>
[
H1["Counter"],
P[$"Current count: {_count}"],
Button.OnClick(() => _count++)["Click me"]
];
}
Going further: child → parent communication
A child declares a Callback<int> property (Callback when the event carries nothing), and the chain step
that sets it wraps the handler so invoking it re-renders the parent that owns the lambda. The caller
hands it a sync or an async handler through the same step, an unset one does nothing when invoked, and the
child stays oblivious to the parent:
public sealed partial class RatingStars : Component
{
public int Value { get; set; }
public Callback<int> OnRate { get; set; } // an event: optional, never a required step
protected override Component? Render() =>
Div[
Enumerable.Range(1, 5).Select(i => Button.Key(i)
.OnClick(() => OnRate.Invoke(i))[i <= Value ? "★" : "☆"]) // child invokes; parent re-renders
];
}
public sealed partial class RatingDemo : Component
{
private int _rating;
protected override Component? Render() =>
[
RatingStars.Value(_rating).OnRate(n => _rating = n), // lambda captures this
P[_rating == 0 ? "Click a star." : $"You rated: {_rating}/5"]
];
}
6. Why HomePage already chains (the generated surface)
You never write a builder by hand. For each concrete Component, the generator emits a chain entry
and a step per public settable property — that's why HomePage, Counter, and your own Greeting can
be named and dotted onto. Which shape a property takes is derived from its declaration:
| Property shape | In the chain |
|---|---|
| Non-nullable, no initializer | a step — required before the component exists |
Nullable (T? / Nullable<T>), no initializer |
an optional setter |
Has an initializer (= ...) |
an optional setter — your default wins |
[SkipFactory] (property or class) |
excluded — no step, no setter |
Children |
always excluded (children attach via the indexer) |
public sealed partial class Card : Component
{
public required string Title { get; set; } // a step: Card.Title("…") opens the chain
public string? Subtitle { get; set; } // an optional setter
public int Elevation { get; set; } = 1; // an optional setter — your default wins
[SkipFactory] public int Internal { get; set; }// excluded explicitly
// → Card.Title("Pricing").Subtitle("per seat").Elevation(2)
}
The steps come first and in any order; miss one and there is no component to render, so the mistake is a
compile error where you made it rather than a null at runtime. The class must be partial — that is
where the generator puts the surface.
Live — a Greeting with a required Name and an optional Title, built with
Greeting.Name("Ada")…:
Non-nullable property without an initializer → required chain step. Nullable property → optional step, null when unset. Property with a constant initializer → optional step defaulting to it.
namespace Rask.Site.Features;
// Call site: invoke the generated factory by its bare name — it is globally
// visible through an auto-generated `global using static`, no using needed.
public sealed partial class ComponentsGreetingDemo : Component
{
protected override Component? Render() => Greeting.Name("Ada").Title("Dr.");
}Dr. Hello, Ada!
Inject framework services (HttpClient, RouteState, IJSRuntime) through the
constructor, not as properties — a non-nullable settable property would become a required step
(and required on a property with a DI-only constructor is the RASK002 warning). Inject
through the primary constructor instead:
public sealed partial class Weather(IWeatherForecastService service) : Component { ... }
Inject services (HttpClient/RouteState) through the constructor, never as a public settable property — that would become a required chain step. Constructor params resolve from DI; only public settable properties become chain steps.
namespace Rask.Site.Features;
// Call site is unchanged — ActivatorUtilities resolves `http`:
public sealed partial class ComponentsDiDemo : Component
{
protected override Component? Render() => WeatherCard.City("Helsinki");
}[SkipFactory] keeps a property settable in code but off the chain — useful for seeding
cached internal state the caller shouldn't pass. The counter below starts at 7 (its Initial is
[SkipFactory], seeded in OnMount) and keeps its state across re-renders like any private field:
[SkipFactory] keeps a property settable in code while leaving it out of the generated chain. The counter below started at 7 — click it and the state persists across re-renders.
namespace Rask.Site.Features;
// The generated factory has NO Initial parameter — the call site stays clean.
// Framework caches the instance by tree position, so _count survives
// re-renders just like any other private state. The counter starts at 7.
public sealed partial class ComponentsSkipFactoryDemo : Component
{
protected override Component? Render() => SkipFactoryCounter;
}7. The document and the HeadAssets override
Your root component (the TApp you pass to the host — App in the template) renders straight into
<body>. Rask composes the document around it: the doctype, <html>, a <head> filled from every
mounted component's HeadAssets override (plus the charset, viewport, the UI kit's stylesheet, your
Styles/app.css, and the scoped CSS and JS the page needs), and a <body> holding what the root
rendered and the auto-appended runtime <script>. So a root is just its title and a Router:
public sealed partial class App(RouteState route) : Component
{
// The tab's title: what the current page calls itself, then the app's name.
protected override Component? HeadAssets => Title[route.Title is { } page ? $"{page} | My Rask App" : "My Rask App"];
protected override Component? Render() => Router;
}
The two attributes an app usually wants on the shell are overrides of their own, read off the root:
HtmlLang — the lang on <html>, "en" by default, null to omit it — and BodyClass, the
class on <body>, null by default:
protected override string? HtmlLang => "fr";
protected override string? BodyClass => "bg-base-200";
Anything those two can't express — another attribute on <html>, an element wrapped around the app —
is a Shell override. It receives the framework's <head> and the app's rendered body as
parameters, so place both: drop head and the page loses every head asset.
protected override Component Shell(Component head, Component body) =>
Html.Lang("en").Dir(Dir.Rtl)[head, Body.Class("dark")[body]];
The doctype is still emitted ahead of whatever Shell returns, and the runtime <script> still lands
in <body> — neither is yours to add. The body parameter is the app's content, not the element: a shell
that writes Html[head, body] gets a <body> put around everything after the head, and a warning naming
the override, because without one the runtime and the session id have nowhere to go and the page never connects. Shell is evaluated once per render, before your Render()
runs, so it can't observe state that render produces; keep anything reactive in the body or in HeadAssets.
A page says what it is called, and the root's Title line above reads it — the same route.Title a layout
puts in its breadcrumb (a page's title):
protected override string? PageTitle => "Welcome";
Any component can contribute to <head> while it's in the tree by overriding HeadAssets — a <meta>, a
stylesheet, a preload. <title> and <base> are singleton tags, and the last contributor wins.
Guardrails: two compile-time checks catch the common mistakes (full list in diagnostics) — RASK021 if the root renders the shell itself, and RASK019 if you pass children to
Head[…]instead of using the override.
Already have an app? Delete the shell from your root's
Render()and return what was inside<body>(usually justRouter). Its pieces move to the overrides that own them: thelangonHtmlbecomesHtmlLang, theClassonBodybecomesBodyClass, theHead[…]slot just goes away (your head contributions belong in theHeadAssetsoverride), and anything left over becomes aShelloverride.Doctype,Html,Head, andBodyare still ordinary tag components — they're what you build a document out of by hand (ToHtml(), an email body), just not the app's page.
8. Add a route
Put [Route("/path")] on a component to register it as a page (Rask.Core.Routing comes in through the scaffold's
GlobalUsings.cs). [RouteParam] and [QueryParam] bind URL pieces to properties, and every
route gets a generated, type-safe URL builder:
[Route("/users/{id}")]
public sealed partial class UserPage : Component
{
[RouteParam] public int Id { get; set; }
[QueryParam] public string? Tab { get; set; }
protected override Component? Render() =>
Span[$"User #{Id} — {Tab ?? "overview"}"];
}
// elsewhere — type-safe, refactor-proof:
NavLink.Href(Routes.UserPage(Id: 42))["View user"];
The Router in your root component matches the current path and renders the page. To navigate from
an event handler, go to the route itself — Routes.HomePage().Go(), nothing injected — or change this page's
query with Go.With("tab", "settings"). For nested layouts
([ParentRoute] + Outlet), 404 pages ([NotFound]), and the full routing model, see
routing.
Troubleshooting
The snags you're most likely to hit on a fresh project:
The IDE flags
HomePageorRoutes.HomePage()as undefined. These are source-generated — the chain surface for every component, the URL builder for every[Route]. They don't exist until the generator runs, which happens on build. Rundotnet buildonce, then reload the solution / restart the language server.net10.0/net10.0-browserwon't restore, or a WASM publish fails. You're missing the .NET SDK (dotnet --versionmust be ≥10.0) or, for WASM, the workload — install it withdotnet workload install wasm-tools.A scoped
.css/.tsfile isn't taking effect. The sibling file must sit in the same folder as its component and share the base name (Card.cs↔Card.css). A mismatch is a build error (RASK015–RASK018) — check the build output.Blank page or 404s on
/_rask/...assets behind a reverse proxy or sub-path. The app is running under a URL prefix the framework doesn't know about — setPathBase(configuration), and build any hand-written asset URL asLiveOptions.PathBase + "/…"(JS interop). For a WASM bundle published under a prefix (GitHub Pages project sites), publish with-p:RaskPathBase=/my-repo— see Deploying to a sub-path.
Next steps
You now have a running, routed, interactive app. From here, the rest of the stack takes it to a shipped product — and the zero-to-deploy tutorial walks that whole path step by step (database, auth, jobs, email, cache, events, and deployment). In short:
- Build a feature → tutorial chapter 2 builds a database-backed
Products catalog by hand: declare the entity, read it off its face (
Product.Where(…)), write through commands whose handlers save with EF Core, build list, create, edit and delete pages, thenrask db add— the app applies the migration itself when it starts. The database is a battery like the rest, so there is nothing to register. - Make SQLite production-ready → Why one server, no PaaS — WAL, busy-timeout, and continuous backup so one SQLite file is your production database.
- Ship to one server → the scaffold already carries a production Dockerfile;
rask deployputs the whole app on one box.
Read the doctrine for the why. Reference guides for the next thing you need:
- Build a form → forms —
Form.Model(m),Input.Bind(() => model.X), validation. - Add more routes / layouts → routing — nested layouts, route/query params,
Go. - Load or save data → Rask.Data — declare a model, read and write it off the type; or
plain EF Core with a
DbContextof your own. - Run code on mount / after render → lifecycle —
OnMount,OnUpdated,OnFirstRender,OnRendered,OnUnmount. - Share state without prop-drilling → composition — context, callbacks,
VirtualizeModel. - Add a login → authentication — cookie sessions and OIDC on Server and WASM.
- Test your components → testing — unit-testing components and rendered HTML.
- Write idiomatic Rask → best practices — patterns and pitfalls that keep an app correct, secure, and fast.
- Decode a build error → diagnostics — every RASK0xx analyzer ID and its fix.
Keep handy while you build: the cheat sheet (every command + wiring line on one page) and the recipes (task-first "how do I do X?").