← back to knowledge-hub

A React + ASP.NET Core App, Orchestrated by Aspire

A full-stack app is usually three terminals and a prayer: API in one, front-end in another, database somewhere, and a tangle of ports and CORS rules holding it together. .NET Aspire collapses that into a single project graph. Declare the pieces, hit F5, and Aspire starts everything, wires the references, and hands you a dashboard.

Here’s the whole build — an ASP.NET Core TODO API, SQLite for storage, and a React front-end on Vite — assembled the Aspire way.

By the way — once this stack is running, dropping a local LLM into it is almost free. See Local AI Models in .NET, Wired Up by Aspire for the Ollama side of the same pattern.

What you need

  • .NET 9.0
  • Node.js
  • VS Code with the C# Dev Kit
  • A container runtime (Docker / Podman)

Install Aspire

As of version 9, Aspire is a CLI tool — no separate workload to install.

1
2
# Windows
iex "& { $(irm https://aspire.dev/install.ps1) }"
1
2
# Linux / macOS
curl -sSL https://aspire.dev/install.sh | bash -s

Create the Aspire app

From the VS Code command palette, pick .NET: New Project, choose the Aspire Starter App template, name it TodojsAspire, and drop it in a src folder.

VS Code command palette showing the New Project option highlighted

VS Code command palette showing the Aspire Starter App template selection

The template gives you an AppHost (the orchestrator), an ApiService, and a Blazor Web project. We’re bringing our own React front-end, so delete the TodojsAspire.Web project from Solution Explorer.

VS Code Explorer panel showing the folder structure of the newly created Aspire app

Solution Explorer context menu with Delete option for removing the TodojsAspire.Web project

The API: model first

A TODO is an id, a title, a done flag, and a position for ordering.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
using System.ComponentModel.DataAnnotations;
namespace TodojsAspire.ApiService;

public class Todo
{
    public int Id { get; set; }
    [Required]
    public string Title { get; set; } = default!;
    public bool IsComplete { get; set; } = false;
    [Required]
    public int Position { get; set; } = 0;
}

Don’t hand-write the CRUD. Scaffold it.

1
dotnet tool install --global Microsoft.dotnet-scaffold

That generates TodoEndpoints.cs with the standard create/read/update/delete. Reordering is the one thing scaffolding won’t guess, so add it. The whole trick is a tuple swap of two Position values:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
group.MapPost("/move-up/{id:int}", async Task<Results<Ok, NotFound>> (int id, TodoDbContext db) =>
{
    var todo = await db.Todo.FirstOrDefaultAsync(t => t.Id == id);
    if (todo is null)
    { return TypedResults.NotFound(); }

    var prevTodo = await db.Todo
        .Where(t => t.Position < todo.Position)
        .OrderByDescending(t => t.Position)
        .FirstOrDefaultAsync();

    if (prevTodo is null)
    { return TypedResults.Ok(); }

    (todo.Position, prevTodo.Position) = (prevTodo.Position, todo.Position);
    await db.SaveChangesAsync();
    return TypedResults.Ok();
})
.WithName("MoveTaskUp");

move-down is the same shape with the comparison flipped.

The database: SQLite via the Community Toolkit

Create the initial migration, then add SQLite to the app graph.

1
2
dotnet ef migrations add TodoEndpointsInitialCreate
aspire add sqlite

Wire it into AppHost.cs — the API gets a reference to the db and a health check:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var builder = DistributedApplication.CreateBuilder(args);

var db = builder.AddSqlite("db")
    .WithSqliteWeb();

var apiService = builder.AddProject<Projects.TodojsAspire_ApiService>("apiservice")
    .WithReference(db)
    .WithHttpHealthCheck("/health");

builder.Build().Run();

Add the EF Core integration package:

1
dotnet add package CommunityToolkit.Aspire.Microsoft.EntityFrameworkCore.Sqlite

And run migrations automatically on startup in Program.cs, so a fresh checkout just works:

1
2
3
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
await dbContext.Database.MigrateAsync();

Exercise the API

Install the REST Client extension and poke the endpoints from a .http file — no Postman, no browser juggling.

VS Code Extensions panel showing the REST Client extension ready for installation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@todoapibaseurl = https://localhost:7473

GET {{todoapibaseurl}}/Todo/

POST {{todoapibaseurl}}/Todo/
Content-Type: application/json

{
  "title": "Sample Todo",
  "isComplete": false,
  "position": 1
}

