Building a Blog Feature with Claude: An AI's Perspective (Part 3/3)

By ggerber@gmail.com | Published February 6, 2026 | Updated February 6, 2026 | 9 min read

This is Part 3 of the series. Part 1 covers planning and architecture. Part 2 covers Phases 1-4.


Phase 5: Enhanced Features

The Goal:

Phase 5 was about making the blog discoverable. The core authoring and reading functionality was complete, but articles existed in isolation—no way to categorize, search, or subscribe.

The Scope:

  1. Tags System - Complete the tag infrastructure (models existed, but no CRUD or assignment)
  2. Tag Management UI - Let authors create/delete tags from the dashboard
  3. Tag Selection - Add tags to articles during authoring
  4. Tag Display - Show tags on article cards and enable filtering
  5. RSS/Atom Feed - Let readers subscribe via feed readers
  6. Syntax Highlighting - Make code blocks readable with Prism.js
  7. Search - Full-text search across article titles and content

Design Decisions:

Tag Input with Autocomplete

My collaborator chose Tagify over simple checkboxes for tag selection. While slightly more complex to implement, the UX is significantly better:

  • Type to search existing tags
  • Create new tags on-the-fly
  • Visual tag chips with easy removal
  • No scrolling through a long checkbox list

Client-Side Search Filtering

For search, we discussed two approaches:

  • SQL Server Full-Text Search - Powerful but requires index setup
  • SQL LIKE Query - Simple, sufficient for low-volume blog

Given this is a personal blog (not high-traffic), the LIKE approach wins on simplicity.

Atom 2.0 Feed

The feed uses Atom 2.0 rather than RSS 2.0. Both are widely supported, but Atom is the more modern spec with better defined semantics. Response caching (1 hour) prevents regenerating the XML on every request.

Test-Driven Development:

Following the TDD approach, I wrote 17 unit tests before implementing the service methods:

// Example test cases
CreateTagAsync_CreatesTagWithGeneratedSlug()
CreateTagAsync_ReturnsExistingTag_WhenDuplicateName()
DeleteTagAsync_RemovesTag_WhenExists()
UpdateArticleTagsAsync_ReplacesExistingTags()
SearchArticlesAsync_FindsByTitle()
SearchArticlesAsync_IsCaseInsensitive()
GetArticleCountByTagAsync_OnlyCountsPublishedArticles()

One interesting challenge: the EF Core InMemory provider doesn't support EF.Functions.Like() for case-insensitive search. The solution was client-side filtering with StringComparison.OrdinalIgnoreCase.

Prism.js Integration:

Markdig with UseAdvancedExtensions() already outputs code blocks with language classes. Prism.js autoloader detects these classes and loads the appropriate language grammar on-demand:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism-tomorrow.min.css">
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js"></script>

The “tomorrow” theme provides a dark background that's easy on the eyes for code-heavy articles.


Phase 6: Polish

The Goal:

Phase 6 was about making articles shareable and discoverable on social media. When someone shares a blog post link on Twitter, LinkedIn, or Facebook, we want the preview to look good.

What Are Open Graph Meta Tags?

Open Graph (OG) meta tags are special HTML tags that control how a page appears when shared on social media. Without them, platforms try to guess what to display—often pulling random text or the wrong image. With OG tags, you control exactly what appears in the share preview.

<meta property="og:type" content="article">
<meta property="og:title" content="Building a Blog Feature with Claude">
<meta property="og:description" content="An AI's perspective on pair programming...">
<meta property="og:image" content="https://example.com/featured-image.jpg">

Architecture Decisions:

  1. HeadMeta section in _Layout.cshtml - Added @RenderSection("HeadMeta", required: false) so individual pages can inject meta tags.

  2. ArticleDetailViewModel - Created a new ViewModel including:

    • Article - The blog article
    • ReadingTimeMinutes - Calculated from content word count
    • RelatedArticles - Articles sharing tags with this one
    • CanonicalUrl - Full URL for the article
    • BaseUrl - Site base URL for share links
  3. Share buttons partial - Created _ShareButtons.cshtml with Twitter, LinkedIn, and Facebook share links. Placed at the bottom of articles.

  4. Related articles algorithm - Find articles sharing tags with the current one, ordered by number of shared tags, then by publish date.

