Remigiusz ZalewskiRemigiusz Zalewski

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

crud-react-typescript-dotnet8-efcore-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 axios calls 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 AllowAnyOrigin doesn'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.

Resources