Build Guide

Blog CMS API — .NET 9 + MySQL + JWT

Step-by-step walkthrough of building this API from scratch. Repo: github.com/fahmykamal/blog-cms-api

1 Project Setup

cli Create a new .NET 9 Web API project:

dotnet new webapi -n BlogCMS.Api cd BlogCMS.Api

package Add required NuGet packages:

dotnet add package Pomelo.EntityFrameworkCore.MySql --version 9.0.0 dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 9.0.0 dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection --version 12.0.1 dotnet add package BCrypt.Net-Next --version 4.2.0 dotnet add package Microsoft.EntityFrameworkCore.Design --version 9.0.0

file appsettings.json — configure connection string + JWT:

{ "ConnectionStrings": { "DefaultConnection": "Server=gateway01.ap-southeast-1.prod.aws.tidbcloud.com;Port=4000;Database=blogcms;User=...;Password=...;SslMode=VerifyFull;" }, "Jwt": { "Key": "your-256-bit-secret-key-here", "Issuer": "BlogCMS", "Audience": "BlogCMS.Client" } }

2 Models

Create the domain models under Models/. These map to database tables via EF Core.

User

public class User { public int Id { get; set; } public string Username { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; public string PasswordHash { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; }

Post

public class Post { public int Id { get; set; } public string Title { get; set; } = string.Empty; public string Slug { get; set; } = string.Empty; public string Content { get; set; } = string.Empty; public string? Excerpt { get; set; } public string? FeaturedImageUrl { get; set; } public bool IsPublished { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } public DateTime? PublishedAt { get; set; } public int AuthorId { get; set; } public User Author { get; set; } = null!; public int CategoryId { get; set; } public Category Category { get; set; } = null!; public ICollection<PostTag> Tags { get; set; } = new List<PostTag>(); public ICollection<Comment> Comments { get; set; } = new List<Comment>(); }

Category

public class Category { public int Id { get; set; } public string Name { get; set; } = string.Empty; public string Slug { get; set; } = string.Empty; }

Comment

public class Comment { public int Id { get; set; } public string Content { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public int PostId { get; set; } public Post Post { get; set; } = null!; public int AuthorId { get; set; } public User Author { get; set; } = null!; }

Tag & PostTag

public class Tag { public int Id { get; set; } public string Name { get; set; } = string.Empty; public string Slug { get; set; } = string.Empty; } public class PostTag { public int PostId { get; set; } public Post Post { get; set; } = null!; public int TagId { get; set; } public Tag Tag { get; set; } = null!; }

3 DbContext

file Data/AppDbContext.cs — register all entities and configure relationships:

public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> opts) : base(opts) { } public DbSet<User> Users => Set<User>(); public DbSet<Post> Posts => Set<Post>(); public DbSet<Category> Categories => Set<Category>(); public DbSet<Comment> Comments => Set<Comment>(); public DbSet<Tag> Tags => Set<Tag>(); public DbSet<PostTag> PostTags => Set<PostTag>(); protected override void OnModelCreating(ModelBuilder builder) { builder.Entity<PostTag>() .HasKey(pt => new { pt.PostId, pt.TagId }); builder.Entity<Post>() .HasIndex(p => p.Slug).IsUnique(); builder.Entity<Category>() .HasIndex(c => c.Slug).IsUnique(); } }

cli Create and apply the initial migration:

dotnet ef migrations add InitialCreate dotnet ef database update

4 DTOs & AutoMapper

file DTOs/ — request/response classes shape the API contract:

// PostDtos.cs public class PostRequest { public string Title { get; set; } = ""; public string Content { get; set; } = ""; public string? Excerpt { get; set; } public string? FeaturedImageUrl { get; set; } public bool IsPublished { get; set; } public int CategoryId { get; set; } public List<int> TagIds { get; set; } = new(); } public class PostResponse { /* full post with author, category, tags, comments */ } public class PostSummaryResponse { /* list-friendly subset */ } // AuthDtos.cs — RegisterRequest, LoginRequest, AuthResponse // CategoryDtos.cs — CategoryRequest, CategoryDto // CommentDtos.cs — CommentRequest, CommentResponse

file Profiles/MappingProfile.cs — define AutoMapper mappings:

public class MappingProfile : Profile { public MappingProfile() { CreateMap<Post, PostResponse>(); CreateMap<Post, PostSummaryResponse>(); CreateMap<Category, CategoryDto>(); CreateMap<Comment, CommentResponse>(); CreateMap<User, AuthorDto>(); CreateMap<Tag, TagDto>(); } }

5 Auth Service (JWT)

file Services/AuthService.cs — handles registration, login, and JWT token generation:

public class AuthService : IAuthService { private readonly AppDbContext _db; private readonly IConfiguration _config; public async Task<AuthResponse> RegisterAsync(RegisterRequest request) { if (await _db.Users.AnyAsync(u => u.Username == request.Username)) throw new InvalidOperationException("Username already taken."); var user = new User { Username = request.Username, Email = request.Email, PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), DisplayName = request.DisplayName, }; _db.Users.Add(user); await _db.SaveChangesAsync(); return new AuthResponse { Token = GenerateJwt(user), ExpiresAt = DateTime.UtcNow.AddDays(1), User = _mapper.Map<UserDto>(user), }; } // LoginAsync: validate credentials with BCrypt.Verify(), throw UnauthorizedAccessException on failure // GenerateJwt: create a JWT with NameIdentifier + Name claims, 1-day expiry }

The GenerateJwt method creates a token using SymmetricSecurityKey and SigningCredentials with the key from appsettings.json.

6 Business Services

PostService

public class PostService : IPostService { private readonly AppDbContext _db; private readonly IMapper _mapper; // GetAllAsync — query with Include (Author, Category, Tags, Comments), // filter by publishedOnly, order by PublishedAt desc // GetBySlugAsync — FirstOrDefault with all Includes // CreateAsync — validate unique slug, validate CategoryId exists, // create Post, attach Tags, SaveChangesAsync // UpdateAsync — find post, validate CategoryId, update fields, // replace tags, SaveChangesAsync // DeleteAsync — FindAsync, Remove, SaveChangesAsync // GenerateSlug — lowercase, replace spaces with hyphens, strip special chars }

CategoryService

// GetAllAsync — list ordered by Name // CreateAsync — generate slug from name, check for duplicates // DeleteAsync — FindAsync, Remove, SaveChangesAsync

CommentService

// GetByPostIdAsync — filter by PostId, order by CreatedAt desc // CreateAsync — validate PostId exists, create Comment, SaveChangesAsync // DeleteAsync — FindAsync, Remove, SaveChangesAsync

All services follow the interface + implementation pattern (IPostService, ICategoryService, ICommentService) and are registered in DI.

7 Controllers

Controllers handle HTTP requests, call services, and return IActionResult. Each is decorated with [ApiController] and [Route("api/[controller]")].

PostsController

[ApiController] [Route("api/[controller]")] public class PostsController : ControllerBase { [HttpGet] // GET /api/posts?published=true [HttpGet("{slug}")] // GET /api/posts/{slug} [HttpPost] // [Authorize] POST /api/posts [HttpPut("{id}")] // [Authorize] PUT /api/posts/{id} [HttpDelete("{id}")] // [Authorize] DELETE /api/posts/{id} }

Key patterns:

8 Program.cs — Middleware & DI

file Program.cs wires everything together:

var builder = WebApplication.CreateBuilder(args); // 1. DbContext — MySQL with Pomelo builder.Services.AddDbContext<AppDbContext>(options => options.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0)))); // 2. JWT Authentication builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { /* configure TokenValidationParameters */ }); builder.Services.AddAuthorization(); // 3. AutoMapper builder.Services.AddAutoMapper(typeof(Program)); // 4. DI — register services builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IPostService, PostService>(); builder.Services.AddScoped<ICategoryService, CategoryService>(); builder.Services.AddScoped<ICommentService, CommentService>(); builder.Services.AddControllers(); var app = builder.Build(); // 5. Auto-migrate on startup using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); db.Database.Migrate(); } // 6. CORS raw middleware — sets headers before every response, // handles OPTIONS preflight, catches exceptions app.Use(async (ctx, next) => { ctx.Response.Headers["Access-Control-Allow-Origin"] = "*"; ctx.Response.Headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"; ctx.Response.Headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"; if (ctx.Request.Method == "OPTIONS") { ctx.Response.StatusCode = 204; return; } try { await next(); } catch (Exception ex) { ctx.Response.Headers["Access-Control-Allow-Origin"] = "*"; ctx.Response.ContentType = "application/json; charset=utf-8"; ctx.Response.StatusCode = ex switch { KeyNotFoundException => 404, InvalidOperationException => 409, _ => 500, }; var msg = ex is KeyNotFoundException or InvalidOperationException ? ex.Message : "An unexpected error occurred."; await ctx.Response.WriteAsJsonAsync(new { error = msg }); } }); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();

9 Database — TiDB Cloud

db This API uses TiDB Cloud for a free MySQL-compatible database:

  1. Create a free TiDB Cloud account and start a Serverless cluster
  2. Go to Connect → copy the connection string (user, password, host, port)
  3. Create a database named blogcms
  4. Update appsettings.json with the connection string
  5. Run dotnet ef database update to apply migrations

Alternatively, use any MySQL 8.0 instance (local, Aiven, PlanetScale, etc.).

10 Deployment — Render

deploy The API is deployed on Render (free tier):

  1. Push the project to a GitHub repo (fahmykamal/blog-cms-api)
  2. In Render dashboard → New + → Web Service
  3. Connect the GitHub repo
  4. Configure:
    • Name: blog-cms-api
    • Runtime: .NET 9
    • Build Command: dotnet restore && dotnet publish -c Release -o out
    • Start Command: dotnet out/BlogCMS.Api.dll
    • Add environment variables: ConnectionStrings__DefaultConnection, Jwt__Key, etc.
  5. Deploy — Render auto-builds and hosts at https://blog-cms-api-iekn.onrender.com

note TiDB Cloud's free tier may spin down after 5 minutes of inactivity. The first request after idle will have a ~5s cold start while the DB wakes up.

11 CORS & Frontend

The API playground lives on GitHub Pages (fahmykamal.github.io). CORS is handled by the raw middleware in Program.cs (step 8).

Key things we learned:

12 GitHub Actions (CI)

ci Optional: add a GitHub Actions workflow to build and test on every push:

# .github/workflows/ci.yml name: CI on: [push] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: '9.x' - run: dotnet restore - run: dotnet build --no-restore - run: dotnet test --no-build