Build a todo app
Build a complete Inertia.Net todo app with session login, public lists, paginated loading, reminders and SSR.
ForgetMeNot is a small todo app, but it uses the same pieces as a real product: sign-in, private data, a public share page, a list that loads as you scroll, and reminders that run in the background. You will start from the official Inertia.Net.Templates template, then add each feature one small step at a time.
What you need
- .NET 10 SDK
- Bun 1.4 or newer
- A code editor
- A terminal
No database server is required. ForgetMeNot uses SQLite, and the template already gives you a working React app.
This guide is for someone who has not built an Inertia app before. Run the commands, look at the checkpoints, and only move on when the app behaves the way the checkpoint says.
What you are building
Sign in with sessions
Private todo lists
Public share pages
Virtual loading
Scroll and InfiniteScroll.Reminders
Pretty UI
1. Start from the template
Install the templates once, then create the app:
Start the two development processes. Keep both terminals open:
Open http://localhost:5180. You should see the template home page and a working Todos page. Click around before changing anything.
Checkpoint
If the app does not load, run dotnet new inertia --help and confirm the template version. The frontend must be started before dotnet watch reads the Vite hot file, but a refresh is enough if you started them in the other order.
Open these files. You do not need to understand all of them yet:
The template already has Inertia, Vite, a deferred prop, a form and validation. We are going to grow it into a full app.
2. Make it look good with shadcn/ui
The template uses a tiny inline-style layout. Replace it with Tailwind and shadcn/ui.
Install the packages:
Update vite.config.ts:
Add these two properties inside compilerOptions in tsconfig.json:
Create ClientApp/app.css and import it from ClientApp/app.tsx:
Now initialize shadcn/ui from the project directory:
Accept the defaults. When it asks for the CSS file, choose ClientApp/app.css; when it asks for the import alias, use @/. Then add the components we need:
Checkpoint
ClientApp/components/ui/ should now contain button.tsx, card.tsx, input.tsx and the other components. If the CLI asks a question you do not understand, choose the default; you can always rerun it.
Replace ClientApp/Layout.tsx with the shared shell:
And replace the template home page with a friendly landing page:
3. Give the app a clean structure
The template keeps everything in Program.cs. We will move the setup into small files so Program.cs stays readable. Create these folders:
The final Program.cs is only a few lines:
Each extension does one job:
| File | Responsibility |
|---|---|
Infrastructure/ForgetMeNotSetup.cs | The one AddForgetMeNot() and UseForgetMeNot() entry point |
Infrastructure/DataSetup.cs | EF Core and SQLite |
Infrastructure/SessionAuthSetup.cs | Session, antiforgery and password hashing |
Infrastructure/InertiaSetup.cs | Inertia options, shared props and SSR |
Infrastructure/SessionAuth.cs | Sign in/out helpers and the RequireSessionUser filter |
Endpoints/AuthEndpoints.cs | Register, login and logout |
Endpoints/TodoEndpoints.cs | Private lists and items |
Endpoints/PublicEndpoints.cs | The public share page |
A beginner can read Program.cs in one glance. That is the point.
The route table is also one readable file:
If a route is hard to find, start there.
4. Model the data
Add the entities:
PublicSlug is the secret part of a public URL. RemindedAt makes reminder delivery idempotent: if the worker restarts, it does not send the same reminder again.
The template uses source-generated JSON. Add every prop and form type to its AppJsonContext:
The data setup extension:
EnsureCreated() is perfect for a first app. For production, replace it with EF migrations.
5. Use sessions for sign-in
Sessions keep the signed-in user id and Inertia's flash/error state on the server. The browser only holds an HttpOnly session cookie.
The session helpers and the endpoint filter:
One instance for this sample
AddDistributedMemoryCache() keeps sessions in memory. That is fine for one app instance. For more than one replica, register a shared cache such as Redis, or the session will only exist on the instance that created it.
6. Hide the Inertia setup
InertiaSetup.cs is the only file a beginner needs to know when they want to change Inertia behavior.
Now the aggregate setup:
UseSession() must run before UseInertia(). Add forwarded-header configuration if you deploy behind Coolify or another TLS proxy.
7. Register, sign in and sign out
The endpoints live in Endpoints/AuthEndpoints.cs and are mapped from MapForgetMeNot(). WithInertiaValidation() turns model errors into a redirect back with errors.
The login page uses useForm and shadcn components:
The register page is the same pattern with displayName, email and password.
Checkpoint
Create an account, sign in, refresh the page and confirm you stay signed in. Sign out and confirm you return to the home page. If the session cookie is not sent, check that UseForgetMeNotSession() runs before UseForgetMeNotInertia().
8. Build the dashboard
Map the private endpoints from TodoEndpoints.cs:
Create a list from the dashboard:
The two deferred loaders:
The dashboard React page uses Card, Badge and Deferred:
9. Load items in pages
These snippets live inside MapTodoEndpoints in Endpoints/TodoEndpoints.cs.
Add the list endpoint with Scroll:
The React page asks for the next page as the user scrolls:
Add the write endpoints:
The React page asks for the next page as the user scrolls:
Scroll merges the data array and tells InfiniteScroll which URL parameter to use. The browser only renders the pages it has requested.
Checkpoint
Create a list, add more than one page of items, then scroll. The network panel should show a partial reload for each new page, and existing items should remain in place.
10. Share a list publicly
Public routes do not use RequireSessionUser. Use a random slug, not the numeric id, and return a small DTO instead of the entity.
The handler above is the body of PublicEndpoints.MapPublicEndpoints in Endpoints/PublicEndpoints.cs.
The public page is the same InfiniteScroll pattern, wrapped in a clean card:
Put the /p/{slug} handler in Endpoints/PublicEndpoints.cs inside MapPublicEndpoints, then add it to the route table. Open the URL in a private browser window to prove no session is needed.
Do not leak the private shape
Return PublicTodoDto, not TodoItem. Public pages must not include RemindAt, the owner's email or private lists.
11. Send reminders once
A background service checks for due items every minute, sends them, and records RemindedAt.
Register the service in ForgetMeNotSetup:
For a single instance, the timer is enough. For several instances, use a durable queue with leases (TickerQ, Hangfire or a database outbox) so only one worker claims each reminder.
12. Test the important paths
Add public partial class Program; to the app, then use Inertia.Net.Testing:
For the public route, sign out first and assert that remindAt and the owner's email are absent. This is the test that protects you from accidentally returning the entity.
13. Run it in production
Build both bundles with Bun:
ForgetMeNot starts ssr/ssr.js with Bun. The container pattern is in the docs app and the published samples:
Dockerfilefromdocs/Inertia.Net.Docssamples/Inkwell/Dockerfilefor a single app with SQLite and Bun SSRsamples/CarParker/Dockerfilefor a Mongo-backed app with public pagessamples/TechLandierDistributed/Dockerfilefor a separate SSR pool
Read Server-side rendering and Deploying behind a proxy before exposing the app publicly. Size the container with the memory table.