Student & Class Management System
Build a full-stack school management app with .NET 10 API and React.
Introduction
30 Second Summary
Every school needs a system to keep track of who is enrolled, which classes are running, and which students are in each class. Behind the scenes, this is a data management problem that full-stack developers solve every day.
In this project, you will build a school management system with a .NET 10 Web API backend and a React frontend. You will create endpoints for managing students and classes, design a relational database, and wire up a clean dashboard interface.
What You'll Build
A full-stack school management system with a RESTful API, a SQL Server database managed by Entity Framework Core migrations, and a React single-page application for the frontend.
By the end of this project, you'll have:
- A working .NET 10 Web API with full CRUD endpoints for Students, Classes, and Enrollments.
- A normalized SQL Server database with proper foreign key relationships and EF Core code-first migrations managing the schema.
- A React frontend with a dashboard, student roster, class management page, and enrollment interface.
- Secret Mission: Add search, filtering, and server-side pagination to the student roster.
Are there any prerequisites?
This project assumes familiarity with C#, React basics, and SQL Server. You should have VS Code, the .NET 10 SDK, Node.js, and SQL Server already installed on your machine.
Before We Start
Before We Start...
Create the .NET Web API Project
Every full-stack application needs a solid backend foundation. In this project, the .NET Web API serves as the backbone that handles all data operations for students, classes, and enrollments.
In this step, you'll scaffold a new .NET 10 Web API project, define your data models, and wire up Entity Framework Core to connect to SQL Server. By the end, your API will have a complete data layer ready for database migrations.
In this step, get ready to:
- Scaffold a .NET 10 Web API project and install EF Core packages.
- Define Student, Class, and Enrollment models with proper relationships.
- Configure the database connection and register the DbContext.
Scaffold the .NET Web API project
The dotnet new CLI scaffolds a controller-based Web API project with routing, middleware, and a sample controller already wired up.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the Web API project by running this command:
dotnet new webapi --use-controllers -o SchoolManagement.API
What does this command do?
dotnet new webapi scaffolds a new ASP.NET Core Web API project.
The --use-controllers flag tells the template to generate a controller-based project instead of the default Minimal API style. The -o SchoolManagement.API flag creates the project in a new folder called SchoolManagement.API.
- Confirm the project was created by running:
ls SchoolManagement.API
You should see files like Program.cs, SchoolManagement.API.csproj, and a Controllers folder in the output.
Command not found?
Make sure you have the .NET 10 SDK installed. Run dotnet --version to check. You should see a version starting with 10. in the output.
help me troubleshoot why dotnet new webapi is not working on my machine
- Move into your new project folder and install the required NuGet packages by running these commands:
cd SchoolManagement.API
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Swashbuckle.AspNetCore
What are these packages?
Microsoft.EntityFrameworkCore.SqlServer is the EF Core provider that lets your app talk to SQL Server.
Microsoft.EntityFrameworkCore.Design provides the design-time tools needed for creating and applying database migrations.
Swashbuckle.AspNetCore adds Swagger UI so you can test your API endpoints in the browser.
Each command should print Determining projects to restore... and finish without errors.
Package install failed?
Make sure you're inside the SchoolManagement.API folder (the one containing SchoolManagement.API.csproj). Run ls to verify you can see the .csproj file.
help me fix a NuGet package install error in my .NET project
- Open the project in VS Code by running:
code .
code command not recognized?
If code . doesn't work, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter. Then use File > Open Folder to open the SchoolManagement.API folder on your Desktop.
Define the data models and database context
The data models define the shape of your database tables. You'll create three models: Student, Class, and Enrollment (a join table that connects students to classes).
- In the VS Code Explorer sidebar, right-click inside the project root and select New Folder.
- Name the folder Models.
- Right-click the Models folder and select New File.
- Name the file Student.cs and paste the following code:
namespace SchoolManagement.API.Models;
public class Student
{
public int Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime EnrolledDate { get; set; } = DateTime.UtcNow;
// Navigation property: a student can have many enrollments
public ICollection<Enrollment> Enrollments { get; set; } = new List<Enrollment>();
}
What does this model do?
Each property maps to a column in the database. Id is the primary key (EF Core detects this by convention).
The Enrollments collection is a navigation property. It tells EF Core that one student can be enrolled in many classes.
- Create another file in the Models folder named Class.cs and paste the following code:
namespace SchoolManagement.API.Models;
public class Class
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Instructor { get; set; } = string.Empty;
// Navigation property: a class can have many enrollments
public ICollection<Enrollment> Enrollments { get; set; } = new List<Enrollment>();
}
What does this model do?
The Class model represents a course offering. Like Student, it has an Enrollments navigation property because one class can have many enrolled students.
- Create one more file in the Models folder named Enrollment.cs and paste the following code:
namespace SchoolManagement.API.Models;
public class Enrollment
{
public int Id { get; set; }
// Foreign key to Student
public int StudentId { get; set; }
public Student Student { get; set; } = null!;
// Foreign key to Class
public int ClassId { get; set; }
public Class Class { get; set; } = null!;
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
}
Why is Enrollment a separate model?
Enrollment is a join entity that connects Students and Classes in a many-to-many relationship. Each enrollment record pairs one student with one class, and tracks when the enrollment happened.
The StudentId and ClassId properties are foreign keys. EF Core uses them to create the database relationships automatically.
Now you'll create the database context, which is EF Core's bridge between your models and the database.
- In the VS Code Explorer sidebar, right-click the project root and select New Folder.
- Name the folder Data.
- Right-click the Data folder, select New File, and name it SchoolDbContext.cs.
- Paste the following code:
using Microsoft.EntityFrameworkCore;
using SchoolManagement.API.Models;
namespace SchoolManagement.API.Data;
public class SchoolDbContext : DbContext
{
public SchoolDbContext(DbContextOptions<SchoolDbContext> options) : base(options) { }
// Each DbSet maps to a database table
public DbSet<Student> Students => Set<Student>();
public DbSet<Class> Classes => Set<Class>();
public DbSet<Enrollment> Enrollments => Set<Enrollment>();
}
What is a DbContext?
The DbContext is EF Core's main class for interacting with the database. Each DbSet property represents a table you can query and save data to.
The constructor accepts DbContextOptions so ASP.NET Core's dependency injection system can configure which database to connect to.
- Save all four files.
You should see no red underlines in the editor. If you see errors in Enrollment.cs referencing Student or Class, make sure all three model files share the same namespace SchoolManagement.API.Models.
Seeing red underlines in the editor?
Check that all three model files (Student.cs, Class.cs, Enrollment.cs) use the same namespace: namespace SchoolManagement.API.Models;.
For SchoolDbContext.cs, confirm you have using Microsoft.EntityFrameworkCore; and using SchoolManagement.API.Models; at the top of the file.
help me fix red underlines in my EF Core models or DbContext file
✔️ Awesome, I've got everything!
Great. Double check you've saved all four files before moving on.
ⓧ I'd like to double check the full code
Here are the complete files you should have in your project:
namespace SchoolManagement.API.Models;
public class Student
{
public int Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime EnrolledDate { get; set; } = DateTime.UtcNow;
// Navigation property: a student can have many enrollments
public ICollection<Enrollment> Enrollments { get; set; } = new List<Enrollment>();
}
namespace SchoolManagement.API.Models;
public class Class
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Instructor { get; set; } = string.Empty;
// Navigation property: a class can have many enrollments
public ICollection<Enrollment> Enrollments { get; set; } = new List<Enrollment>();
}
namespace SchoolManagement.API.Models;
public class Enrollment
{
public int Id { get; set; }
// Foreign key to Student
public int StudentId { get; set; }
public Student Student { get; set; } = null!;
// Foreign key to Class
public int ClassId { get; set; }
public Class Class { get; set; } = null!;
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
}
using Microsoft.EntityFrameworkCore;
using SchoolManagement.API.Models;
namespace SchoolManagement.API.Data;
public class SchoolDbContext : DbContext
{
public SchoolDbContext(DbContextOptions<SchoolDbContext> options) : base(options) { }
// Each DbSet maps to a database table
public DbSet<Student> Students => Set<Student>();
public DbSet<Class> Classes => Set<Class>();
public DbSet<Enrollment> Enrollments => Set<Enrollment>();
}
Configure the database connection
Your models and context are ready, but EF Core doesn't know where to find your SQL Server database yet. You'll add the connection string and register the context with ASP.NET Core's dependency injection.
- In VS Code, open appsettings.json from the Explorer sidebar.
- Replace its entire contents with the following:
{
"ConnectionStrings": {
"SchoolDb": "Server=localhost;Database=SchoolManagementDb;Trusted_Connection=True;TrustServerCertificate=True;"
},
"Logging": { "LogLevel": { "Default": "Information" } },
"AllowedHosts": "*"
}
What does this connection string do?
Server=localhost points to your local SQL Server instance. Database=SchoolManagementDb names the database EF Core will create when you run migrations.
Trusted_Connection=True uses Windows Authentication. TrustServerCertificate=True avoids certificate errors during local development.
- Save appsettings.json.
- Now open Program.cs from the Explorer sidebar.
- Replace its entire contents with the following:
using Microsoft.EntityFrameworkCore;
using SchoolManagement.API.Data;
var builder = WebApplication.CreateBuilder(args);
// Register controllers and Swagger for API documentation
builder.Services.AddControllers();
builder.Services.AddDbContext<SchoolDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("SchoolDb")));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Enable Swagger in development for testing endpoints
if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); }
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
What does this code do?
AddDbContext<SchoolDbContext> registers your database context with ASP.NET Core's dependency injection system. It reads the SchoolDb connection string from appsettings.json and passes it to EF Core's SQL Server provider.
The Swagger lines (AddSwaggerGen, UseSwagger, UseSwaggerUI) give you a browser-based UI to test API endpoints once you build them.
- Save Program.cs.
- Verify the project compiles by running this command in the VS Code terminal (Ctrl+` to open):
dotnet build
You should see Build succeeded with 0 errors in the output.
Build failed?
If you see errors about missing namespaces, confirm the top of Program.cs includes using Microsoft.EntityFrameworkCore; and using SchoolManagement.API.Data;.
If you see errors about AddSwaggerGen, make sure the Swashbuckle.AspNetCore package was installed successfully. Run dotnet add package Swashbuckle.AspNetCore again if needed.
help me fix build errors in my SchoolManagement.API project
✔️ Awesome, I've got everything!
Great. Make sure both appsettings.json and Program.cs are saved.
ⓧ I'd like to double check the full code
{
"ConnectionStrings": {
"SchoolDb": "Server=localhost;Database=SchoolManagementDb;Trusted_Connection=True;TrustServerCertificate=True;"
},
"Logging": { "LogLevel": { "Default": "Information" } },
"AllowedHosts": "*"
}
using Microsoft.EntityFrameworkCore;
using SchoolManagement.API.Data;
var builder = WebApplication.CreateBuilder(args);
// Register controllers and Swagger for API documentation
builder.Services.AddControllers();
builder.Services.AddDbContext<SchoolDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("SchoolDb")));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Enable Swagger in development for testing endpoints
if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); }
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Your data layer is complete. Next up, you'll use EF Core migrations to turn these models into actual database tables in SQL Server.
Set Up the Database with EF Core Migrations
Your data models and DbContext are in place, but right now they only exist as C# classes. To turn them into real database tables, you need a way to translate your code into SQL schema changes.
That's where EF Core Migrations come in. Migrations track every change you make to your models and generate the SQL needed to keep your database in sync. In this step, you'll create your first migration, apply it to SQL Server, and seed the database with sample data.
In this step, get ready to:
- Install the EF Core CLI tools and generate your first migration.
- Apply the migration to create the database and tables.
- Add seed data to populate the database with sample students and classes.
Install the EF Core CLI and create the initial migration
The dotnet-ef tool is a CLI utility that lets you create migrations, apply them, and manage your database schema directly from the terminal. You need to install it globally before you can use any migration commands.
- Install the EF Core CLI tools globally by running this command:
dotnet tool install --global dotnet-ef
What does this command do?
This installs dotnet-ef as a global tool on your machine. Global tools are available from any directory in your terminal, so you only need to install this once.
Seeing a message that it's already installed?
If you already have dotnet-ef installed, the command will tell you. That's fine. You can move on to the next command.
help me troubleshoot the dotnet-ef installation
Now that the tool is installed, you can generate a migration from your models. A migration is a C# file that describes exactly what tables, columns, and relationships to create in the database.
- Make sure your terminal is inside the SchoolManagement.API project folder, then create the initial migration by running:
dotnet ef migrations add InitialCreate
What does this command do?
dotnet ef migrations add InitialCreate inspects your SchoolDbContext and compares it against the current database state (which is empty). It generates a migration file named InitialCreate inside a new Migrations folder.
The migration file contains an Up() method that creates the tables and a Down() method that rolls them back.
- Confirm the migration was created by running:
ls Migrations/
You should see files ending in _InitialCreate.cs and a SchoolDbContextModelSnapshot.cs file listed.
Don't see a Migrations folder?
Make sure your terminal is inside the SchoolManagement.API folder (the one with the .csproj file). Run ls and check that you can see SchoolManagement.API.csproj in the output.
Also verify that the Microsoft.EntityFrameworkCore.Design package is installed in your project. Without it, the migration tooling cannot run.
help me fix my EF Core migration creation error
Apply the migration to create the database
The migration file you just created is like a blueprint. Applying it executes the SQL against your SQL Server instance to create the actual database and tables.
- Apply the migration to create your database by running:
dotnet ef database update
What does this command do?
dotnet ef database update reads your connection string from appsettings.json, connects to SQL Server, and runs the SQL from your pending migrations.
After this runs, you'll have a SchoolManagementDb database with three tables: Students, Classes, and Enrollments. The Enrollments table includes foreign keys pointing to Students and Classes.
You should see output ending with Done. in your terminal, confirming the database was created successfully.
Getting a connection error?
Verify that SQL Server is running on your machine. Check that your connection string in appsettings.json matches your SQL Server instance name.
If you're using a named instance (not the default), update Server=localhost to Server=localhost\YOURINSTANCE in your connection string.
help me fix my SQL Server connection error
Add seed data to populate the database
An empty database isn't very useful for development. Seed data gives you sample records to work with immediately, so you can test your API endpoints without manually inserting rows every time.
- Create a new file called SeedData.cs inside the Data folder of your SchoolManagement.API project.
- Add the following code to SeedData.cs:
public static class SeedData
{
public static void Initialize(SchoolDbContext context)
{
// Skip seeding if data already exists
if (context.Students.Any()) return;
// Add sample students
context.Students.AddRange(
new Student { FirstName = "Alice", LastName = "Johnson", Email = "alice@school.com" },
new Student { FirstName = "Bob", LastName = "Smith", Email = "bob@school.com" },
new Student { FirstName = "Carol", LastName = "Williams", Email = "carol@school.com" }
);
// Add sample classes
context.Classes.AddRange(
new Class { Name = "Mathematics 101", Description = "Intro to Math", Instructor = "Dr. Brown" },
new Class { Name = "English Literature", Description = "Classic Literature", Instructor = "Prof. Davis" }
);
// Persist all seed records to the database
context.SaveChanges();
}
}
What does this code do?
- if (context.Students.Any()) return; makes the method idempotent. If data already exists, it skips seeding so you never get duplicate records.
- AddRange() queues multiple records for insertion in a single call.
- SaveChanges() commits all the queued records to the database in one transaction.
Now you need to call this seed method when the application starts. You'll add a startup block to Program.cs that requests a database context and runs the seeder.
- In Program.cs, find this line:
app.MapControllers();
- Add the following seed block directly above app.MapControllers();:
// Seed the database with sample data on startup
using (var scope = app.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<SchoolDbContext>();
SeedData.Initialize(context);
}
What does this code do?
CreateScope() creates a short-lived dependency injection scope, which is required to resolve scoped services like SchoolDbContext outside of an HTTP request.
GetRequiredService<SchoolDbContext>() retrieves the database context from the DI container. Then SeedData.Initialize() runs the seeder, which inserts sample records only if the tables are empty.
- Save both SeedData.cs and Program.cs.
- Run the application to trigger the seed logic:
dotnet run
The app starts and seeds the database on first launch. You should see it listening on a local port without any errors.
- Press Ctrl+C to stop the application.
Seeing a build error?
Check that SeedData.cs is inside the Data folder and that the class is public static.
Verify that the seed block in Program.cs is placed before app.Run() and after var app = builder.Build().
help me fix my seed data build error
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here's what SeedData.cs should look like:
public static class SeedData
{
public static void Initialize(SchoolDbContext context)
{
// Skip seeding if data already exists
if (context.Students.Any()) return;
// Add sample students
context.Students.AddRange(
new Student { FirstName = "Alice", LastName = "Johnson", Email = "alice@school.com" },
new Student { FirstName = "Bob", LastName = "Smith", Email = "bob@school.com" },
new Student { FirstName = "Carol", LastName = "Williams", Email = "carol@school.com" }
);
// Add sample classes
context.Classes.AddRange(
new Class { Name = "Mathematics 101", Description = "Intro to Math", Instructor = "Dr. Brown" },
new Class { Name = "English Literature", Description = "Classic Literature", Instructor = "Prof. Davis" }
);
// Persist all seed records to the database
context.SaveChanges();
}
}
Here's what Program.cs should look like:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<SchoolDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("SchoolDb")));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); }
app.UseHttpsRedirection();
app.UseAuthorization();
// Seed the database with sample data on startup
using (var scope = app.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<SchoolDbContext>();
SeedData.Initialize(context);
}
app.MapControllers();
app.Run();
Your database is live with tables and sample data ready to go. Next up, you'll build the API controllers that expose this data through RESTful endpoints.
Build the API Controllers
Your database is ready with tables and seed data. Now you need a way for the outside world to interact with that data.
In this step, you'll build API controllers that expose your Student and Class data as REST endpoints. You'll also create DTOs (Data Transfer Objects) to control exactly what data flows in and out of the API.
In this step, get ready to:
- Create the StudentsController with full CRUD endpoints.
- Create the ClassesController with CRUD and enrollment endpoints.
- Add DTOs for clean request/response models and test the API.
Create the StudentsController with CRUD endpoints
Controllers in ASP.NET Core are classes that handle incoming HTTP requests and return responses. Each public method in a controller maps to an API endpoint.
- Create a new file called StudentsController.cs inside the Controllers folder of your SchoolManagement.API project.
- The Controllers folder already exists from the webapi template. If you don't see it, create it at the project root.
- Paste the following code into StudentsController.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SchoolManagement.API.Data;
using SchoolManagement.API.DTOs;
using SchoolManagement.API.Models;
namespace SchoolManagement.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class StudentsController : ControllerBase
{
private readonly SchoolDbContext _context;
public StudentsController(SchoolDbContext context) { _context = context; }
// GET api/students - return all students as DTOs
[HttpGet]
public async Task<IActionResult> GetAll() =>
Ok(await _context.Students.Select(s => new StudentDto(s.Id, s.FirstName, s.LastName, s.Email, s.EnrolledDate)).ToListAsync());
// GET api/students/5 - return a single student by ID
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var student = await _context.Students.FindAsync(id);
if (student == null) return NotFound();
return Ok(new StudentDto(student.Id, student.FirstName, student.LastName, student.Email, student.EnrolledDate));
}
// POST api/students - create a new student
[HttpPost]
public async Task<IActionResult> Create(CreateStudentDto dto)
{
var student = new Student { FirstName = dto.FirstName, LastName = dto.LastName, Email = dto.Email };
_context.Students.Add(student);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = student.Id }, new StudentDto(student.Id, student.FirstName, student.LastName, student.Email, student.EnrolledDate));
}
// PUT api/students/5 - update an existing student
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, UpdateStudentDto dto)
{
var student = await _context.Students.FindAsync(id);
if (student == null) return NotFound();
student.FirstName = dto.FirstName; student.LastName = dto.LastName; student.Email = dto.Email;
await _context.SaveChangesAsync();
return NoContent();
}
// DELETE api/students/5 - remove a student
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var student = await _context.Students.FindAsync(id);
if (student == null) return NotFound();
_context.Students.Remove(student);
await _context.SaveChangesAsync();
return NoContent();
}
}
What does this code do?
The [ApiController] attribute enables automatic model validation and binding. The [Route("api/[controller]")] attribute maps requests to /api/students based on the class name.
- GetAll() projects each Student entity into a StudentDto record, keeping the API response lean.
- Create() accepts a CreateStudentDto (without an Id), builds the entity, saves it, then returns a 201 with the new resource location.
- Update() and Delete() both check if the entity exists first and return 404 if not found.
- Save StudentsController.cs.
- Your editor will show errors for StudentDto, CreateStudentDto, and UpdateStudentDto because those types don't exist yet. You'll create them in the final substep.
Create the ClassesController with CRUD and enrollment endpoints
The ClassesController follows the same pattern but adds enrollment-specific endpoints for adding and removing students from a class.
- Create a new file called ClassesController.cs inside the Controllers folder.
- Paste the following code into ClassesController.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SchoolManagement.API.Data;
using SchoolManagement.API.DTOs;
using SchoolManagement.API.Models;
namespace SchoolManagement.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ClassesController : ControllerBase
{
private readonly SchoolDbContext _context;
public ClassesController(SchoolDbContext context) { _context = context; }
// GET api/classes - return all classes with enrollment counts
[HttpGet]
public async Task<IActionResult> GetAll() =>
Ok(await _context.Classes.Include(c => c.Enrollments).Select(c => new ClassDto(c.Id, c.Name, c.Description, c.Instructor, c.Enrollments.Count)).ToListAsync());
// GET api/classes/5 - return a single class with enrolled students
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var cls = await _context.Classes.Include(c => c.Enrollments).ThenInclude(e => e.Student).FirstOrDefaultAsync(c => c.Id == id);
if (cls == null) return NotFound();
return Ok(cls);
}
// POST api/classes - create a new class
[HttpPost]
public async Task<IActionResult> Create(CreateClassDto dto)
{
var cls = new Class { Name = dto.Name, Description = dto.Description, Instructor = dto.Instructor };
_context.Classes.Add(cls);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = cls.Id }, new ClassDto(cls.Id, cls.Name, cls.Description, cls.Instructor, 0));
}
// DELETE api/classes/5 - remove a class
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var cls = await _context.Classes.FindAsync(id);
if (cls == null) return NotFound();
_context.Classes.Remove(cls);
await _context.SaveChangesAsync();
return NoContent();
}
// POST api/classes/5/enroll - enroll a student in a class
[HttpPost("{id}/enroll")]
public async Task<IActionResult> Enroll(int id, EnrollmentDto dto)
{
var cls = await _context.Classes.FindAsync(id);
var student = await _context.Students.FindAsync(dto.StudentId);
if (cls == null || student == null) return NotFound();
var enrollment = new Enrollment { ClassId = id, StudentId = dto.StudentId };
_context.Enrollments.Add(enrollment);
await _context.SaveChangesAsync();
return Ok();
}
// DELETE api/classes/5/enroll/3 - remove a student from a class
[HttpDelete("{classId}/enroll/{studentId}")]
public async Task<IActionResult> Unenroll(int classId, int studentId)
{
var enrollment = await _context.Enrollments.FirstOrDefaultAsync(e => e.ClassId == classId && e.StudentId == studentId);
if (enrollment == null) return NotFound();
_context.Enrollments.Remove(enrollment);
await _context.SaveChangesAsync();
return NoContent();
}
}
What does this code do?
This controller manages classes and their enrollments. The key differences from the StudentsController are the relationship-aware queries and the enrollment endpoints.
- GetAll() uses .Include(c => c.Enrollments) to eagerly load enrollments, then projects each class into a ClassDto that includes the enrolled student count.
- GetById() chains .ThenInclude(e => e.Student) to load the full student details for each enrollment.
- Enroll() creates a new Enrollment record linking a student to a class. Unenroll() finds and removes that link.
- Save ClassesController.cs.
- You'll see the same DTO errors as before. The next substep resolves them.
Add DTOs and test the API with the .http file
DTOs separate your API contract from your database entities. This prevents accidentally exposing internal fields and keeps your API stable even when the data model changes.
- Create a new folder called DTOs inside your SchoolManagement.API project.
- Create a file called StudentDto.cs inside the DTOs folder and paste this code:
namespace SchoolManagement.API.DTOs;
// Represents a student in API responses
public record StudentDto(int Id, string FirstName, string LastName, string Email, DateTime EnrolledDate);
// Represents the data needed to create a new student
public record CreateStudentDto(string FirstName, string LastName, string Email);
// Represents the data needed to update an existing student
public record UpdateStudentDto(string FirstName, string LastName, string Email);
What are record types?
C# records are immutable reference types designed for holding data. A single line like public record StudentDto(int Id, string FirstName, ...) automatically generates a constructor, properties, equality checks, and a readable ToString() method.
Using records for DTOs keeps the code minimal while giving you all the behaviour you need for data transfer.
- Create a file called ClassDto.cs inside the DTOs folder and paste this code:
namespace SchoolManagement.API.DTOs;
// Represents a class in API responses, includes enrollment count
public record ClassDto(int Id, string Name, string Description, string Instructor, int EnrolledCount);
// Represents the data needed to create a new class
public record CreateClassDto(string Name, string Description, string Instructor);
// Represents the data needed to enroll a student
public record EnrollmentDto(int StudentId);
- Save both DTO files.
- Return to StudentsController.cs and ClassesController.cs. The red squiggles should disappear now that the DTO types exist.
Why use DTOs instead of returning entities directly?
If you return your EF Core entities directly, you expose every property (including navigation properties that can cause circular references). DTOs let you choose exactly which fields to include.
They also decouple your API contract from your database schema. You can rename a column or add a new entity property without breaking existing API consumers.
- Verify the project compiles by running this command from the SchoolManagement.API folder:
dotnet build
- You should see Build succeeded with 0 errors in the terminal output.
Seeing build errors?
- Check that your DTO files are in the DTOs folder and their namespace is SchoolManagement.API.DTOs. The controllers reference this namespace in their using statements.
- Make sure the record parameter names match exactly between the DTOs and how they're constructed in the controllers (e.g., FirstName, not firstName).
- help me fix build errors in my .NET API project
Now start the API so you can test the endpoints.
- Start the API by running:
dotnet run
- Note the URL shown in the terminal output (e.g., http://localhost:5000 or https://localhost:7xxx). You'll need this for the .http file.
- Open or create a file called SchoolManagement.API.http in the project root. The webapi template may have already created this file.
- Replace the file contents with the following test requests (update the port to match your terminal output):
@baseUrl = https://localhost:7001
### Get all students
GET {{baseUrl}}/api/students
### Get student by ID
GET {{baseUrl}}/api/students/1
### Create a new student
POST {{baseUrl}}/api/students
Content-Type: application/json
{
"firstName": "David",
"lastName": "Lee",
"email": "david@school.com"
}
### Update a student
PUT {{baseUrl}}/api/students/1
Content-Type: application/json
{
"firstName": "Alice",
"lastName": "Johnson-Smith",
"email": "alice.updated@school.com"
}
### Delete a student
DELETE {{baseUrl}}/api/students/4
### Get all classes
GET {{baseUrl}}/api/classes
### Enroll a student in a class
POST {{baseUrl}}/api/classes/1/enroll
Content-Type: application/json
{
"studentId": 1
}
### Remove a student from a class
DELETE {{baseUrl}}/api/classes/1/enroll/1
How do .http files work?
The .http file format lets you define HTTP requests directly in your editor. Each request starts with the HTTP method and URL on one line. Requests are separated by ### delimiters.
In VS Code, install the REST Client extension to get a clickable "Send Request" link above each request. The @baseUrl variable at the top is referenced with {{baseUrl}} in each request URL.
- Update the @baseUrl value to match the port shown in your terminal after dotnet run.
- Click **Send Request** above the first GET {{baseUrl}}/api/students request.
- You should see a 200 response with a JSON array containing the three seeded students (Alice, Bob, and Carol).
- Test the POST request to create a new student. You should get a 201 response with the new student's details.
- Test the GET {{baseUrl}}/api/classes request. You should see the two seeded classes with an enrolledCount of 0.
Getting connection refused or 404?
- Make sure dotnet run is still running in your terminal. The API must be active to respond.
- Check that the port number in your @baseUrl matches what the terminal shows. The port varies by project.
- If you see a certificate warning, your browser or client may not trust the development HTTPS certificate. Try using the http:// URL instead.
- help me troubleshoot my .NET API connection issue
✔️ Awesome, I've got everything!
Great. Make sure you've saved all files and stop the API with Ctrl+C in the terminal when you're done testing.
ⓧ I'd like to double check the full code
Here's the complete code for each file you created in this step.
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SchoolManagement.API.Data;
using SchoolManagement.API.DTOs;
using SchoolManagement.API.Models;
namespace SchoolManagement.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class StudentsController : ControllerBase
{
private readonly SchoolDbContext _context;
public StudentsController(SchoolDbContext context) { _context = context; }
[HttpGet]
public async Task<IActionResult> GetAll() =>
Ok(await _context.Students.Select(s => new StudentDto(s.Id, s.FirstName, s.LastName, s.Email, s.EnrolledDate)).ToListAsync());
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var student = await _context.Students.FindAsync(id);
if (student == null) return NotFound();
return Ok(new StudentDto(student.Id, student.FirstName, student.LastName, student.Email, student.EnrolledDate));
}
[HttpPost]
public async Task<IActionResult> Create(CreateStudentDto dto)
{
var student = new Student { FirstName = dto.FirstName, LastName = dto.LastName, Email = dto.Email };
_context.Students.Add(student);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = student.Id }, new StudentDto(student.Id, student.FirstName, student.LastName, student.Email, student.EnrolledDate));
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, UpdateStudentDto dto)
{
var student = await _context.Students.FindAsync(id);
if (student == null) return NotFound();
student.FirstName = dto.FirstName; student.LastName = dto.LastName; student.Email = dto.Email;
await _context.SaveChangesAsync();
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var student = await _context.Students.FindAsync(id);
if (student == null) return NotFound();
_context.Students.Remove(student);
await _context.SaveChangesAsync();
return NoContent();
}
}
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SchoolManagement.API.Data;
using SchoolManagement.API.DTOs;
using SchoolManagement.API.Models;
namespace SchoolManagement.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ClassesController : ControllerBase
{
private readonly SchoolDbContext _context;
public ClassesController(SchoolDbContext context) { _context = context; }
[HttpGet]
public async Task<IActionResult> GetAll() =>
Ok(await _context.Classes.Include(c => c.Enrollments).Select(c => new ClassDto(c.Id, c.Name, c.Description, c.Instructor, c.Enrollments.Count)).ToListAsync());
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var cls = await _context.Classes.Include(c => c.Enrollments).ThenInclude(e => e.Student).FirstOrDefaultAsync(c => c.Id == id);
if (cls == null) return NotFound();
return Ok(cls);
}
[HttpPost]
public async Task<IActionResult> Create(CreateClassDto dto)
{
var cls = new Class { Name = dto.Name, Description = dto.Description, Instructor = dto.Instructor };
_context.Classes.Add(cls);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = cls.Id }, new ClassDto(cls.Id, cls.Name, cls.Description, cls.Instructor, 0));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var cls = await _context.Classes.FindAsync(id);
if (cls == null) return NotFound();
_context.Classes.Remove(cls);
await _context.SaveChangesAsync();
return NoContent();
}
[HttpPost("{id}/enroll")]
public async Task<IActionResult> Enroll(int id, EnrollmentDto dto)
{
var cls = await _context.Classes.FindAsync(id);
var student = await _context.Students.FindAsync(dto.StudentId);
if (cls == null || student == null) return NotFound();
var enrollment = new Enrollment { ClassId = id, StudentId = dto.StudentId };
_context.Enrollments.Add(enrollment);
await _context.SaveChangesAsync();
return Ok();
}
[HttpDelete("{classId}/enroll/{studentId}")]
public async Task<IActionResult> Unenroll(int classId, int studentId)
{
var enrollment = await _context.Enrollments.FirstOrDefaultAsync(e => e.ClassId == classId && e.StudentId == studentId);
if (enrollment == null) return NotFound();
_context.Enrollments.Remove(enrollment);
await _context.SaveChangesAsync();
return NoContent();
}
}
namespace SchoolManagement.API.DTOs;
public record StudentDto(int Id, string FirstName, string LastName, string Email, DateTime EnrolledDate);
public record CreateStudentDto(string FirstName, string LastName, string Email);
public record UpdateStudentDto(string FirstName, string LastName, string Email);
namespace SchoolManagement.API.DTOs;
public record ClassDto(int Id, string Name, string Description, string Instructor, int EnrolledCount);
public record CreateClassDto(string Name, string Description, string Instructor);
public record EnrollmentDto(int StudentId);
Your API is fully functional with CRUD endpoints for both students and classes. Next up, you'll build the React frontend to give these endpoints a visual interface.
Create the React Frontend
Your API is live and responding to requests. Now it needs a face.
In this step, you will scaffold a React application using Vite as the build tool. You will also create a clean service layer so the frontend can talk to your .NET API through a single, consistent interface.
In this step, get ready to:
- Scaffold a React app with Vite and install key dependencies.
- Organize the project into components, pages, and services folders.
- Configure the Vite dev server to proxy API requests to your .NET backend.
Scaffold the React app and install dependencies
Your React app will live in a separate folder alongside the API project. Vite handles bundling and hot-reload during development.
- Open a new terminal in VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
- Navigate to the parent folder that contains your SchoolManagement.API project by running:
cd ~/Desktop
- Scaffold the React project by running this command:
npm create vite@latest school-management-client -- --template react
What does this command do?
This tells npm to download and run the create-vite scaffolding tool. The --template react flag pre-selects React so you skip the interactive framework prompt.
The double dash -- separates npm's own flags from the flags passed to create-vite.
First time running this?
npm may prompt you to install the create-vite package. Type y and press Enter to proceed.
- Move into the new project folder and install the base dependencies by running:
cd school-management-client
npm install
- Install axios for HTTP requests and react-router-dom for client-side routing by running:
npm install axios react-router-dom
Why these two packages?
axios provides a clean interface for making HTTP requests with features like automatic JSON parsing and request interceptors. react-router-dom handles page navigation without full page reloads, which is essential for a multi-page SPA.
- Confirm the packages installed by running:
ls node_modules/axios node_modules/react-router-dom
You should see both folder names listed in the output with no errors.
Seeing 'No such file or directory'?
Make sure you ran npm install axios react-router-dom from inside the school-management-client folder. Run pwd to check your current directory.
Help me figure out why my npm packages didn't install.
Set up the project structure with an API service layer
A consistent folder structure keeps your frontend maintainable as it grows. You will create dedicated folders for pages, reusable components, and services.
- Create the folder structure by running these commands from inside school-management-client:
mkdir src/components src/pages src/services
What is each folder for?
- src/components/ holds reusable UI pieces (buttons, forms, layout elements).
- src/pages/ holds full page views (Dashboard, Students list, Class detail).
- src/services/ holds modules that talk to external systems like your API.
- Confirm the folders were created by running:
ls src
You should see components, pages, and services listed alongside the existing src files like App.jsx and main.jsx.
Now you will create the API service layer. This is a single file that wraps all HTTP calls to your .NET backend. Every page will import from here instead of making raw axios calls.
- Create the API service file by running:
touch src/services/api.js
- Open src/services/api.js in the VS Code editor by clicking it in the Explorer sidebar.
- Add the following code to api.js:
import axios from 'axios';
// Create a shared axios instance with the base URL prefix
const api = axios.create({ baseURL: '/api' });
// Student endpoints
export const studentsApi = {
getAll: () => api.get('/students'),
getById: (id) => api.get(`/students/${id}`),
create: (data) => api.post('/students', data),
update: (id, data) => api.put(`/students/${id}`, data),
delete: (id) => api.delete(`/students/${id}`)
};
// Class endpoints
export const classesApi = {
getAll: () => api.get('/classes'),
getById: (id) => api.get(`/classes/${id}`),
create: (data) => api.post('/classes', data),
delete: (id) => api.delete(`/classes/${id}`),
enroll: (classId, studentId) => api.post(`/classes/${classId}/enroll`, { studentId }),
unenroll: (classId, studentId) => api.delete(`/classes/${classId}/enroll/${studentId}`)
};
export default api;
What does this code do?
axios.create({ baseURL: '/api' }) creates a reusable HTTP client. Every request URL you pass (like /students) is automatically prefixed with /api.
The studentsApi and classesApi objects group related endpoints together. When a page needs to fetch all students, it calls studentsApi.getAll() instead of manually writing the URL and HTTP method each time.
- Save src/services/api.js.
Red squiggly under the import?
If VS Code underlines import axios, it may not have refreshed since you installed the package. Close and reopen the file or restart VS Code.
Help me fix import errors in my api.js file.
Configure the Vite proxy to forward API requests
Your React app runs on one port and your .NET API runs on another. Without a proxy, the browser blocks cross-origin requests. The Vite dev server can forward any request starting with /api directly to your .NET backend.
Why a proxy instead of hardcoding the URL?
A proxy keeps your frontend code portable. Your API service uses relative paths like /api/students that work in development (via the proxy) and in production (where both apps share a domain). No environment-specific URLs to manage.
- Open vite.config.js in the VS Code editor. This file is at the root of your school-management-client folder.
- Replace the entire contents of vite.config.js with the following:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Vite dev server configuration
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Forward all /api requests to the .NET backend
'/api': {
target: 'https://localhost:5001',
changeOrigin: true,
secure: false
}
}
}
});
What does each proxy option do?
- target points to where your .NET API is running.
- changeOrigin: true updates the request's Host header to match the target, preventing CORS issues.
- secure: false allows the proxy to connect to the .NET dev server's self-signed HTTPS certificate without errors.
- Save vite.config.js.
- Start the React dev server to verify everything works by running:
npm run dev
You should see output showing the local development URL (usually http://localhost:5173). The default Vite + React welcome page will display in your browser.
Dev server not starting?
Make sure you are inside the school-management-client folder when you run npm run dev. Also confirm that npm install completed successfully earlier with no errors.
Help me troubleshoot my Vite dev server.
✔️ Awesome, I've got everything!
Great. Double check that you have saved both api.js and vite.config.js before continuing.
ⓧ I'd like to double check the full code
Here is the complete src/services/api.js:
import axios from 'axios';
const api = axios.create({ baseURL: '/api' });
export const studentsApi = {
getAll: () => api.get('/students'),
getById: (id) => api.get(`/students/${id}`),
create: (data) => api.post('/students', data),
update: (id, data) => api.put(`/students/${id}`, data),
delete: (id) => api.delete(`/students/${id}`)
};
export const classesApi = {
getAll: () => api.get('/classes'),
getById: (id) => api.get(`/classes/${id}`),
create: (data) => api.post('/classes', data),
delete: (id) => api.delete(`/classes/${id}`),
enroll: (classId, studentId) => api.post(`/classes/${classId}/enroll`, { studentId }),
unenroll: (classId, studentId) => api.delete(`/classes/${classId}/enroll/${studentId}`)
};
export default api;
Here is the complete vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'https://localhost:5001',
changeOrigin: true,
secure: false
}
}
}
});
Your React frontend is scaffolded, organized, and connected to your .NET backend through the proxy. Next up, you will build the Dashboard and Student pages that bring your data to life.
Build the Dashboard and Student Pages
Your API is serving data and your React app has a service layer ready to consume it. Now it's time to build the pages that turn raw JSON into something a school administrator can actually use.
In this step, you'll create the Dashboard for a quick overview of the school, a Students list page for browsing the roster, and a form for adding or editing student records. Each page will call the API through the service layer you already set up.
In this step, get ready to:
- Create a Dashboard page with summary cards showing total students and total classes.
- Build a Students list page with a data table and action buttons.
- Create an Add/Edit Student form with validation and API integration.
Create the Dashboard page
The Dashboard is the landing page for your school management system. It fetches student and class data on mount, then displays the counts as summary cards.
- In VS Code, create a new file called Dashboard.jsx inside the src/pages/ folder.
- Add the following code to Dashboard.jsx:
import { useEffect, useState } from 'react';
import { studentsApi, classesApi } from '../services/api';
export default function Dashboard() {
// Store student and class arrays from the API
const [students, setStudents] = useState([]);
const [classes, setClasses] = useState([]);
// Fetch both datasets when the component mounts
useEffect(() => {
studentsApi.getAll().then(r => setStudents(r.data));
classesApi.getAll().then(r => setClasses(r.data));
}, []);
return (
<div>
<h1>Dashboard</h1>
<div className="cards">
<div className="card"><h2>{students.length}</h2><p>Total Students</p></div>
<div className="card"><h2>{classes.length}</h2><p>Total Classes</p></div>
</div>
</div>
);
}
What does this code do?
The useEffect hook runs once on mount (empty dependency array) and calls both API endpoints in parallel. The responses populate two state variables.
The JSX renders two summary cards. Each card reads the .length of its respective array, giving you a live count of students and classes.
- Save Dashboard.jsx.
You won't see a change in the browser yet because the Dashboard isn't wired into your router. The next step will handle routing. For now, the file is ready.
Seeing a red squiggly under the import?
Make sure the relative path ../services/api is correct. The file should be at src/services/api.js relative to your project root.
help me fix my Dashboard import path
Build the Students list page
The Students page is the main roster view. It loads all students into a table and provides Edit and Delete buttons on each row so an administrator can manage records.
- Create a new file called Students.jsx inside src/pages/.
- Add the following code to Students.jsx:
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { studentsApi } from '../services/api';
export default function Students() {
const [students, setStudents] = useState([]);
const navigate = useNavigate();
// Load all students on mount
useEffect(() => { studentsApi.getAll().then(r => setStudents(r.data)); }, []);
// Remove a student and update local state
const handleDelete = async (id) => {
await studentsApi.delete(id);
setStudents(students.filter(s => s.id !== id));
};
return (
<div>
<h1>Students</h1>
<button onClick={() => navigate('/students/new')}>Add Student</button>
<table>
<thead><tr><th>Name</th><th>Email</th><th>Enrolled</th><th>Actions</th></tr></thead>
<tbody>{students.map(s => (
<tr key={s.id}>
<td>{s.firstName} {s.lastName}</td>
<td>{s.email}</td>
<td>{new Date(s.enrolledDate).toLocaleDateString()}</td>
<td>
<button onClick={() => navigate(`/students/${s.id}/edit`)}>Edit</button>
<button onClick={() => handleDelete(s.id)}>Delete</button>
</td>
</tr>
))}</tbody>
</table>
</div>
);
}
What does this code do?
- useNavigate from React Router lets you push the user to a different page programmatically (for the Add and Edit buttons).
- handleDelete calls the API to remove the student, then filters the local state array so the row disappears instantly without a full reload.
- The table maps over the students array. Each row shows the full name, email, and a formatted enrollment date.
- Save Students.jsx.
Like the Dashboard, this page won't render in the browser until routing is configured in the next step.
Worried about the Delete button having no confirmation?
For now, clicking Delete immediately removes the student. In a production app you'd add a confirmation dialog, but that's outside the scope of this project.
help me add a confirmation dialog before deleting a student
Create the Add/Edit Student form
A single form component handles both creating and editing a student. It checks the URL for an id parameter. If one exists, it pre-fills the form with that student's data for editing. If not, it starts blank for a new record.
- Create a new file called StudentForm.jsx inside src/pages/.
- Add the following code to StudentForm.jsx:
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { studentsApi } from '../services/api';
export default function StudentForm() {
// Grab the id param from the URL (undefined for new students)
const { id } = useParams();
const navigate = useNavigate();
const [form, setForm] = useState({ firstName: '', lastName: '', email: '' });
const [errors, setErrors] = useState({});
// If editing, pre-fill the form with existing student data
useEffect(() => {
if (id) studentsApi.getById(id).then(r => setForm(r.data));
}, [id]);
// Client-side validation: all fields required
const validate = () => {
const e = {};
if (!form.firstName) e.firstName = 'Required';
if (!form.lastName) e.lastName = 'Required';
if (!form.email) e.email = 'Required';
return e;
};
const handleSubmit = async (e) => {
e.preventDefault();
const errs = validate();
if (Object.keys(errs).length) { setErrors(errs); return; }
// Call update for existing students, create for new ones
if (id) await studentsApi.update(id, form);
else await studentsApi.create(form);
navigate('/students');
};
return (
<form onSubmit={handleSubmit}>
<h1>{id ? 'Edit' : 'Add'} Student</h1>
<input placeholder="First Name" value={form.firstName} onChange={e => setForm({...form, firstName: e.target.value})} />
{errors.firstName && <span>{errors.firstName}</span>}
<input placeholder="Last Name" value={form.lastName} onChange={e => setForm({...form, lastName: e.target.value})} />
{errors.lastName && <span>{errors.lastName}</span>}
<input placeholder="Email" value={form.email} onChange={e => setForm({...form, email: e.target.value})} />
{errors.email && <span>{errors.email}</span>}
<button type="submit">Save</button>
<button type="button" onClick={() => navigate('/students')}>Cancel</button>
</form>
);
}
What does this code do?
- useParams() extracts the id from the URL. When the route is /students/new, id is undefined so the form starts empty.
- The validate function checks that all three fields have values. If any are empty, their error messages render below the input.
- handleSubmit decides between studentsApi.update (PUT) and studentsApi.create (POST) based on whether id exists. After success, it navigates back to the student list.
- Save StudentForm.jsx.
Once routing is added in the next step, navigating to /students/new will show the blank form, and /students/1/edit will pre-fill Alice's data.
Why a single form for both create and edit?
Reusing one component for both operations avoids duplicating input fields and validation logic. The only difference is the API call at submit time. This is a common pattern in CRUD applications.
Form not pre-filling when editing?
Make sure the useEffect dependency array contains [id]. Without it, the effect won't re-run when the URL changes.
Also check that your API is returning the correct shape. The response should include firstName, lastName, and email fields matching the form state keys.
help me debug why my StudentForm isn't pre-filling
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files in src/pages/.
ⓧ I'd like to double check the full code
Here is the full code for each file. Compare yours against these.
import { useEffect, useState } from 'react';
import { studentsApi, classesApi } from '../services/api';
export default function Dashboard() {
const [students, setStudents] = useState([]);
const [classes, setClasses] = useState([]);
useEffect(() => {
studentsApi.getAll().then(r => setStudents(r.data));
classesApi.getAll().then(r => setClasses(r.data));
}, []);
return (
<div>
<h1>Dashboard</h1>
<div className="cards">
<div className="card"><h2>{students.length}</h2><p>Total Students</p></div>
<div className="card"><h2>{classes.length}</h2><p>Total Classes</p></div>
</div>
</div>
);
}
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { studentsApi } from '../services/api';
export default function Students() {
const [students, setStudents] = useState([]);
const navigate = useNavigate();
useEffect(() => { studentsApi.getAll().then(r => setStudents(r.data)); }, []);
const handleDelete = async (id) => {
await studentsApi.delete(id);
setStudents(students.filter(s => s.id !== id));
};
return (
<div>
<h1>Students</h1>
<button onClick={() => navigate('/students/new')}>Add Student</button>
<table>
<thead><tr><th>Name</th><th>Email</th><th>Enrolled</th><th>Actions</th></tr></thead>
<tbody>{students.map(s => (
<tr key={s.id}>
<td>{s.firstName} {s.lastName}</td>
<td>{s.email}</td>
<td>{new Date(s.enrolledDate).toLocaleDateString()}</td>
<td>
<button onClick={() => navigate(`/students/${s.id}/edit`)}>Edit</button>
<button onClick={() => handleDelete(s.id)}>Delete</button>
</td>
</tr>
))}</tbody>
</table>
</div>
);
}
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { studentsApi } from '../services/api';
export default function StudentForm() {
const { id } = useParams();
const navigate = useNavigate();
const [form, setForm] = useState({ firstName: '', lastName: '', email: '' });
const [errors, setErrors] = useState({});
useEffect(() => {
if (id) studentsApi.getById(id).then(r => setForm(r.data));
}, [id]);
const validate = () => {
const e = {};
if (!form.firstName) e.firstName = 'Required';
if (!form.lastName) e.lastName = 'Required';
if (!form.email) e.email = 'Required';
return e;
};
const handleSubmit = async (e) => {
e.preventDefault();
const errs = validate();
if (Object.keys(errs).length) { setErrors(errs); return; }
if (id) await studentsApi.update(id, form);
else await studentsApi.create(form);
navigate('/students');
};
return (
<form onSubmit={handleSubmit}>
<h1>{id ? 'Edit' : 'Add'} Student</h1>
<input placeholder="First Name" value={form.firstName} onChange={e => setForm({...form, firstName: e.target.value})} />
{errors.firstName && <span>{errors.firstName}</span>}
<input placeholder="Last Name" value={form.lastName} onChange={e => setForm({...form, lastName: e.target.value})} />
{errors.lastName && <span>{errors.lastName}</span>}
<input placeholder="Email" value={form.email} onChange={e => setForm({...form, email: e.target.value})} />
{errors.email && <span>{errors.email}</span>}
<button type="submit">Save</button>
<button type="button" onClick={() => navigate('/students')}>Cancel</button>
</form>
);
}
Your pages are built and ready to display data. Next up, you'll create the class management pages and wire everything together with React Router so you can navigate between Dashboard, Students, and Classes.
Build Class Management and Enrollment
Your student pages are complete with full CRUD functionality. Now you need a way to manage classes and connect students to them through enrollments.
In this step, you will build class management pages and wire up React Router to tie the entire app together with a sidebar navigation layout.
In this step, get ready to:
- Create a Classes list page showing each class and its enrolled student count.
- Build a Class Detail page with add and remove enrollment controls.
- Wire up React Router with a sidebar layout for full app navigation.
Create the Classes list page
The Classes page displays all available classes in a table format. Each row shows the class name, instructor, how many students are enrolled, and a delete button.
- Create a new file at src/pages/Classes.jsx inside your school-management-client folder.
- Add the following code to build the Classes list page:
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { classesApi } from '../services/api';
export default function Classes() {
const [classes, setClasses] = useState([]);
const navigate = useNavigate();
// Fetch all classes when the component loads
useEffect(() => { classesApi.getAll().then(r => setClasses(r.data)); }, []);
// Delete a class and remove it from the local list
const handleDelete = async (id) => {
await classesApi.delete(id);
setClasses(classes.filter(c => c.id !== id));
};
return (
<div>
<h1>Classes</h1>
<table>
<thead><tr><th>Name</th><th>Instructor</th><th>Students</th><th>Actions</th></tr></thead>
<tbody>{classes.map(c => (
<tr key={c.id}>
<td><a onClick={() => navigate(`/classes/${c.id}`)}>{c.name}</a></td>
<td>{c.instructor}</td>
<td>{c.enrolledCount}</td>
<td><button onClick={() => handleDelete(c.id)}>Delete</button></td>
</tr>
))}</tbody>
</table>
</div>
);
}
What does this code do?
This component follows the same pattern as your Students.jsx page. It fetches all classes from the API on mount, then renders them in a table.
The enrolledCount field comes directly from the ClassDto your API returns. The class name is wrapped in a clickable <a> tag that navigates to the class detail page.
- Save Classes.jsx.
Seeing an import error?
Make sure your file is in src/pages/Classes.jsx (capital C). The import path in the router must match the filename exactly.
If you see a warning about useNavigate, confirm that react-router-dom is installed in your project from the previous step.
help me debug my Classes page import
✔️ Awesome, I've got everything!
Great. Make sure you have saved Classes.jsx before moving on.
ⓧ I'd like to double check the full code
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { classesApi } from '../services/api';
export default function Classes() {
const [classes, setClasses] = useState([]);
const navigate = useNavigate();
useEffect(() => { classesApi.getAll().then(r => setClasses(r.data)); }, []);
const handleDelete = async (id) => {
await classesApi.delete(id);
setClasses(classes.filter(c => c.id !== id));
};
return (
<div>
<h1>Classes</h1>
<table>
<thead><tr><th>Name</th><th>Instructor</th><th>Students</th><th>Actions</th></tr></thead>
<tbody>{classes.map(c => (
<tr key={c.id}>
<td><a onClick={() => navigate(`/classes/${c.id}`)}>{c.name}</a></td>
<td>{c.instructor}</td>
<td>{c.enrolledCount}</td>
<td><button onClick={() => handleDelete(c.id)}>Delete</button></td>
</tr>
))}</tbody>
</table>
</div>
);
}
Build the Class Detail page with enrollment controls
When a user clicks a class name, they need a detail page showing who is enrolled and a way to add or remove students. This is where your enrollment API endpoints come into play.
- Create a new file at src/pages/ClassDetail.jsx inside your school-management-client folder.
- Add the following code to build the enrollment interface:
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { classesApi, studentsApi } from '../services/api';
export default function ClassDetail() {
const { id } = useParams();
const [cls, setCls] = useState(null);
const [allStudents, setAllStudents] = useState([]);
const [selectedStudentId, setSelectedStudentId] = useState('');
// Fetch class details and all students on load
useEffect(() => {
classesApi.getById(id).then(r => setCls(r.data));
studentsApi.getAll().then(r => setAllStudents(r.data));
}, [id]);
// Enroll selected student then refresh class data
const handleEnroll = async () => {
await classesApi.enroll(id, parseInt(selectedStudentId));
classesApi.getById(id).then(r => setCls(r.data));
};
// Remove a student from this class
const handleUnenroll = async (studentId) => {
await classesApi.unenroll(id, studentId);
classesApi.getById(id).then(r => setCls(r.data));
};
if (!cls) return <p>Loading...</p>;
// Filter out students who are already enrolled
const enrolledIds = cls.enrollments.map(e => e.studentId);
const unenrolledStudents = allStudents.filter(s => !enrolledIds.includes(s.id));
return (
<div>
<h1>{cls.name}</h1>
<p>Instructor: {cls.instructor}</p>
<h2>Enrolled Students</h2>
<ul>{cls.enrollments.map(e => (
<li key={e.studentId}>{e.student.firstName} {e.student.lastName}
<button onClick={() => handleUnenroll(e.studentId)}>Remove</button>
</li>
))}</ul>
<h2>Add Student</h2>
<select value={selectedStudentId} onChange={e => setSelectedStudentId(e.target.value)}>
<option value="">Select a student</option>
{unenrolledStudents.map(s => <option key={s.id} value={s.id}>{s.firstName} {s.lastName}</option>)}
</select>
<button onClick={handleEnroll} disabled={!selectedStudentId}>Enroll</button>
</div>
);
}
What does this code do?
This page fetches both the class details (including its enrollments) and the full student list. It compares the two to build a dropdown showing only students who are not already enrolled.
The handleEnroll function calls the /api/classes/:id/enroll endpoint you built earlier, then re-fetches the class to update the enrolled list. The handleUnenroll function does the reverse using the delete enrollment endpoint.
- Save ClassDetail.jsx.
Dropdown showing no students?
If the dropdown is empty, all students may already be enrolled in the class. Check the enrolled list above the dropdown. You can remove a student first, then verify they appear in the dropdown.
Also verify that your api.js service file has both studentsApi and classesApi exported.
help me debug my ClassDetail enrollment dropdown
✔️ Awesome, I've got everything!
Great. Make sure ClassDetail.jsx is saved.
ⓧ I'd like to double check the full code
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { classesApi, studentsApi } from '../services/api';
export default function ClassDetail() {
const { id } = useParams();
const [cls, setCls] = useState(null);
const [allStudents, setAllStudents] = useState([]);
const [selectedStudentId, setSelectedStudentId] = useState('');
useEffect(() => {
classesApi.getById(id).then(r => setCls(r.data));
studentsApi.getAll().then(r => setAllStudents(r.data));
}, [id]);
const handleEnroll = async () => {
await classesApi.enroll(id, parseInt(selectedStudentId));
classesApi.getById(id).then(r => setCls(r.data));
};
const handleUnenroll = async (studentId) => {
await classesApi.unenroll(id, studentId);
classesApi.getById(id).then(r => setCls(r.data));
};
if (!cls) return <p>Loading...</p>;
const enrolledIds = cls.enrollments.map(e => e.studentId);
const unenrolledStudents = allStudents.filter(s => !enrolledIds.includes(s.id));
return (
<div>
<h1>{cls.name}</h1>
<p>Instructor: {cls.instructor}</p>
<h2>Enrolled Students</h2>
<ul>{cls.enrollments.map(e => (
<li key={e.studentId}>{e.student.firstName} {e.student.lastName}
<button onClick={() => handleUnenroll(e.studentId)}>Remove</button>
</li>
))}</ul>
<h2>Add Student</h2>
<select value={selectedStudentId} onChange={e => setSelectedStudentId(e.target.value)}>
<option value="">Select a student</option>
{unenrolledStudents.map(s => <option key={s.id} value={s.id}>{s.firstName} {s.lastName}</option>)}
</select>
<button onClick={handleEnroll} disabled={!selectedStudentId}>Enroll</button>
</div>
);
}
Wire up React Router and add a sidebar layout
Right now your pages exist but there is no way to navigate between them in the browser. You need a shared layout with a sidebar and a router that maps URL paths to page components.
- Create a new folder src/components inside school-management-client, then create Layout.jsx inside it.
- Add the following code to build the sidebar navigation layout:
import { NavLink, Outlet } from 'react-router-dom';
export default function Layout() {
return (
<div style={{ display: 'flex' }}>
{/* Sidebar navigation */}
<nav style={{ width: 200, padding: 16 }}>
<h2>School App</h2>
<ul>
<li><NavLink to="/">Dashboard</NavLink></li>
<li><NavLink to="/students">Students</NavLink></li>
<li><NavLink to="/classes">Classes</NavLink></li>
</ul>
</nav>
{/* Main content area renders the matched child route */}
<main style={{ flex: 1, padding: 16 }}><Outlet /></main>
</div>
);
}
What is a layout route?
A layout route renders shared UI (like a sidebar) that wraps all child routes. The <Outlet /> component is a placeholder where React Router renders whichever child route matches the current URL.
Using NavLink instead of a regular <a> tag gives you automatic active class styling on the link that matches the current page.
- Save Layout.jsx.
Now you need to update App.jsx to use this layout and define all your routes.
- Open src/App.jsx and replace its entire contents with the following:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Students from './pages/Students';
import StudentForm from './pages/StudentForm';
import Classes from './pages/Classes';
import ClassDetail from './pages/ClassDetail';
export default function App() {
return (
<BrowserRouter>
<Routes>
{/* Layout wraps all pages with the sidebar */}
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="students" element={<Students />} />
<Route path="students/new" element={<StudentForm />} />
<Route path="students/:id/edit" element={<StudentForm />} />
<Route path="classes" element={<Classes />} />
<Route path="classes/:id" element={<ClassDetail />} />
</Route>
</Routes>
</BrowserRouter>
);
}
What does this routing structure do?
The parent <Route path="/" element={<Layout />}> renders the sidebar on every page. Each child route renders its component inside the <Outlet /> of the Layout.
The index route matches the exact / path and shows the Dashboard. Routes like students/:id/edit use a URL parameter that the useParams hook reads inside the component.
- Save App.jsx.
- Return to your browser. You should see a sidebar on the left with links to Dashboard, Students, and Classes.
- Click the Classes link in the sidebar. You should see a table with Mathematics 101 and English Literature listed.
- Click a class name to open its detail page. You should see the instructor name, an empty enrolled students list, and an Add Student dropdown.
Seeing a blank page or console errors?
Check that all import paths in App.jsx match your actual file names and locations. The most common issue is a mismatched path like ./pages/classes instead of ./pages/Classes (case matters).
If the sidebar renders but page content does not, verify that your Layout component includes <Outlet /> from react-router-dom. Without it, child routes have nowhere to render.
help me debug my React Router layout
✔️ Awesome, I've got everything!
Great. Double check that all four files are saved: Classes.jsx, ClassDetail.jsx, Layout.jsx, and App.jsx.
ⓧ I'd like to double check the full code
Here is the full code for Layout.jsx:
import { NavLink, Outlet } from 'react-router-dom';
export default function Layout() {
return (
<div style={{ display: 'flex' }}>
<nav style={{ width: 200, padding: 16 }}>
<h2>School App</h2>
<ul>
<li><NavLink to="/">Dashboard</NavLink></li>
<li><NavLink to="/students">Students</NavLink></li>
<li><NavLink to="/classes">Classes</NavLink></li>
</ul>
</nav>
<main style={{ flex: 1, padding: 16 }}><Outlet /></main>
</div>
);
}
Here is the full code for App.jsx:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Students from './pages/Students';
import StudentForm from './pages/StudentForm';
import Classes from './pages/Classes';
import ClassDetail from './pages/ClassDetail';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="students" element={<Students />} />
<Route path="students/new" element={<StudentForm />} />
<Route path="students/:id/edit" element={<StudentForm />} />
<Route path="classes" element={<Classes />} />
<Route path="classes/:id" element={<ClassDetail />} />
</Route>
</Routes>
</BrowserRouter>
);
}
Your school management system is fully functional. You have a dashboard, student management with CRUD, and class management with enrollment. All pages are accessible through the sidebar navigation.
Secret mission
Add Search, Filtering, and Pagination
Your student roster works, but what happens when there are hundreds of students? Right now the API dumps every record at once. In this mission, you'll add server-side search and pagination so the system stays fast at scale.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine. There are no ongoing cloud costs, but you may want to free up disk space or remove the SQL Server database you created.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- SchoolManagement.API .NET project folder (on your Desktop).
- school-management-client React project folder (on your Desktop).
- SchoolManagementDb SQL Server database.
- dotnet-ef global tool.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project with more features.
- Your project folders remain on your Desktop.
- The SchoolManagementDb database stays in your local SQL Server instance.
- The dotnet-ef global tool remains installed for future EF Core projects.
Pause - I'll come back to this later
Stop any running processes to free up memory. Your files and database stay intact for when you return.
- Stop the .NET dev server if it is running by pressing Ctrl+C in its terminal.
- Stop the Vite dev server if it is running by pressing Ctrl+C in its terminal.
- Close any open VS Code windows for the project.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your code, database, and the EF Core global tool.
- Stop any running dev servers by pressing Ctrl+C in their terminals.
Delete the SQL Server database by running this command in your terminal:
sqlcmd -S localhost -Q "DROP DATABASE SchoolManagementDb;"
What does this command do?
This connects to your local SQL Server instance and runs the T-SQL DROP DATABASE statement to permanently delete the SchoolManagementDb database and all its data files.
- Delete the .NET API project folder by running:
rm -rf ~/Desktop/SchoolManagement.API
- Delete the React client folder by running:
rm -rf ~/Desktop/school-management-client
Windows users
On Windows, use these commands in PowerShell instead:
Remove-Item -Recurse -Force ~\Desktop\SchoolManagement.API
Remove-Item -Recurse -Force ~\Desktop\school-management-client
- Uninstall the dotnet-ef global tool by running:
dotnet tool uninstall --global dotnet-ef
- Confirm removal by running:
dotnet tool list --global
You should no longer see dotnet-ef in the list.
Nice Work!
Nice Work!
Nice work! You just built a full-stack school management system from scratch using .NET 10 and React.
You've learned how to:
- Built a .NET 10 Web API with full CRUD controllers, DTOs, and a SQL Server database managed by EF Core code-first migrations.
- Designed a normalized relational schema with Students, Classes, and Enrollments connected by proper foreign key relationships.
- Created a React frontend with Vite, including a dashboard, student CRUD pages, class enrollment, and a clean API service layer connecting everything to the backend.
- Added server-side search, filtering, and pagination to the student roster for production-ready data handling.
Ready to quiz yourself?