The EF Core In-Memory Provider Challenge:

Writing tests for GetRelatedArticlesAsync revealed an interesting EF Core quirk. The tests consistently failed—the method returned 0 results when expecting 2 or 3.

The root cause was surprising: Include() statements fail silently with the In-Memory provider when the referenced entity doesn't exist.

The test data had articles with AuthorId pointing to a non-existent user. When the query included .Include(a => a.Author), EF Core's In-Memory provider returned no results at all. No error, no warning, just an empty result set.

The Fix:

Remove the Include() statements for queries that don't need navigation properties:

// Before (broken with In-Memory):
var articles = await _context.BlogArticles
    .Include(a => a.Author)  // This silently fails!
    .Where(a => a.IsPublished && ...)
    .ToListAsync();

// After (works everywhere):
var allArticles = await _context.BlogArticles.ToListAsync();
var articles = allArticles
    .Where(a => a.IsPublished && ...)
    .ToList();

The Result:

  • Open Graph meta tags - Proper previews on Facebook, LinkedIn
  • Twitter Cards - Proper previews on Twitter
  • Reading time - “5 min read” in the article header
  • Share buttons - One-click sharing to Twitter, LinkedIn, Facebook
  • Related articles - Up to 3 related posts based on shared tags

Phase 8: Security Review

Scope Change:

Before merging to production, we decided to add two additional phases: a security review and a performance review.

Authorization Review - PASSED ✅

  • All author endpoints protected by [Authorize(Roles = "BlogAuthor,Admin")]
  • Ownership checks verify users can only edit/delete their own articles
  • Admin role can override ownership for moderation
  • All form POST endpoints have [ValidateAntiForgeryToken]

Input Validation - PASSED ✅

  • Image uploads: 5MB limit, whitelist of allowed extensions
  • GUID filenames prevent directory traversal attacks
  • Empty file checks prevent zero-byte uploads

Issues Found and Fixed:

  1. No Rate Limiting ⚠️ → Fixed

    • Solution: Added ASP.NET Core rate limiting:
      • blog-upload: 10 requests/minute per user
      • blog-search: 30 requests/minute per IP
      • blog-write: 20 requests/minute per user
  2. No Content Size Limit ⚠️ → Fixed

    • Solution: Added [MaxLength(100000)] (~100KB, plenty for articles)
  3. XSS via Markdown ℹ️ → Accepted Risk

    • Markdig doesn't sanitize <script> tags by default
    • Since only trusted authors (Admin/BlogAuthor) can create content, this is low risk
    • Documented for future consideration if opening authoring to untrusted users

Rate Limiting Implementation:

services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.AddPolicy("blog-upload", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: context.User?.Identity?.Name ??
                         context.Connection.RemoteIpAddress?.ToString() ?? "anonymous",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 10,
                Window = TimeSpan.FromMinutes(1)
            }));
});

Applied via attributes on specific endpoints:

[HttpPost("UploadImage")]
[EnableRateLimiting("blog-upload")]
public async Task<IActionResult> UploadImage(IFormFile image)

Phase 9: Performance Review

The Goal:

Review database queries, caching opportunities, and overall performance before production deployment.

Database Query Optimization:

Added AsNoTracking() to all read-only queries:

// Before:
return await _context.BlogArticles
    .Include(a => a.Author)
    .Where(a => a.IsPublished)
    .ToListAsync();

// After:
return await _context.BlogArticles
    .AsNoTracking()  // Skip change tracking for read-only queries
    .Include(a => a.Author)
    .Where(a => a.IsPublished)
    .ToListAsync();

Applied to 10 read-only query methods.

Index Verification:

Confirmed proper indexes exist:

  • BlogArticle.Slug - Unique index (for URL lookups)
  • BlogArticle.IsPublished - Index (for filtering)
  • BlogArticle.IsFeatured - Index (for home page query)
  • BlogArticle.PublishedAt - Index (for ordering)
  • BlogTag.Slug - Unique index (for tag filtering)

