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

Register, sign in, sign out and keep flash messages in the same session.

Private todo lists

Create lists, add items, mark them done and set due dates.

Public share pages

Share an unguessable URL that opens without an account.

Virtual loading

Load todo items in pages with Scroll and InfiniteScroll.

Reminders

Set a reminder time and let a background worker send it once.

Pretty UI

Use Tailwind CSS and shadcn/ui components instead of inline styles.

1. Start from the template

Install the templates once, then create the app:

Text
dotnet new install Inertia.Net.Templates::1.5.3
dotnet new inertia -n ForgetMeNot --framework react
cd ForgetMeNot
bun install

Start the two development processes. Keep both terminals open:

Text
# terminal 1
bun run dev

# terminal 2
dotnet watch

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:

Text
Program.cs
app.html
ClientApp/app.tsx
ClientApp/Layout.tsx
ClientApp/pages/Home.tsx
ClientApp/pages/Todos/Index.tsx

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:

Text
bun add -d tailwindcss @tailwindcss/vite
bun add class-variance-authority clsx tailwind-merge lucide-react

Update vite.config.ts:

Text
import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
// ...your existing imports

export default defineConfig({
  plugins: [react(), tailwindcss(), inertia(), hotFile()],
  resolve: {
    alias: { '@': path.resolve(import.meta.dirname, 'ClientApp') },
  },
  // ...the rest stays the same
})

Add these two properties inside compilerOptions in tsconfig.json:

