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.
Rask requires the .NET 10 SDK. Confirm you have it:
dotnet --version # must be ≥ 10.0If that prints an older version (or errors), install the .NET 10 SDK from dotnet.microsoft.com first.
WASM only: the two WebAssembly templates (
wasm,wasm-hosted) 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.
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.
dotnet tool install -g Rask.Cli # one-time: install the rask CLI
rask new MyApp # create a server app in ./MyApp (server is the default)rask new ships four templates, one per host model:
--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. |
wasm-hosted |
Three projects: MyApp.Client (the WASM SPA), MyApp.Server (the ASP.NET host that serves the bundle and your own /api/... endpoints), and MyApp.Shared (a class library both reference). |
native |
A native iOS/Android app head (WebView hybrid, preview) running the same components — see native. Needs the ios android workloads. |
They emit the same starter pages, so the rest of this guide applies whichever you chose. Each also
accepts a --auth switch that scaffolds a working login flow — see authentication
when you need it. The server template additionally accepts --cqrs, which scaffolds the
Rask.Cqrs mediator (a sample query + handler and a /greeting page that dispatches it),
--pwa, which makes it an installable PWA, and --docker — a flag, not a template, which adds
a Dockerfile for rask deploy. The full flag list is in the CLI reference.
cd MyApp
dotnet run # server / wasm
# wasm-hosted: run the host project — dotnet run --project MyApp.ServerOpen 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/.js), 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.)
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.builder.Services.AddRask()registers the framework,app.UseRask<App>()mounts your root component (App) as the whole site, and a/healthendpoint is wired for deployment probes. Your own services go here too. -
App.cs— two things live here. First, the root componentApp: the one place that renders the full HTML page shell (Doctype/Html/Head/Body) 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 .js 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.
Every component is a sealed class : Component. Override Render() and return a tree of HTML built
from generated factory methods (Div(), H1(), P(), …). 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 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.
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 class Counter : Component
{
private int _count;
protected override Component? Render() =>
[
H1()["Counter"],
P()[$"Current count: {_count}"],
Button(OnClick: () => _count++)["Click me"]
];
}A child declares a plain delegate property (Action<int>?, Func<Task>?, …), and the generated factory
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 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 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"]
];
}You never write a factory method by hand. For each concrete Component, the generator emits a
{Type}(...) factory — that's why HomePage(), Counter(), and your own Greeting() are callable.
Its parameters are derived from your public settable properties:
| Property shape | In the factory |
|---|---|
| Non-nullable, no initializer | required parameter |
Nullable (T? / Nullable<T>), no initializer |
optional, defaults to null |
Has an initializer (= ...) |
excluded — your default wins |
[SkipFactory] (property or class) |
excluded |
Children |
always excluded (children attach via the indexer) |
public sealed class Card : Component
{
public required string Title { get; set; } // required factory param
public string? Subtitle { get; set; } // optional, default null
public int Elevation { get; set; } = 1; // excluded — your default wins
[SkipFactory] public int Internal { get; set; }// excluded explicitly
// → generated: Card(string Title, string? Subtitle = null, ...)
}Live — a Greeting with a required Name and an optional Title, called through its generated
Greeting(Name: "Ada", …) factory:
Inject framework services (HttpClient, Navigator, RouteState, IJSRuntime) through the
constructor, not as properties — a non-nullable settable property would become a required factory
parameter (and required on a property with a DI-only constructor is the RASK002 warning). Inject
through the primary constructor instead:
public sealed class Weather(IWeatherForecastService service) : Component { ... }[SkipFactory] keeps a property settable in code but out of the factory signature — 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:
Your root component (the TApp you pass to the host — App in the template) must render the full
HTML shell: Doctype, Html, Head, Body. Both <head> and <body> are framework-managed — the
runtime <script> is auto-appended to <body>, and <head> is filled from every mounted component's
Head override.
public sealed 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("utf-8"),
Meta(Name: "viewport", Content: "width=device-width, initial-scale=1")
];
protected override Component? Render() =>
[
Doctype(),
Html("en")[
Head(), // framework-managed slot — do NOT pass children
Body()[
Router()
]
]
];
}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 doesn't render a complete shell, and RASK019 if you pass children to
Head()instead of using the override.
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 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(UserPage(id: 42))["View user"];The Router() in your shell 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.
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 factory 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/.jsfile 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.
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:
- Scaffold a feature →
rask generate featureemits 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(Bind: ...), 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/JWT/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?").