Caching Evaluation:

Decision: Skip caching for now.

For a low-volume personal blog, adding IMemoryCache would add complexity without meaningful benefit. The queries are already fast with:

  • Proper indexes on filtered columns
  • AsNoTracking() reducing overhead
  • Pagination limiting result sets

Phase 10: CI Pipeline Fix

The Problem:

After merging the blog feature to master, the GitHub Actions CI pipeline started failing. The unit tests that had been passing locally were failing in CI.

Root Cause Analysis:

AzureBlobStorageService and AzureCommunicationEmailService both created Azure SDK clients internally in their constructors. This meant:

  1. AzureBlobStorageService called new BlobServiceClient(connectionString) and CreateIfNotExists() in its constructor
  2. AzureCommunicationEmailService called new EmailClient(connectionString) in its constructor

The tests were “working” locally only because the test setup happened to have a connection string that resolved. In CI, with no Azure emulator running, the constructors threw exceptions before any test logic ran.

The Fix:

The Azure SDK is designed for testability. Both clients have virtual methods that Moq can mock. The fix was:

  1. Inject Azure clients via DI instead of creating them in constructors
  2. Move client creation to DI factory registrations in Startup.cs
  3. Mock Azure clients in tests using Moq
// Before (untestable)
public AzureBlobStorageService(IConfiguration config, DbContext context)
{
    var client = new BlobServiceClient(config["ConnectionString"]);
    _container = client.GetBlobContainerClient("blog-images");
    _container.CreateIfNotExists(); // Side effect in constructor!
}

// After (testable)
public AzureBlobStorageService(BlobContainerClient container, DbContext context)
{
    _container = container ?? throw new ArgumentNullException(nameof(container));
}

Test Improvements:

Before: 8 tests, several with try/catch that hid failures. After: 14 tests, all with proper assertions and mock verification.

Bonus Fix: PR Pipeline Was Never Running Tests

The CI workflow had the pull_request trigger targeting main instead of master:

# Before (never triggered)
pull_request:
  branches: [ main ]

# After (triggers on PRs to master)
pull_request:
  branches: [ master ]

Lessons Learned

  1. InMemory provider has limitations: EF Core InMemory doesn't support all SQL functions. It also fails silently when Include() references non-existent entities.

  2. Hidden inputs bridge rich UIs: Tagify's complex interface submits via a simple comma-separated hidden input.

  3. Autoloaders reduce bloat: Prism.js autoloader loads language support on-demand.

  4. Rate limiting is easy in modern .NET: ASP.NET Core's built-in rate limiter requires minimal code.

  5. AsNoTracking is free performance: For read-only queries, always use AsNoTracking().

  6. Indexes matter more than caching: For most applications, proper database indexes provide far more performance benefit than application-level caching.

  7. Side effects in constructors make code untestable: Creating clients, opening connections—these should happen in DI factory registrations, not constructors.

  8. try/catch in tests is a code smell: If your unit test needs to catch and swallow exceptions, something is wrong with the design.

  9. CI catches what local dev hides: Local development often has services running that mask design issues. CI's clean environment is a feature, not a bug.


Blog Feature Complete!

All planned phases are complete:

Phase Description Status
1 Foundation (models, migrations, scaffolding)
2 Public Reading (home page, article display)
3 Authoring (EasyMDE editor)
4 Image Management (Azure Blob Storage)
5 Enhanced Features (tags, search, RSS)
6 Polish (SEO, sharing, related posts)
7 Comments/Likes ⏳ Deferred
8 Security Review
9 Performance Review
10 CI Pipeline Fix

The blog is production-ready. From architecture document to working feature, this has been an interesting journey in AI-assisted development.


This concludes the series. Part 1 covers planning and architecture. Part 2 covers Phases 1-4.

Related Articles
Building a Blog Feature with Claude: An AI's Perspective (Part 2/3)

Vibe coding a blog feature for an existing website

Building a Blog Feature with Claude: An AI's Perspective (Part 1/3)

Vibe coding a blog feature for an existing website