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:
- Protected endpoints use
[Authorize] attribute
- Auth endpoints extract
UserId from JWT claims via User.FindFirstValue(ClaimTypes.NameIdentifier)
- Service-layer exceptions (
KeyNotFoundException, InvalidOperationException) return user-friendly JSON via controller try/catch or global middleware
- Empty
NotFound() replaced with NotFound(new { error = "..." }) for descriptive messages
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:
- Create a free TiDB Cloud account and start a Serverless cluster
- Go to Connect → copy the connection string (user, password, host, port)
- Create a database named
blogcms
- Update
appsettings.json with the connection string
- 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):
- Push the project to a GitHub repo (
fahmykamal/blog-cms-api)
- In Render dashboard → New + → Web Service
- Connect the GitHub repo
- 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.
- 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:
- CORS headers must be set before
app.UseAuthentication()
- OPTIONS preflight must be handled explicitly (return 204)
- Exception middleware must re-add CORS headers on error responses
- A 500 error (e.g. FK violation) can look like a CORS error in the browser when headers are missing
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