Getting started with Rask
Rask is the .NET One Person Framework — one developer builds, runs, and ships a whole product solo, in
C#, on one server (read the doctrine). It starts with the UI: you build it 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→ a plain delegate). 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. 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
wasmtemplate — and--wasmon a server app — 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 10 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 templates:
--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 net10.0-browser project that publishes to a static wwwroot/ you can host anywhere (GitHub Pages, S3, nginx). Bring your own API. |
They emit the same starter pages, so the rest of this guide applies whichever 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,
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 in Program.cs rather than on the command line — a server app
starts with English registered there, and adding another is a line in the same block. 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
dotnet run # server / wasm
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. Run
rask devinstead ofdotnet runfor a live inner loop: 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
HomePage(),Counter(), orNavLink(...)as undefined — they're generated methods 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 setup, and mostly notable for what is not in it:var app = RaskApp.Create(args); app.Run<App>();RaskApp.Createbuilds the host and turns on every battery — the database, mediator, background jobs, transactional email, cache, outbox, operator dashboard, durable logs, Web Push, snapshots and continuous backup.app.Run<App>()mounts your root component as the whole site and applies the middleware order: forwarded headers, the health endpoint, HSTS and HTTPS redirection, static assets, authentication, your own endpoints, then Rask's catch-all.Your own services go here too, on
app.Services. What an app writes in this file is the exceptions — the batteries it does without, and anything configured differently:app.Configure(c => { c.Jobs.Off(); // this app has no background work c.Mail.Configure(o => o.From = "no-reply@example.com"); });Nothing has to be turned off in order to configure it: you can also call a battery's own
AddRaskXdirectly and yours wins. And to map your own endpoints, useapp.MapEndpoints(e => …)— a named place for them rather than an ordering rule, since routing matches on precedence and any route you write is more specific than Rask's catch-all.App.cs— two things live here. First, the root componentApp: it renders straight into<body>— Rask builds the document around it — and drops aRouter()where the current page appears.<head>is framework-managed — app-wide tags (title, charset, viewport) go through itsHeadoverride, not by passing children toHead()(more in section 7). Second, theHomePagecomponent — 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).
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 plain delegates. 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 plain delegate property (Action<int>?, Func<Task>?, …), and the chain step that
sets it wraps it so invoking it re-renders the parent that owns the lambda. There is no
EventCallback type, and the child stays oblivious to the parent:
public sealed partial class RatingStars : Component
{
public int Value { get; set; }
public Action<int>? OnRate { get; set; } // a plain delegate prop
protected override Component? Render() =>
Div[
Enumerable.Range(1, 5).Select(i => (Component)Button.OnClick(() => OnRate?.Invoke(i))// child invokes; parent re-renders
.Key(i)[i <= Value ? "★" : "☆"])
];
}
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 factory parameter. Nullable property → optional with default null. Property with an initializer → excluded from the factory.
namespace Rask.Site.Features;
// A component is a class that subclasses Component and overrides Render.
// The Rask source generator emits a Generated.Greeting(...) factory whose
// parameters are derived from the public settable properties:
// • Name — non-nullable, no initializer → required factory parameter.
// • Title — nullable → optional, defaults to null.
public sealed partial class Greeting : Component
{
public required string Name { get; set; }
public new string? Title { get; set; }
protected override Component? Render() =>
P.Class("mb-0")[
Title is null ? "" : $"{Title} ",
"Hello, ", Strong[Name], "!"
];
}
// 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, Navigator, 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/Navigator/RouteState) through the constructor, never as a public settable property — that would become a required factory parameter, and `required` on a property with a DI-only constructor is RASK002. Constructor params resolve from DI via ActivatorUtilities; only public settable properties feed the generated factory.
using System.Net.Http.Json;
using System.Text.Json.Serialization;
namespace Rask.Site.Features;
// Inject services like HttpClient/Navigator/RouteState through the primary
// constructor — never as a public settable property. A non-nullable settable
// property would become a *required* factory parameter the caller has to pass,
// and the `required` keyword on a property + a DI-only constructor (no
// parameterless ctor) is the RASK002 warning.
public sealed partial class WeatherCard(HttpClient http) : Component
{
private Forecast? _forecast;
// Only the public settable properties feed the generated factory, so the
// call site is Generated.WeatherCard(City: "Helsinki") — `http` resolves
// from DI via ActivatorUtilities, invisible to the caller. City is a
// non-nullable, no-initializer property, so the generator emits it as a
// *required* factory parameter (RASK001) — note there's no `required`
// keyword: that keyword plus a DI-only constructor (no parameterless ctor)
// would be RASK002, since ActivatorUtilities can't satisfy `required`
// members. Rask assigns City
// after construction, which the CS8618 suppression acknowledges.
#pragma warning disable CS8618
public string City { get; set; }
#pragma warning restore CS8618
protected override async Task OnMountAsync() =>
_forecast = await http.GetFromJsonAsync(
$"data/weather-{City.ToLowerInvariant()}.json",
WeatherJsonContext.Default.Forecast,
CancellationToken);
protected override Component? Render() =>
_forecast is null
? P[Em["Loading…"]]
: Article[
H3[City],
P[$"{_forecast.Summary}, {_forecast.TemperatureC} °C"]
];
public sealed record Forecast(
[property: JsonPropertyName("summary")] string Summary,
[property: JsonPropertyName("temperatureC")] int TemperatureC);
}
// Call site is unchanged — ActivatorUtilities resolves `http`:
public sealed partial class ComponentsDiDemo : Component
{
protected override Component? Render() => WeatherCard.City("Helsinki");
}
[JsonSerializable(typeof(WeatherCard.Forecast))]
internal sealed partial class WeatherJsonContext : JsonSerializerContext;
[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 removing it from the generated factory signature. The counter below started at 7 — click it and the state persists across re-renders.
namespace Rask.Site.Features;
public sealed partial class SkipFactoryCounter : Component
{
private int _count;
// [SkipFactory] excludes this property from the generated factory.
// The initializer seeds the cached instance — the factory call site
// doesn't have to (and can't) pass Initial through.
[SkipFactory] public int Initial { get; set; } = 7;
protected override void OnMount() => _count = Initial;
protected override Component? Render() =>
Button.Type("button").Class(Tw.BtnOutlinePrimary).Id("skipfactory-counter").OnClick(() => _count++)[UiIcon.Name(UiIconName.Cursor).Class("me-2"), $"Clicks: {_count}"];
}
// 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 Head 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 Head override (plus 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 head
contributions and a Router():
public sealed partial class App : Component
{
// App-level head; pages can override their own Head to set a per-page Title.
protected override Component? Head => [
Title["My Rask App"],
Meta.Charset("utf-8"),
Meta.Name("viewport").Content("width=device-width, initial-scale=1")
];
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-body-tertiary";
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("en", 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. 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 Head.
Any component can contribute to <head> while it's in the tree by overriding Head. <title> and
<base> are singleton tags — the last contributor wins, so a page's Title overrides the app fallback:
protected override Component? Head => Title["Welcome — My Rask App"];
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: thelangonHtml(...)becomesHtmlLang, theClassonBody(...)becomesBodyClass, theHead()slot just goes away (your head contributions were already in theHeadoverride), 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 is the one namespace
you bring in explicitly). [RouteParam] and [QueryParam] bind URL pieces to properties, and every
route gets a generated, type-safe URL builder:
using Rask.Core.Routing;
[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(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, inject the Navigator service through the constructor and call
nav.NavigateTo(HomePage()), nav.SetQuery("tab", "settings"), and so on. 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
HomePage(),Counter(), orNavLink(...)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 10 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 One Person Framework path 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 writes a full CQRS + EF Core CRUD vertical
slice (entity, value objects, validation, list/create/edit pages — and, with
--tests, a test project) in one command, wiring the DI intoProgram.csfor you. - 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 → a
--dockertemplate emits a production Dockerfile; deploy the whole app to one box.
Read the doctrine for the why. Reference guides for the next thing you need:
- Build a form → forms —
Form<T>,Input(() => model.X), validation. - Add more routes / layouts → routing — nested layouts, route/query params,
Navigator. - Load or save data → data access — EF Core + SQLite in a Server app.
- Run code on mount / after render → lifecycle —
OnMount*/OnRendered*, async hooks. - 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?").