Tutorial: a full-stack app
Build a todo app with a minimal API and a React frontend in one project. Along the way you will use Fast Refresh, C# hot reload that reaches the page, client-side routing, CSS modules, environment variables and a production publish.
You need NiteFx installed (Getting started). The whole tutorial takes about half an hour.
1. Create the project
nite init todos
cd todos
nite watch
The browser opens on the template's page. Leave nite watch running for the rest of the tutorial; every step below is a save, not a restart.
2. Write the API
Replace Program.cs with an in-memory store and a few endpoints:
using System.Collections.Concurrent;
using Nite;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddNite();
builder.Services.AddSingleton<TodoStore>();
var app = builder.Build();
app.UseNite();
var api = app.MapGroup("/api");
api.MapGet("/todos", (TodoStore store) => store.All());
api.MapPost("/todos", (NewTodo input, TodoStore store) =>
string.IsNullOrWhiteSpace(input.Title)
? Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["A title is required."] })
: Results.Created("/api/todos", store.Add(input.Title.Trim())));
api.MapPatch("/todos/{id:int}", (int id, TodoPatch patch, TodoStore store) =>
store.SetDone(id, patch.Done) is { } todo ? Results.Ok(todo) : Results.NotFound());
app.MapStaticAssets();
app.MapNiteSpa();
app.Run();
record Todo(int Id, string Title, bool Done);
record NewTodo(string Title);
record TodoPatch(bool Done);
class TodoStore
{
private readonly ConcurrentDictionary<int, Todo> _todos = new();
private int _nextId;
public TodoStore()
{
Add("Write the API in C#");
Add("Write the UI in React");
}
public IReadOnlyList<Todo> All() => _todos.Values.OrderBy(t => t.Id).ToList();
public Todo Add(string title)
{
var todo = new Todo(Interlocked.Increment(ref _nextId), title, false);
_todos[todo.Id] = todo;
return todo;
}
public Todo? SetDone(int id, bool done) =>
_todos.TryGetValue(id, out var todo) ? _todos[id] = todo with { Done = done } : null;
}
New endpoints and a new service need a restart, so nite watch restarts the app. The page waits for the server to come back and reloads (about ten seconds). Check the API directly at /api/todos.
Note
app.MapNiteSpa() serves Client/index.html for every path without a file extension, except paths under /api. A mistyped API URL gets a 404, not a page of HTML.
3. A typed API client
Create Client/api.ts:
// Same origin as the ASP.NET Core app: no CORS, no proxy, no base URL.
export type Todo = { id: number; title: string; done: boolean };
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!response.ok) throw new Error(`${init?.method ?? "GET"} ${url}: ${response.status}`);
return response.json() as Promise<T>;
}
export const api = {
todos: () => request<Todo[]>("/api/todos"),
add: (title: string) => request<Todo>("/api/todos", { method: "POST", body: JSON.stringify({ title }) }),
setDone: (id: number, done: boolean) =>
request<Todo>(`/api/todos/${id}`, { method: "PATCH", body: JSON.stringify({ done }) }),
};
/** Runs `callback` when C# hot reload changes the server. Returns the unsubscribe function. */
export function onServerUpdate(callback: () => void) {
addEventListener("nite:server-update", callback);
return () => removeEventListener("nite:server-update", callback);
}
Nothing reloads when you save this file: no module on the page imports it yet.
4. The todo list
Create Client/TodoList.tsx:
import { useEffect, useState, type FormEvent } from "react";
import { api, onServerUpdate, type Todo } from "./api";
export function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [draft, setDraft] = useState("");
useEffect(() => {
const load = () => api.todos().then(setTodos);
load();
return onServerUpdate(load);
}, []);
async function add(event: FormEvent) {
event.preventDefault();
if (!draft.trim()) return;
const todo = await api.add(draft);
setTodos((current) => [...current, todo]);
setDraft("");
}
async function toggle(todo: Todo) {
const updated = await api.setDone(todo.id, !todo.done);
setTodos((current) => current.map((t) => (t.id === updated.id ? updated : t)));
}
return (
<section>
<h1>Todos</h1>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input type="checkbox" checked={todo.done} onChange={() => toggle(todo)} /> {todo.title}
</label>
</li>
))}
</ul>
<form onSubmit={add}>
<input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="What needs doing?" />
<button>Add</button>
</form>
</section>
);
}
And render it from Client/App.tsx:
import { TodoList } from "./TodoList";
export function App() {
return (
<main>
<TodoList />
</main>
);
}
The page updates without a reload.
5. Feel Fast Refresh
Type something into the "What needs doing?" box, but do not submit it. Now edit TodoList.tsx: change <h1>Todos</h1> to <h1>Things to do</h1> and save.
The heading changes in about 150 ms, and your half-typed text is still there. NiteFx found that TodoList.tsx exports only components, re-imported just that module, and let React swap the new function in while keeping its state. Edits to api.ts work too: NiteFx walks up from the changed module to the components that import it and refreshes those.
What resets state, and why:
- Adding or removing a hook in a component remounts that component (its hook list changed, so old state cannot map onto it).
- Editing
main.tsxreloads the page: the entry module has no component boundary above it.
6. C# changes reach the page
Change the seed data in TodoStore's constructor, or the validation message, and save Program.cs. Hot reload applies the change in the running app and NiteFx sends the page a nite:server-update event; TodoList re-fetches through onServerUpdate, and the list updates with nothing reloaded.
Tip
The event never fires in production, so onServerUpdate costs nothing there. Use it in any component that shows server data.
7. Routing
Add React Router:
nite add react-router
The running dev server notices nite.json changed, pre-bundles the package and reloads the page. Wrap the app in a router in Client/main.tsx:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
Add an about page and routes in Client/App.tsx:
import { NavLink, Route, Routes } from "react-router";
import { TodoList } from "./TodoList";
function About() {
return <p>A todo app with an ASP.NET Core API and a React frontend, built with nite.</p>;
}
export function App() {
return (
<>
<nav>
<NavLink to="/" end>Todos</NavLink> · <NavLink to="/about">About</NavLink>
</nav>
<main>
<Routes>
<Route path="/" element={<TodoList />} />
<Route path="/about" element={<About />} />
</Routes>
</main>
</>
);
}
Open /about directly in a new tab: MapNiteSpa serves index.html for it, and the router takes over.
8. Scoped styles with a CSS module
Create Client/TodoList.module.css:
.list {
list-style: none;
padding: 0;
}
.done {
color: gray;
text-decoration: line-through;
}
Use it in TodoList.tsx:
import styles from "./TodoList.module.css";
// ...
<ul className={styles.list}>
{todos.map((todo) => (
<li key={todo.id} className={todo.done ? styles.done : undefined}>
Class names are scoped to the file, so .done here never collides with a .done anywhere else. Edit the CSS and save: the components using it refresh in place. Plain .css files, Sass, Less, Tailwind and PostCSS work the same way; see Stylesheets.
9. Configuration from the environment
Create .env in the project folder (next to nite.json):
NITE_PUBLIC_APP_NAME=Todos
DATABASE_PASSWORD=never-reaches-the-browser
Use it in TodoList.tsx:
<h1>{import.meta.env.NITE_PUBLIC_APP_NAME}</h1>
Only variables starting with NITE_PUBLIC_ reach client code; the password stays on the server. Editing .env reloads the page with the new values. See Environment variables.
10. Publish
Stop nite watch (Ctrl+C) and publish:
dotnet publish -c Release -o publish
cd publish
dotnet todos.dll
The Release build ran nite build: publish/wwwroot/_nite holds the minified, content-hashed bundles (with .br and .gz copies) and index.html pointing at them. MapNiteSpa serves that built page, and MapStaticAssets serves the bundles, compressed with Brotli for browsers that accept it. The published app has no node_modules, no esbuild and no dev server.
What you used
| Step | Feature | Read more |
|---|---|---|
| 2 | A restart for a structural C# change, handled by nite watch |
Hot reload |
| 4, 5 | Fast Refresh and the module graph | How NiteFx works |
| 6 | nite:server-update |
Browser runtime |
| 7 | nite add, MapNiteSpa fallback routing |
Packages, Single-page apps |
| 8 | CSS modules | Stylesheets |
| 9 | .env and envPrefix |
Environment variables |
| 10 | nite build and MapStaticAssets |
Deployment |