Tidy the API while you’re here: sort the GET by Position, and auto-assign the next position on create so the client never has to.

Hit Ctrl+F5. Aspire boots the API and the database together and opens the dashboard.

Aspire dashboard showing the ApiService and SQLite database components with their status and endpoints

The dashboard is the payoff: live logs, traces, and environment config for every resource, plus stop/start/restart buttons. One place, whole system.

The React front-end

Scaffold a Vite React app, then teach Aspire about Node and the Community Toolkit extensions.

1
2
3
npm create vite@latest todo-frontend -- --template react
aspire add nodejs
aspire add ct-extensions

Add the Vite app to AppHost.cs. WithReference(apiService) injects the API’s address as an environment variable; WaitFor holds the front-end until the API is healthy; WithNpmPackageInstallation runs npm install for you.

1
2
3
4
builder.AddViteApp(name: "todo-frontend", workingDirectory: "../todo-frontend")
    .WithReference(apiService)
    .WaitFor(apiService)
    .WithNpmPackageInstallation();

The proxy is what makes the front-end blind to where the API lives. Vite reads the address Aspire injected and forwards /api/* to it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');

  return {
    plugins: [react()],
    server:{
      port: parseInt(env.VITE_PORT),
      proxy: {
        '/api': {
          target: process.env.services__apiservice__https__0 || process.env.services__apiservice__http__0,
          changeOrigin: true,
          secure: false,
          rewrite: (path) => path.replace(/^\/api/, '')
        }
      }
    }
  }
})

The components

A single task — text plus delete and reorder buttons:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
function TodoItem({ task, deleteTaskCallback, moveTaskUpCallback, moveTaskDownCallback }) {
  return (
      <li aria-label="task">
          <span className="text">{task}</span>
          <button type="button" aria-label="Delete task" className="delete-button"
              onClick={() => deleteTaskCallback()}>
              🗑️
          </button>
          <button type="button" aria-label="Move task up" className="up-button"
              onClick={() => moveTaskUpCallback()}>
              
          </button>
          <button type="button" aria-label="Move task down" className="down-button"
              onClick={() => moveTaskDownCallback()}>
              
          </button>
      </li>
  );
}

The list owns the state and every API call. Note the fetch("/api/Todo") — no host, no port. The proxy handles it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { useState, useEffect } from 'react';
import TodoItem from './TodoItem';

function TodoList() {
    const [todos, setTodo] = useState([]);
    const [newTaskText, setNewTaskText] = useState('');

    const getTodo = async ()=>{
        fetch("/api/Todo")
        .then(response => response.json())
        .then(json => setTodo(json))
        .catch(error => console.error('Error fetching todos:', error));
    }

    useEffect(() => {
        getTodo();
    },[]);

    async function addTask(event) {
        event.preventDefault();
        if (newTaskText.trim()) {
            const result = await fetch("/api/Todo", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ title: newTaskText, isCompleted: false })
            })
            if(result.ok){ await getTodo(); }
            setNewTaskText('');
        }
    }

    async function deleteTask(id) {
        const result = await fetch(`/api/Todo/${id}`, { method: "DELETE" });
        if(result.ok){ await getTodo(); }
    }

    async function moveTaskUp(index) {
        const todo = todos[index];
        const result = await fetch(`/api/Todo/move-up/${todo.id}`, { method: "POST" });
        if(result.ok){ await getTodo(); }
    }
}

Mount it in App.jsx:

1
2
3
4
5
import TodoList from "./components/TodoList"

function App() {
    return <TodoList />
}

And give index.html a <main> root:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!doctype html>
<html lang="en">
  <head>
    <title>TODO app</title>
  </head>
  <body>
    <main></main>
    <script defer type="module" src="/src/main.jsx"></script>
  </body>
</html>

Run the whole thing

F5 again. Now the dashboard shows all three resources — React front-end, API, and SQLite — started in dependency order.

Aspire dashboard showing all components including the todo-frontend React app, ApiService, and SQLite database

Final thought

The point isn’t the TODO app — it’s that the API never hardcoded a database path, the front-end never hardcoded an API URL, and nothing needed a fourth terminal. Aspire owns the wiring; each piece just declares what it depends on. The result publishes to any host that runs ASP.NET Core, Linux containers included.

Full source: sayedihashimi/todojsaspire.

Adapted from Sayed Ibrahim Hashimi’s Building a Full-Stack App with React and Aspire on the .NET Blog. Images and videos are from the original post.

graph cloud