Text
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./ClientApp/*"]
    }
  }
}

Create ClientApp/app.css and import it from ClientApp/app.tsx:

Text
@import "tailwindcss";
Text
// ClientApp/app.tsx
import './app.css'
import { createInertiaApp } from '@inertiajs/react'

createInertiaApp()

Now initialize shadcn/ui from the project directory:

Text
bunx --bun shadcn@latest init

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:

Text
bunx --bun shadcn@latest add button card input label badge checkbox separator

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:

Text
import { Link, usePage } from '@inertiajs/react'
import { LogOut, NotebookPen, UserRound } from 'lucide-react'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'

export default function Layout({ children }: { children: ReactNode }) {
  const { props, flash } = usePage()

  return (
    <div className="min-h-screen bg-background text-foreground">
      <header className="border-b">
        <div className="mx-auto flex h-16 max-w-5xl items-center gap-4 px-4">
          <Link href="/" className="flex items-center gap-2 font-semibold">
            <NotebookPen className="size-5 text-primary" />
            ForgetMeNot
          </Link>

          <nav className="ml-auto flex items-center gap-2">
            {props.auth ? (
              <>
                <Button asChild variant="ghost" size="sm"><Link href="/todos">My lists</Link></Button>
                <span className="hidden items-center gap-1 text-sm text-muted-foreground sm:flex">
                  <UserRound className="size-4" /> {props.auth.displayName}
                </span>
                <Button asChild variant="ghost" size="sm">
                  <Link href="/logout" method="post" as="button"><LogOut className="size-4" /> Sign out</Link>
                </Button>
              </>
            ) : (
              <>
                <Button asChild variant="ghost" size="sm"><Link href="/login">Sign in</Link></Button>
                <Button asChild size="sm"><Link href="/register">Create account</Link></Button>
              </>
            )}
          </nav>
        </div>
      </header>

      {flash.success && (
        <div className="border-b bg-emerald-50 px-4 py-2 text-center text-sm text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-200">
          {flash.success}
        </div>
      )}

      <main className="mx-auto max-w-5xl px-4 py-10">{children}</main>
    </div>
  )
}

And replace the template home page with a friendly landing page:

Text
// ClientApp/pages/Home.tsx
import { Link } from '@inertiajs/react'
import { ArrowRight, CheckCircle2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import Layout from '../Layout'

export default function Home() {
  return (
    <Layout>
      <section className="mx-auto max-w-3xl py-16 text-center">
        <Badge variant="secondary">Inertia.Net sample</Badge>
        <h1 className="mt-6 text-balance text-5xl font-semibold tracking-tight">
          Remember everything. Share what matters.
        </h1>
        <p className="mx-auto mt-4 max-w-xl text-lg text-muted-foreground">
          Private lists, public links, due dates and reminders — built with ASP.NET Core and Inertia.
        </p>
        <div className="mt-8 flex justify-center gap-3">
          <Button asChild size="lg"><Link href="/register">Create an account <ArrowRight className="size-4" /></Link></Button>
          <Button asChild size="lg" variant="outline"><Link href="/login">Sign in</Link></Button>
        </div>
      </section>

      <div className="grid gap-4 sm:grid-cols-3">
        {['Private by default', 'One public link', 'Reminders that fire once'].map((text) => (
          <Card key={text}>
            <CardContent className="flex items-center gap-2 p-5 text-sm">
              <CheckCircle2 className="size-4 text-primary" /> {text}
            </CardContent>
          </Card>
        ))}
      </div>
    </Layout>
  )
}

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:

Text
ForgetMeNot/
  Data/
  Endpoints/
  Infrastructure/

The final Program.cs is only a few lines:

Text
using ForgetMeNot;
using ForgetMeNot.Endpoints;
using ForgetMeNot.Infrastructure;

var builder = WebApplication.CreateBuilder(args);
builder.AddForgetMeNot();

var app = builder.Build();
app.UseForgetMeNot();
app.MapForgetMeNot();

app.Run();

Each extension does one job:

FileResponsibility
Infrastructure/ForgetMeNotSetup.csThe one AddForgetMeNot() and UseForgetMeNot() entry point
Infrastructure/DataSetup.csEF Core and SQLite
Infrastructure/SessionAuthSetup.csSession, antiforgery and password hashing
Infrastructure/InertiaSetup.csInertia options, shared props and SSR
Infrastructure/SessionAuth.csSign in/out helpers and the RequireSessionUser filter
Endpoints/AuthEndpoints.csRegister, login and logout
Endpoints/TodoEndpoints.csPrivate lists and items
Endpoints/PublicEndpoints.csThe public share page

A beginner can read Program.cs in one glance. That is the point.

The route table is also one readable file:

Text
// Endpoints/ForgetMeNotEndpoints.cs
public static class ForgetMeNotEndpoints
{
    public static IEndpointRouteBuilder MapForgetMeNot(this IEndpointRouteBuilder app)
    {
        app.MapGet("/health", () => Results.Ok("ok"));
        app.MapGet("/", () => Render("Home"));
        app.MapGet("/login", () => Render("Auth/Login"));
        app.MapGet("/register", () => Render("Auth/Register"));

        app.MapAuthEndpoints();
        app.MapTodoEndpoints();
        app.MapPublicEndpoints();
        return app;
    }
}

If a route is hard to find, start there.

4. Model the data

Add the entities:

Text
// Data/Entities.cs
public sealed class AppUser
{
    public int Id { get; set; }
    public required string Email { get; set; }
    public required string DisplayName { get; set; }
    public required string PasswordHash { get; set; }
    public List<TodoList> Lists { get; } = [];
}

public sealed class TodoList
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public AppUser User { get; set; } = null!;
    public required string Title { get; set; }
    public required string PublicSlug { get; set; }
    public bool IsPublic { get; set; }
    public List<TodoItem> Items { get; } = [];
}

public sealed class TodoItem
{
    public int Id { get; set; }
    public int TodoListId { get; set; }
    public TodoList List { get; set; } = null!;
    public required string Title { get; set; }
    public bool IsDone { get; set; }
    public DateTimeOffset? DueAt { get; set; }
    public DateTimeOffset? RemindAt { get; set; }
    public DateTimeOffset? RemindedAt { get; set; }
}

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.

Text
// Data/AppDbContext.cs
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<AppUser> Users => Set<AppUser>();
    public DbSet<TodoList> Lists => Set<TodoList>();
    public DbSet<TodoItem> Items => Set<TodoItem>();

    protected override void OnModelCreating(ModelBuilder model)
    {
        model.Entity<TodoList>().HasIndex(l => l.PublicSlug).IsUnique();
        model.Entity<TodoItem>().HasIndex(i => new { i.TodoListId, i.RemindAt, i.RemindedAt });
    }
}
Text
// Data/PageModels.cs
public sealed record TodoListDto(int Id, string Title, string PublicSlug, bool IsPublic, int Total, int Open);
public sealed record TodoItemDto(int Id, string Title, bool IsDone, DateTimeOffset? DueAt);
public sealed record TodoListHeader(int Id, string Title, string PublicSlug, bool IsPublic);
public sealed record ReminderDto(int Id, string Title, DateTimeOffset RemindAt);
public sealed record TodoStats(int Open, int Done, int DueToday);
public sealed record PublicListHeader(int Id, string Title, string Owner, int OpenCount);
public sealed record PublicTodoDto(int Id, string Title, DateTimeOffset? DueAt);
public sealed record TodoItemsPage(List<TodoItemDto> Data);
public sealed record PublicTodoPage(List<PublicTodoDto> Data);

public sealed class CreateListForm
{
    [Required, MaxLength(120)]
    public string? Title { get; set; }

    public bool IsPublic { get; set; }
}

public sealed class CreateTodoForm
{
    [Required, MaxLength(200)]
    public string? Title { get; set; }

    public DateTimeOffset? DueAt { get; set; }
    public DateTimeOffset? RemindAt { get; set; }
}

The template uses source-generated JSON. Add every prop and form type to its AppJsonContext:

Text
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(AuthUser))]
[JsonSerializable(typeof(SharedProps))]
[JsonSerializable(typeof(Flash))]
[JsonSerializable(typeof(TodoListDto))]
[JsonSerializable(typeof(List<TodoListDto>))]
[JsonSerializable(typeof(TodoItemDto))]
[JsonSerializable(typeof(TodoListHeader))]
[JsonSerializable(typeof(TodoItemsPage))]
[JsonSerializable(typeof(ReminderDto))]
[JsonSerializable(typeof(List<ReminderDto>))]
[JsonSerializable(typeof(TodoStats))]
[JsonSerializable(typeof(PublicListHeader))]
[JsonSerializable(typeof(PublicTodoDto))]
[JsonSerializable(typeof(PublicTodoPage))]
[JsonSerializable(typeof(RegisterForm))]
[JsonSerializable(typeof(LoginForm))]
[JsonSerializable(typeof(CreateListForm))]
[JsonSerializable(typeof(CreateTodoForm))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

The data setup extension:

Text
// Infrastructure/DataSetup.cs
public static class DataSetup
{
    public static WebApplicationBuilder AddForgetMeNotData(this WebApplicationBuilder builder)
    {
        builder.Services.AddDbContext<AppDbContext>(o =>
            o.UseSqlite("Data Source=forgetmenot.db"));
        return builder;
    }

    public static WebApplication EnsureForgetMeNotData(this WebApplication app)
    {
        using var scope = app.Services.CreateScope();
        scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.EnsureCreated();
        return app;
    }
}

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.

Text
// Infrastructure/SessionAuthSetup.cs
public static class SessionAuthSetup
{
    public static WebApplicationBuilder AddForgetMeNotSessionAuth(this WebApplicationBuilder builder)
    {
        builder.Services.AddDistributedMemoryCache();
        builder.Services.AddSession(o =>
        {
            o.Cookie.Name = ".fmn.session";
            o.Cookie.HttpOnly = true;
            o.Cookie.IsEssential = true;
            o.Cookie.SameSite = SameSiteMode.Lax;
            o.Cookie.SecurePolicy = builder.Environment.IsDevelopment()
                ? CookieSecurePolicy.SameAsRequest
                : CookieSecurePolicy.Always;
            o.IdleTimeout = TimeSpan.FromDays(7);
        });

        builder.Services.AddAntiforgery(o => o.HeaderName = InertiaHeaders.XsrfToken);
        builder.Services.AddScoped<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
        return builder;
    }

    public static WebApplication UseForgetMeNotSession(this WebApplication app)
    {
        app.UseSession();
        return app;
    }
}

The session helpers and the endpoint filter:

Text
// Infrastructure/SessionAuth.cs
public static class SessionAuth
{
    public const string UserIdKey = "fmn.user-id";
    public const string DisplayNameKey = "fmn.display-name";

    public static int? UserId(this HttpContext http) => http.Session.GetInt32(UserIdKey);

    public static void SignIn(this HttpContext http, AppUser user)
    {
        http.Session.SetInt32(UserIdKey, user.Id);
        http.Session.SetString(DisplayNameKey, user.DisplayName);
    }

    public static void SignOut(this HttpContext http) => http.Session.Clear();
}

public sealed class RequireSessionUser : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        if (context.HttpContext.UserId() is null)
        {
            return Inertia.Net.Inertia.Location("/login");
        }

        return await next(context);
    }
}

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.

Text
// Infrastructure/InertiaSetup.cs
public static class InertiaSetup
{
    public static WebApplicationBuilder AddForgetMeNotInertia(this WebApplicationBuilder builder)
    {
        builder.Services.AddInertia(o =>
        {
            o.Share("appName", "ForgetMeNot");
            o.Share("auth", ctx =>
            {
                var id = ctx.Session.GetInt32(SessionAuth.UserIdKey);
                var name = ctx.Session.GetString(SessionAuth.DisplayNameKey);
                return id is null ? null : new AuthUser(id.Value, name ?? "You");
            });
            o.Share("baseUrl", ctx =>
                $"{ctx.Request.Scheme}://{ctx.Request.Host}{ctx.Request.PathBase}");

            o.Pages.Directory = "ClientApp/pages";
            o.Vite.Prefetch = VitePrefetch.Waterfall;
            o.State.UseSession();                  // flash/errors live in the session too
            o.TypeScript.OutputPath = "ClientApp/types/inertia.d.ts";
            o.TypeScript.SharedProps<SharedProps>().FlashData<Flash>();
            o.Ssr.BundlePath = "ssr/ssr.js";
            o.Ssr.Mode = SsrMode.Preferred;
            o.Ssr.UseNodeProcess(n => n.Executable = "bun");
        });

        builder.Services.AddInertiaDevTools();
        return builder;
    }

    public static WebApplication UseForgetMeNotInertia(this WebApplication app)
    {
        app.UseInertia();
        app.UseInertiaAntiforgery();
        return app;
    }
}

public sealed record AuthUser(int Id, string DisplayName);
public sealed record SharedProps(string AppName, AuthUser? Auth, string BaseUrl);
public sealed record Flash(string? Success);

Now the aggregate setup:

Text
// Infrastructure/ForgetMeNotSetup.cs
public static class ForgetMeNotSetup
{
    public static WebApplicationBuilder AddForgetMeNot(this WebApplicationBuilder builder)
    {
        builder.Services.Configure<ForwardedHeadersOptions>(o =>
        {
            o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
            o.ForwardLimit = 1;
            o.KnownIPNetworks.Clear();
            o.KnownProxies.Clear();
        });

        builder.Services.AddValidation();
        builder.Services.AddProblemDetails();
        builder.Services.AddResponseCompression(o => o.EnableForHttps = true);
        builder.Services.ConfigureHttpJsonOptions(o =>
            o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));

        builder.AddForgetMeNotData();
        builder.AddForgetMeNotSessionAuth();
        builder.AddForgetMeNotInertia();
        return builder;
    }

    public static WebApplication UseForgetMeNot(this WebApplication app)
    {
        app.UseForwardedHeaders();
        app.UseResponseCompression();
        app.UseStaticFiles(new StaticFileOptions
        {
            OnPrepareResponse = c =>
            {
                if (c.Context.Request.Path.StartsWithSegments("/build/assets"))
                    c.Context.Response.Headers.CacheControl = "public, max-age=31536000, immutable";
            },
        });

        app.UseForgetMeNotSession();
        app.UseForgetMeNotInertia();
        app.EnsureForgetMeNotData();
        return app;
    }
}

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.

Text
public sealed class RegisterForm
{
    [Required, EmailAddress]
    public string? Email { get; set; }

    [Required, MinLength(8)]
    public string? Password { get; set; }

    [Required, MaxLength(80)]
    public string? DisplayName { get; set; }
}

public sealed class LoginForm
{
    [Required, EmailAddress]
    public string? Email { get; set; }

    [Required]
    public string? Password { get; set; }
}

public static class AuthEndpoints
{
    public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapPost("/register", async (
            RegisterForm form, AppDbContext db, IPasswordHasher<AppUser> hasher, HttpContext http) =>
        {
            var email = form.Email!.Trim().ToLowerInvariant();
            if (await db.Users.AnyAsync(u => u.Email == email))
            {
                return Back().WithErrors(new Dictionary<string, string>
                {
                    ["email"] = "An account with this email already exists.",
                });
            }

            var user = new AppUser
            {
                Email = email,
                DisplayName = form.DisplayName!.Trim(),
                PasswordHash = "",
            };
            user.PasswordHash = hasher.HashPassword(user, form.Password!);

            db.Users.Add(user);
            await db.SaveChangesAsync();

            http.Inertia().Flash("success", "Account created. Sign in to continue.");
            return Results.Redirect("/login");
        }).WithInertiaValidation();

        app.MapPost("/login", async (
            LoginForm form, AppDbContext db, IPasswordHasher<AppUser> hasher, HttpContext http) =>
        {
            var email = form.Email!.Trim().ToLowerInvariant();
            var user = await db.Users.SingleOrDefaultAsync(u => u.Email == email);

            if (user is null || hasher.VerifyHashedPassword(user, user.PasswordHash, form.Password!)
                == PasswordVerificationResult.Failed)
            {
                return Back().WithErrors(new Dictionary<string, string>
                {
                    ["email"] = "These credentials do not match our records.",
                });
            }

            http.SignIn(user);
            return Results.Redirect("/todos");
        }).WithInertiaValidation();

        app.MapPost("/logout", (HttpContext http) =>
        {
            http.SignOut();
            http.Inertia().ClearHistory();
            return Results.Redirect("/");
        });

        return app;
    }
}

The login page uses useForm and shadcn components:

Text
// ClientApp/pages/Auth/Login.tsx
import { Link, useForm } from '@inertiajs/react'
import type { FormEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import Layout from '../../Layout'

export default function Login() {
  const form = useForm({ email: '', password: '' })

  const submit = (event: FormEvent) => {
    event.preventDefault()
    form.post('/login', { onFinish: () => form.reset('password') })
  }

  return (
    <Layout>
      <Card className="mx-auto mt-12 max-w-md">
        <CardHeader>
          <CardTitle>Welcome back</CardTitle>
          <CardDescription>Sign in to see your lists and reminders.</CardDescription>
        </CardHeader>
        <CardContent>
          <form onSubmit={submit} className="space-y-4">
            <div className="space-y-2">
              <Label htmlFor="email">Email</Label>
              <Input id="email" type="email" value={form.data.email} onChange={(e) => form.setData('email', e.target.value)} />
              {form.errors.email && <p className="text-sm text-red-600">{form.errors.email}</p>}
            </div>

            <div className="space-y-2">
              <Label htmlFor="password">Password</Label>
              <Input id="password" type="password" value={form.data.password} onChange={(e) => form.setData('password', e.target.value)} />
              {form.errors.password && <p className="text-sm text-red-600">{form.errors.password}</p>}
            </div>

            <Button type="submit" className="w-full" disabled={form.processing}>
              {form.processing ? 'Signing in...' : 'Sign in'}
            </Button>

            <p className="text-center text-sm text-muted-foreground">
              New here? <Link href="/register" className="font-medium text-foreground underline">Create an account</Link>
            </p>
          </form>
        </CardContent>
      </Card>
    </Layout>
  )
}

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:

Text
public static class TodoEndpoints
{
    public static IEndpointRouteBuilder MapTodoEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapGet("/todos", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
        {
            var userId = http.UserId()!.Value;

            var lists = await db.Lists
                .AsNoTracking()
                .Where(l => l.UserId == userId)
                .OrderBy(l => l.Title)
                .Select(l => new TodoListDto(
                    l.Id, l.Title, l.PublicSlug, l.IsPublic,
                    l.Items.Count, l.Items.Count(i => !i.IsDone)))
                .ToListAsync(ct);

            return Render("Todos/Index", new InertiaProps
            {
                ["lists"] = lists,
                ["reminders"] = Defer(ct => LoadReminders(db, userId, ct), group: "sidebar"),
                ["stats"] = Defer(ct => LoadStats(db, userId, ct), group: "sidebar"),
            });
        }).AddEndpointFilter<RequireSessionUser>();

        // ...the list, create and toggle endpoints below...
        return app;
    }
}

Create a list from the dashboard:

Text
app.MapPost("/lists", async (
    CreateListForm form, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
    var list = new TodoList
    {
        UserId = http.UserId()!.Value,
        Title = form.Title!.Trim(),
        IsPublic = form.IsPublic,
        PublicSlug = NewPublicSlug(),
    };
    db.Lists.Add(list);
    await db.SaveChangesAsync(ct);
    return Results.Redirect($"/todos/{list.Id}");
}).AddEndpointFilter<RequireSessionUser>().WithInertiaValidation();

static string NewPublicSlug() =>
    Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();

The two deferred loaders:

Text
static async Task<List<ReminderDto>> LoadReminders(AppDbContext db, int userId, CancellationToken ct) =>
    await db.Items
        .AsNoTracking()
        .Where(i => i.List.UserId == userId && i.RemindAt != null && i.RemindedAt == null)
        .OrderBy(i => i.RemindAt)
        .Take(10)
        .Select(i => new ReminderDto(i.Id, i.Title, i.RemindAt!.Value))
        .ToListAsync(ct);

static async Task<TodoStats> LoadStats(AppDbContext db, int userId, CancellationToken ct)
{
    var today = DateTimeOffset.UtcNow.Date;
    return new TodoStats(
        await db.Items.CountAsync(i => i.List.UserId == userId && !i.IsDone, ct),
        await db.Items.CountAsync(i => i.List.UserId == userId && i.IsDone, ct),
        await db.Items.CountAsync(i => i.List.UserId == userId && !i.IsDone
            && i.DueAt != null && i.DueAt < today.AddDays(1), ct));
}

The dashboard React page uses Card, Badge and Deferred:

Text
// ClientApp/pages/Todos/Index.tsx
import { Deferred, Link, useForm } from '@inertiajs/react'
import { Plus } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import Layout from '../../Layout'

interface Props {
  lists: { id: number; title: string; isPublic: boolean; total: number; open: number }[]
  reminders?: { id: number; title: string; remindAt: string }[]
  stats?: { open: number; done: number; dueToday: number }
}

export default function Index({ lists, reminders, stats }: Props) {
  const form = useForm({ title: '' })

  return (
    <Layout>
      <div className="flex items-end justify-between gap-4">
        <div>
          <h1 className="text-3xl font-semibold tracking-tight">Your lists</h1>
          <p className="mt-1 text-muted-foreground">Private by default. Share a list when you want to.</p>
        </div>
      </div>

      <div className="mt-8 grid gap-4 sm:grid-cols-2">
        {lists.map((list) => (
          <Link key={list.id} href={`/todos/${list.id}`}>
            <Card className="transition-colors hover:border-primary/40">
              <CardHeader>
                <div className="flex items-center justify-between gap-2">
                  <CardTitle>{list.title}</CardTitle>
                  <Badge variant={list.isPublic ? 'default' : 'secondary'}>{list.isPublic ? 'Public' : 'Private'}</Badge>
                </div>
              </CardHeader>
              <CardContent className="text-sm text-muted-foreground">
                {list.open} open of {list.total}
              </CardContent>
            </Card>
          </Link>
        ))}
      </div>

      <Card className="mt-8">
        <CardHeader><CardTitle>New list</CardTitle></CardHeader>
        <CardContent>
          <form onSubmit={(event) => { event.preventDefault(); form.post('/lists', { onSuccess: () => form.reset() }) }} className="flex gap-2">
            <Input value={form.data.title} onChange={(e) => form.setData('title', e.target.value)} placeholder="Weekend plans" />
            <Button type="submit" disabled={form.processing}><Plus className="size-4" /> Add</Button>
          </form>
          {form.errors.title && <p className="mt-2 text-sm text-red-600">{form.errors.title}</p>}
        </CardContent>
      </Card>

      <div className="mt-8 grid gap-4 sm:grid-cols-2">
        <Deferred data="stats" fallback={<p className="text-sm text-muted-foreground">Counting...</p>}>
          <Card>
            <CardHeader><CardTitle>Overview</CardTitle></CardHeader>
            <CardContent className="text-sm text-muted-foreground">
              {stats?.open} open · {stats?.done} done · {stats?.dueToday} due today
            </CardContent>
          </Card>
        </Deferred>

        <Deferred data="reminders" fallback={<p className="text-sm text-muted-foreground">Loading reminders...</p>}>
          <Card>
            <CardHeader><CardTitle>Upcoming reminders</CardTitle></CardHeader>
            <CardContent>
              {reminders?.length ? reminders.map((reminder) => (
                <p key={reminder.id} className="text-sm">{reminder.title}</p>
              )) : <p className="text-sm text-muted-foreground">Nothing scheduled.</p>}
            </CardContent>
          </Card>
        </Deferred>
      </div>
    </Layout>
  )
}

9. Load items in pages

These snippets live inside MapTodoEndpoints in Endpoints/TodoEndpoints.cs.

Add the list endpoint with Scroll:

Text
app.MapGet("/todos/{id:int}", async (
    int id, int? page, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
    var userId = http.UserId()!.Value;
    var list = await db.Lists
        .AsNoTracking()
        .Where(l => l.Id == id && l.UserId == userId)
        .Select(l => new TodoListHeader(l.Id, l.Title, l.PublicSlug, l.IsPublic))
        .FirstOrDefaultAsync(ct);

    if (list is null) return Results.NotFound();

    const int pageSize = 40;
    var current = Math.Max(1, page ?? 1);
    var rows = await db.Items
        .AsNoTracking()
        .Where(i => i.TodoListId == id)
        .OrderBy(i => i.IsDone)
        .ThenBy(i => i.DueAt)
        .ThenBy(i => i.Id)
        .Skip((current - 1) * pageSize)
        .Take(pageSize + 1)
        .Select(i => new TodoItemDto(i.Id, i.Title, i.IsDone, i.DueAt))
        .ToListAsync(ct);

    return Render("Todos/Show", new InertiaProps
    {
        ["list"] = list,
        ["items"] = Scroll(
            new TodoItemsPage(rows.Take(pageSize).ToList()),
            metadata: ScrollMetadata.FromPage(current, rows.Count > pageSize)),
    });
}).AddEndpointFilter<RequireSessionUser>();

The React page asks for the next page as the user scrolls:

Add the write endpoints:

Text
app.MapPost("/todos/{id:int}/items", async (
    int id, CreateTodoForm form, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
    var userId = http.UserId()!.Value;
    if (!await db.Lists.AnyAsync(l => l.Id == id && l.UserId == userId, ct))
        return Results.NotFound();

    db.Items.Add(new TodoItem
    {
        TodoListId = id,
        Title = form.Title!.Trim(),
        DueAt = form.DueAt,
        RemindAt = form.RemindAt,
    });
    await db.SaveChangesAsync(ct);

    return Back().WithFlash("success", "Todo added");
}).AddEndpointFilter<RequireSessionUser>().WithInertiaValidation();

app.MapPatch("/items/{id:int}", async (
    int id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
    var userId = http.UserId()!.Value;
    var item = await db.Items
        .Include(i => i.List)
        .FirstOrDefaultAsync(i => i.Id == id && i.List.UserId == userId, ct);

    if (item is null) return Results.NotFound();

    item.IsDone = !item.IsDone;
    await db.SaveChangesAsync(ct);
    return Back();
}).AddEndpointFilter<RequireSessionUser>();

The React page asks for the next page as the user scrolls:

Text
// ClientApp/pages/Todos/Show.tsx
import { InfiniteScroll, router, useForm } from '@inertiajs/react'
import type { FormEvent } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import Layout from '../../Layout'

interface Props {
  list: { id: number; title: string }
  items: { data: { id: number; title: string; isDone: boolean; dueAt: string | null }[] }
}

export default function Show({ list, items }: Props) {
  const form = useForm({ title: '', dueAt: '', remindAt: '' })

  const submit = (event: FormEvent) => {
    event.preventDefault()
    form.post(`/todos/${list.id}/items`, { onSuccess: () => form.reset() })
  }

  return (
    <Layout>
      <h1 className="text-3xl font-semibold tracking-tight">{list.title}</h1>
      <p className="mt-1 text-muted-foreground">Loaded in pages, merged as you scroll.</p>

      <Card className="mt-6">
        <CardContent className="p-4">
          <form onSubmit={submit} className="grid gap-3 sm:grid-cols-[1fr_auto_auto_auto]">
            <Input placeholder="What needs doing?" value={form.data.title} onChange={(e) => form.setData('title', e.target.value)} />
            <Input type="datetime-local" value={form.data.dueAt} onChange={(e) => form.setData('dueAt', e.target.value)} aria-label="Due date" />
            <Input type="datetime-local" value={form.data.remindAt} onChange={(e) => form.setData('remindAt', e.target.value)} aria-label="Remind me" />
            <Button type="submit" disabled={form.processing}>Add</Button>
          </form>
          {form.errors.title && <p className="mt-2 text-sm text-red-600">{form.errors.title}</p>}
        </CardContent>
      </Card>

      <InfiniteScroll data="items" className="mt-8 space-y-3">
        {items.data.map((item) => (
          <Card key={item.id}>
            <CardContent className="flex items-center gap-3 p-4">
              <Checkbox checked={item.isDone} onCheckedChange={() => router.patch(`/items/${item.id}`)} />
              <span className={item.isDone ? 'text-muted-foreground line-through' : ''}>{item.title}</span>
              {item.dueAt && <span className="ml-auto text-xs text-muted-foreground">{new Date(item.dueAt).toLocaleDateString()}</span>}
            </CardContent>
          </Card>
        ))}
      </InfiniteScroll>
    </Layout>
  )
}

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.

Text
app.MapGet("/p/{slug}", async (
    string slug, int? page, AppDbContext db, CancellationToken ct) =>
{
    var list = await db.Lists
        .AsNoTracking()
        .Where(l => l.PublicSlug == slug && l.IsPublic)
        .Select(l => new PublicListHeader(l.Id, l.Title, l.User.DisplayName, l.Items.Count(i => !i.IsDone)))
        .FirstOrDefaultAsync(ct);

    if (list is null) return Results.NotFound();

    const int pageSize = 25;
    var current = Math.Max(1, page ?? 1);
    var rows = await db.Items
        .AsNoTracking()
        .Where(i => i.TodoListId == list.Id && !i.IsDone)
        .OrderBy(i => i.DueAt)
        .ThenBy(i => i.Id)
        .Skip((current - 1) * pageSize)
        .Take(pageSize + 1)
        .Select(i => new PublicTodoDto(i.Id, i.Title, i.DueAt))
        .ToListAsync(ct);

    return Render("Public/Show", new InertiaProps
    {
        ["list"] = list,
        ["items"] = Scroll(
            new PublicTodoPage(rows.Take(pageSize).ToList()),
            metadata: ScrollMetadata.FromPage(current, rows.Count > pageSize)),
    });
});

The handler above is the body of PublicEndpoints.MapPublicEndpoints in Endpoints/PublicEndpoints.cs.

Text
static string NewPublicSlug() =>
    Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();

The public page is the same InfiniteScroll pattern, wrapped in a clean card:

Text
// ClientApp/pages/Public/Show.tsx
import { InfiniteScroll } from '@inertiajs/react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import Layout from '../../Layout'

interface Props {
  list: { title: string; owner: string; openCount: number }
  items: { data: { id: number; title: string; dueAt: string | null }[] }
}

export default function PublicShow({ list, items }: Props) {
  return (
    <Layout>
      <Badge variant="secondary">Public list</Badge>
      <h1 className="mt-4 text-4xl font-semibold tracking-tight">{list.title}</h1>
      <p className="mt-2 text-muted-foreground">Shared by {list.owner} · {list.openCount} open items</p>

      <InfiniteScroll data="items" className="mt-8 space-y-3">
        {items.data.map((item) => (
          <Card key={item.id}>
            <CardHeader><CardTitle className="text-base">{item.title}</CardTitle></CardHeader>
            {item.dueAt && <CardContent className="text-sm text-muted-foreground">Due {new Date(item.dueAt).toLocaleDateString()}</CardContent>}
          </Card>
        ))}
      </InfiniteScroll>
    </Layout>
  )
}

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.

Text
public interface IEmailSender
{
    Task SendAsync(string to, string subject, string body, CancellationToken ct = default);
}

public sealed class ReminderWorker(
    IServiceScopeFactory scopes,
    ILogger<ReminderWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            using var scope = scopes.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            var mail = scope.ServiceProvider.GetRequiredService<IEmailSender>();
            var now = DateTimeOffset.UtcNow;

            var due = await db.Items
                .Include(i => i.List).ThenInclude(l => l.User)
                .Where(i => i.RemindAt != null && i.RemindAt <= now && i.RemindedAt == null)
                .OrderBy(i => i.RemindAt)
                .Take(100)
                .ToListAsync(stoppingToken);

            foreach (var item in due)
            {
                await mail.SendAsync(item.List.User.Email, $"Reminder: {item.Title}", item.Title, stoppingToken);
                item.RemindedAt = now;
            }

            if (due.Count > 0)
            {
                await db.SaveChangesAsync(stoppingToken);
                logger.LogInformation("Sent {Count} reminders", due.Count);
            }
        }
    }
}

Register the service in ForgetMeNotSetup:

Text
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>(); // your implementation
builder.Services.AddHostedService<ReminderWorker>();

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:

Text
var page = await client.GetInertiaPageAsync("/todos");

new AssertableInertia(page)
    .Component("Todos/Index")
    .Has("lists")
    .Missing("reminders");

var loaded = await page.LoadDeferredPropsAsync(client, "sidebar");
new AssertableInertia(loaded).Has("reminders");

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:

Text
bun run build
dotnet publish -c Release

ForgetMeNot starts ssr/ssr.js with Bun. The container pattern is in the docs app and the published samples:

  • Dockerfile from docs/Inertia.Net.Docs
  • samples/Inkwell/Dockerfile for a single app with SQLite and Bun SSR
  • samples/CarParker/Dockerfile for a Mongo-backed app with public pages
  • samples/TechLandierDistributed/Dockerfile for 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.