CRUD with React, Typescript & .NET 8 Web API, EF Core, SQLite

Introduction
Most CRUD tutorials stop at "here's a controller with four actions." This build goes further on purpose - a .NET 8 Minimal API structured around CQRS with MediatR, a proper layered solution (Domain / Application / Infrastructure / Presentation), SQLite for a database that needs zero setup, and a React + TypeScript client with its own typed API layer. The goal is a shape that still makes sense once the app has more than one entity.
🎬 Watch the full video here:
Commands and queries instead of one fat service
Every write is a MediatR command, every read a query, each with its own handler:
public record CreateMovieCommand(string Title, string Description, string Category) : IRequest<int>;
public class CreateMovieCommandHandler : IRequestHandler<CreateMovieCommand, int>
{
private readonly MoviesDbContext _moviesDbContext;
public async Task<int> Handle(CreateMovieCommand request, CancellationToken cancellationToken)
{
var movie = new Movie
{
Title = request.Title,
Category = request.Category,
Description = request.Description,
CreateDate = DateTime.Now.ToUniversalTime()
};
await _moviesDbContext.Movies.AddAsync(movie, cancellationToken);
await _moviesDbContext.SaveChangesAsync(cancellationToken);
return movie.Id;
}
}
The Minimal API endpoint itself stays thin - it builds the command from the request body and hands it to MediatR, with no business logic living in the endpoint at all:
app.MapPost("/api/movies", async (IMediator mediator, CreateMovieRequest request, CancellationToken ct) =>
{
var command = new CreateMovieCommand(request.Title, request.Description, request.Category);
var result = await mediator.Send(command, ct);
return Results.Ok(result);
}).WithTags("Movies");
This split matters the moment a write needs validation, side effects, or gets called from more than one place - the handler is the single place that logic lives, independent of which endpoint (or background job, or test) triggers it.
SQLite: a real relational database with zero setup
builder.Services.AddDbContext<MoviesDbContext>(opt =>
{
opt.UseSqlite(builder.Configuration.GetConnectionString("DbConnectionString"));
});
SQLite writes the entire database to a single file on disk - no server process to install or run, no connection string pointing at infrastructure. For a demo, a prototype, or a small self-contained tool, that's not a compromise, it's the right database. EF Core's migrations, LINQ queries, and change tracking all work identically against it - the only thing that changes versus SQL Server is the provider package and the connection string.
Mapping entities to DTOs without hand-written boilerplate
Mapster projects EF Core entities straight into response DTOs, including inside a LINQ query:
var getMoviesQuery = _moviesDbContext.Movies.ProjectToType<MovieDto>().AsQueryable();
ProjectToType<MovieDto>() translates into a SQL projection that selects only the columns MovieDto actually needs - not "load full entities, then map in memory," but "map as part of the query itself," which is both less code and a smaller SQL SELECT.
A typed API layer on the React side
The client wraps every HTTP call behind a single typed object rather than scattering axios.get calls across components:
const apiConnector = {
createMovie: async (movie: MovieDto): Promise<void> => {
await axiosInstance.post<number>(`/movies`, movie)
},
editMovie: async (movie: MovieDto): Promise<void> => {
await axiosInstance.put<number>(`/movies/${movie.id}`, movie)
},
deleteMovie: async (movieId: string): Promise<void> => {
await axiosInstance.delete<number>(`/movies/${movieId}`)
},
}
Every component that needs to talk to the API imports apiConnector instead of building its own request - so a change to a URL, a header, or the base API path happens in one file, not in every component that happens to call it.
Global exception handling instead of try/catch in every handler
Rather than wrapping every command handler in its own try/catch, IExceptionHandler catches unhandled exceptions once, centrally, and turns them into a consistent error response:
builder.Services.AddExceptionHandler<ExceptionHandler>();
// ...
app.UseExceptionHandler(_ => { });
A validation failure, a not-found entity, or an unexpected error all flow through the same funnel - the API's error shape stays consistent without every handler needing its own error-handling code.
Common pitfalls
- Putting business logic directly in Minimal API endpoint lambdas. It gets unreadable fast and can't be reused outside an HTTP request; MediatR handlers keep it testable and centralized.
- Choosing SQLite for a genuinely concurrent, multi-writer production workload. It's excellent for demos, tools, and single-writer scenarios - not a SQL Server replacement at scale.
- Scattering
axioscalls across components instead of centralizing them behind one typed API layer. - Loading full entities and mapping in memory when
ProjectToType<T>()could push the projection into the SQL query itself. - Forgetting
AllowAnyOrigindoesn't work once credentials are involved - name the React dev server's origin explicitly in the CORS policy.
Key Takeaways
- CQRS with MediatR splits every operation into a command or query with its own handler, keeping Minimal API endpoints thin.
- SQLite is a genuinely good choice for a self-contained app or demo - zero infrastructure, full EF Core support.
- Mapster's
ProjectToType<T>()maps directly inside the LINQ query, avoiding a separate in-memory mapping pass. - A single typed API connector object on the React side keeps every HTTP call in one place instead of scattered across components.
- Centralized exception handling (
IExceptionHandler) keeps error responses consistent without try/catch in every handler.
Get the Full Source Code
The complete runnable solution - the layered .NET 8 API (Domain/Application/Infrastructure/Presentation), the SQLite database, and the full React + TypeScript client - is available to Patreon supporters. If you want the whole CRUD app ready to run instead of rebuilding it from the walkthrough above, you can find it on Patreon.