These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is .NET?
.NET is Microsoft's open-source, cross-platform framework for building web applications, APIs, and services. ASP.NET Core, its web framework component, provides everything from Minimal APIs and MVC controllers to Blazor for interactive frontends—all running on a high-performance runtime with built-in dependency injection, middleware pipelines, and strongly-typed configuration.
.NET 10 is the current Long-Term Support (LTS) release with support extending to approximately November 2028, and .NET 9 remains in active Standard Term Support (STS) through November 2026. Both versions share the same HttpClient and IHttpClientFactory patterns used throughout this guide.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate .NET with Strapi?
Pairing .NET with Strapi v5 bridges the gap between a strongly-typed, high-performance backend framework and a flexible, API-first content management system. Here's what that combination gives you:
- Auto-generated REST endpoints—and, with Strapi's official GraphQL plugin installed, GraphQL queries and mutations—Define a content type in Strapi's Content-Type Builder, and production-ready API access appears immediately. No custom controller scaffolding required on the Strapi side.
- Standard HttpClient consumption—Strapi's REST API returns JSON over HTTP. ASP.NET Core's
IHttpClientFactoryandSystem.Net.Http.Jsonextensions handle serialization, connection pooling, and resilience out of the box. - One content backend, multiple .NET frontends—A single Strapi instance can serve an ASP.NET Core MVC site, a Blazor frontend, and a .NET MAUI mobile app through the same API endpoints. No duplicate content models needed.
- Full admin interface with RBAC—Strapi ships with a complete editorial UI, role-based access control, draft/publish workflows, and media management. Building equivalent functionality in ASP.NET Core from scratch is weeks of undifferentiated work.
- Open-source and self-hostable—Deploy Strapi alongside your .NET application on Azure, AWS, or on-premises. No per-API-call pricing, no external data dependencies, and full control over data residency.
- TypeScript codebase aligns with typed-language expectations—Strapi 5 runs on a codebase that is almost entirely written in TypeScript. When you need to extend Strapi through plugins or lifecycle hooks, you get the same autocompletion and type safety C# developers expect.
How to Integrate .NET with Strapi
Prerequisites
Before starting, make sure you have the following installed and configured:
| Tool | Version | Purpose |
|---|---|---|
| .NET SDK | 9.0.14+ (STS) or 10.x (LTS) | ASP.NET Core runtime and CLI |
| Node.js | v20, v22, or v24 | Strapi runtime |
| npm | v6+ | Strapi package management |
| Code editor | VS Code, Visual Studio, or Rider | Development environment |
Verify your installations:
dotnet --version
node --version
npm --versionStep 1: Create and Configure a Strapi v5 Project
Start by scaffolding a new Strapi project:
npx create-strapi@latest my-strapi-projectThe CLI walks you through authentication with Strapi Cloud (optional) and project configuration. Press Enter to accept defaults for a quick setup.
Start the development server:
cd my-strapi-project && npm run developThe Admin Panel opens at http://localhost:1337/admin. Register your first admin account.
Step 2: Create Content Types in Strapi
Navigate to Content-Type Builder in the admin sidebar. Create an "Article" Collection Type:
- Click the + icon next to Collection types
- Enter
Articleas the Display name and click Continue - Add these fields:
| Field Type | Name | Advanced Settings |
|---|---|---|
| Text (Short) | title | Required, Unique |
| Text (Long) | slug | Required, Unique |
| Rich text (Blocks) | content | — |
| Media (Single) | cover | — |
- Click Save and wait for Strapi to restart
Add a few sample articles through Content Manager, then click Publish on each entry to make them accessible via the API.
Step 3: Configure API Permissions
By default, Strapi's REST API returns nothing for unauthenticated requests. Configure public access:
- Go to Settings > Users & Permissions Plugin > Roles
- Click the Public role
- Under Permissions, find Article and check
findandfindOne - Click Save
Test the endpoint:
curl http://localhost:1337/api/articlesThe response uses Strapi v5's flattened format—attributes sit at the top level of each data item, not nested inside data.attributes:
{
"data": [
{
"id": 1,
"documentId": "wf7m1n3g8g22yr5k50hsryhk",
"title": "Getting Started with Strapi",
"slug": "getting-started-with-strapi",
"createdAt": "2024-09-10T12:49:32.350Z",
"updatedAt": "2024-09-10T13:14:18.275Z",
"publishedAt": "2024-09-10T13:14:18.280Z",
"locale": null
}
],
"meta": {
"pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 1 }
}
}Note both id (integer) and documentId (alphanumeric string) appear in responses. Use documentId as your stable content identifier—it persists across localization variants and content versions.
Step 4: Generate an API Token
For authenticated access from your .NET application:
- Navigate to Settings > Global settings > API Tokens
- Click Create new API Token
- Configure the token:
| Field | Value |
|---|---|
| Name | dotnet-client |
| Token duration | Unlimited (or rotate on a schedule) |
| Token type | Read-only |
- Click Save and copy the token immediately—it's shown only once unless you've configured an encryption key.
Every request to Strapi's REST API includes this token in the Authorization header:
Authorization: bearer your-api-token-hereStep 5: Create the ASP.NET Core Project
Scaffold a new ASP.NET Core MVC application:
dotnet new mvc -n StrapiDotnetApp
cd StrapiDotnetAppAdd the resilience package for production-grade HTTP handling:
dotnet add package Microsoft.Extensions.Http.ResilienceStep 6: Define C# Models for Strapi v5 Responses
Strapi v5's flattened response format means your C# models map directly to the JSON structure—no intermediate attributes wrapper class needed.
Create a Models/ directory with the following files:
// Models/StrapiResponses.cs
using System.Text.Json.Serialization;
namespace StrapiDotnetApp.Models;
public class StrapiListResponse<T>
{
[JsonPropertyName("data")]
public List<T> Data { get; set; } = new();
[JsonPropertyName("meta")]
public StrapiMeta? Meta { get; set; }
}
public class StrapiSingleResponse<T>
{
[JsonPropertyName("data")]
public T? Data { get; set; }
[JsonPropertyName("meta")]
public StrapiMeta? Meta { get; set; }
}
public class StrapiMeta
{
[JsonPropertyName("pagination")]
public StrapiPagination? Pagination { get; set; }
}
public class StrapiPagination
{
[JsonPropertyName("page")]
public int Page { get; set; }
[JsonPropertyName("pageSize")]
public int PageSize { get; set; }
[JsonPropertyName("pageCount")]
public int PageCount { get; set; }
[JsonPropertyName("total")]
public int Total { get; set; }
}// Models/Article.cs
using System.Text.Json.Serialization;
namespace StrapiDotnetApp.Models;
public class Article
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("documentId")]
public string DocumentId { get; set; } = string.Empty;
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("slug")]
public string? Slug { get; set; }
[JsonPropertyName("publishedAt")]
public DateTimeOffset? PublishedAt { get; set; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
[JsonPropertyName("locale")]
public string? Locale { get; set; }
[JsonPropertyName("cover")]
public StrapiImage? Cover { get; set; }
}
public class StrapiImage
{
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
[JsonPropertyName("alternativeText")]
public string? AlternativeText { get; set; }
[JsonPropertyName("width")]
public int Width { get; set; }
[JsonPropertyName("height")]
public int Height { get; set; }
}The [JsonPropertyName] attributes handle the mapping between Strapi's camelCase JSON keys and your C# properties. JsonSerializerDefaults.Web provides case-insensitive matching as a fallback, but explicit attributes keep things predictable.
Step 7: Build a Typed HttpClient Service
Typed clients are one of several IHttpClientFactory consumption patterns, and the best choice depends on the app's requirements. They provide IntelliSense, a single configuration location, and full dependency injection support.
// Services/IStrapiContentService.cs
using StrapiDotnetApp.Models;
namespace StrapiDotnetApp.Services;
public interface IStrapiContentService
{
Task<StrapiListResponse<Article>?> GetArticlesAsync(
int page = 1, CancellationToken ct = default);
Task<StrapiSingleResponse<Article>?> GetArticleByDocumentIdAsync(
string documentId, CancellationToken ct = default);
}// Services/StrapiContentService.cs
using System.Net.Http.Json;
using System.Text.Json;
using StrapiDotnetApp.Models;
namespace StrapiDotnetApp.Services;
public class StrapiContentService : IStrapiContentService
{
private readonly HttpClient _httpClient;
private readonly ILogger<StrapiContentService> _logger;
private readonly string _baseUrl;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public StrapiContentService(
HttpClient httpClient,
ILogger<StrapiContentService> logger,
IConfiguration configuration)
{
_httpClient = httpClient;
_logger = logger;
_baseUrl = configuration["Strapi:BaseUrl"] ?? "http://localhost:1337";
}
public async Task<StrapiListResponse<Article>?> GetArticlesAsync(
int page = 1, CancellationToken ct = default)
{
try
{
var result = await _httpClient.GetFromJsonAsync<StrapiListResponse<Article>>(
$"/api/articles?pagination[page]={page}&pagination[pageSize]=25&sort=createdAt:desc",
JsonOptions, ct);
if (result?.Data is not null)
ResolveMediaUrls(result.Data);
return result;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error fetching articles: {StatusCode}", ex.StatusCode);
return null;
}
}
public async Task<StrapiSingleResponse<Article>?> GetArticleByDocumentIdAsync(
string documentId, CancellationToken ct = default)
{
try
{
var result = await _httpClient.GetFromJsonAsync<StrapiSingleResponse<Article>>(
$"/api/articles/{documentId}?populate=cover",
JsonOptions, ct);
if (result?.Data?.Cover is not null)
result.Data.Cover.Url = ResolveMediaUrl(result.Data.Cover.Url);
return result;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error fetching article {DocumentId}: {StatusCode}",
documentId, ex.StatusCode);
return null;
}
}
private void ResolveMediaUrls(List<Article> articles)
{
foreach (var article in articles)
{
if (article.Cover?.Url is not null)
article.Cover.Url = ResolveMediaUrl(article.Cover.Url);
}
}
private string ResolveMediaUrl(string url)
{
if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
return url;
return $"{_baseUrl.TrimEnd('/')}{url}";
}
}A few things worth noting here. The populate=cover query parameter is required because Strapi's REST API does not populate relations by default. Without it, the cover field is not returned in the API response. The ResolveMediaUrl helper prepends the Strapi base URL to relative paths from the local upload provider—if you're using a cloud provider like AWS S3 or Cloudinary, the URL is already absolute and the helper passes it through unchanged.
Step 8: Register Services and Configure Program.cs
Add Strapi connection settings to appsettings.json:
{
"Strapi": {
"BaseUrl": "http://localhost:1337",
"ApiToken": "your-api-token-here"
}
}Configure the typed client in Program.cs:
// Program.cs
using System.Net.Http.Headers;
using Microsoft.Extensions.Options;
using StrapiDotnetApp.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
// Typed HTTP client for Strapi
builder.Services.AddHttpClient<IStrapiContentService, StrapiContentService>((sp, client) =>
{
var config = sp.GetRequiredService<IConfiguration>();
client.BaseAddress = new Uri(config["Strapi:BaseUrl"]!);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", config["Strapi:ApiToken"]);
client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5))
.AddStandardResilienceHandler();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();The AddStandardResilienceHandler() call from Microsoft.Extensions.Http.Resilience adds a five-layer resilience pipeline—rate limiter, total timeout, retry, circuit breaker, and attempt timeout—in a single line. For any production integration against a content API, this should be standard configuration.
Using IHttpClientFactory instead of new HttpClient() helps avoid socket exhaustion and DNS staleness issues. The factory pools HttpMessageHandler instances and manages their lifecycle automatically.
Step 9: Build the Controller
The controller delegates entirely to the service layer—no direct HTTP calls or JSON parsing:
// Controllers/ArticlesController.cs
using Microsoft.AspNetCore.Mvc;
using StrapiDotnetApp.Services;
namespace StrapiDotnetApp.Controllers;
public class ArticlesController : Controller
{
private readonly IStrapiContentService _strapiService;
public ArticlesController(IStrapiContentService strapiService)
=> _strapiService = strapiService;
public async Task<IActionResult> Index(int page = 1)
{
var result = await _strapiService.GetArticlesAsync(page);
if (result is null) return View("Error");
return View(result);
}
public async Task<IActionResult> Detail(string documentId)
{
var result = await _strapiService.GetArticleByDocumentIdAsync(documentId);
if (result?.Data is null) return NotFound();
return View(result.Data);
}
}Step 10: Create Razor Views
The listing view iterates over the Data collection and renders pagination from the Meta object:
@* Views/Articles/Index.cshtml *@
@using StrapiDotnetApp.Models
@model StrapiListResponse<Article>
@{
ViewData["Title"] = "Articles";
}
<h1>Articles</h1>
@if (Model?.Data is not null && Model.Data.Any())
{
<div class="row">
@foreach (var article in Model.Data)
{
<div class="col-md-6 mb-4">
<div class="card">
@if (article.Cover is not null)
{
<img src="@article.Cover.Url"
alt="@article.Cover.AlternativeText"
width="@article.Cover.Width"
height="@article.Cover.Height"
class="card-img-top"
loading="lazy" />
}
<div class="card-body">
<h2 class="card-title">
<a asp-action="Detail"
asp-route-documentId="@article.DocumentId">
@article.Title
</a>
</h2>
@if (article.PublishedAt.HasValue)
{
<time datetime="@article.PublishedAt.Value.ToString("yyyy-MM-dd")">
@article.PublishedAt.Value.ToString("MMMM d, yyyy")
</time>
}
</div>
</div>
</div>
}
</div>
@if (Model.Meta?.Pagination is { } paging)
{
<nav>
<p>Page @paging.Page of @paging.PageCount (@paging.Total total articles)</p>
@if (paging.Page > 1)
{
<a asp-action="Index" asp-route-page="@(paging.Page - 1)">Previous</a>
}
@if (paging.Page < paging.PageCount)
{
<a asp-action="Index" asp-route-page="@(paging.Page + 1)">Next</a>
}
</nav>
}
}
else
{
<p>No articles available.</p>
}The detail view renders a single article:
@* Views/Articles/Detail.cshtml *@
@using StrapiDotnetApp.Models
@model Article
@{
ViewData["Title"] = Model.Title;
}
<article>
<h1>@Model.Title</h1>
@if (Model.PublishedAt.HasValue)
{
<time datetime="@Model.PublishedAt.Value.ToString("yyyy-MM-dd")">
Published @Model.PublishedAt.Value.ToString("MMMM d, yyyy")
</time>
}
@if (Model.Cover is not null)
{
<img src="@Model.Cover.Url"
alt="@Model.Cover.AlternativeText"
width="@Model.Cover.Width"
height="@Model.Cover.Height" />
}
</article>Project Example: Knowledge Base with Article Search and Categories
This example extends the integration into a knowledge base application where articles are organized by categories, support filtered queries, and include a search feature. It demonstrates how Strapi APIs can be consumed by a .NET service layer.
Set Up the Category Content Type
In Strapi's Content-Type Builder, create a Category Collection Type:
- Click + next to Collection types, enter
Category - Add a Text field named
name(Required, Unique) - Add a Relation field—select many-to-many with
Article - Click Save
Create a few categories (e.g., "Getting Started", "Advanced", "Deployment"), assign them to your articles, and publish everything. Set Public role permissions to allow find and findOne on both Article and Category.
Extend the C# Models
// Models/Category.cs
using System.Text.Json.Serialization;
namespace StrapiDotnetApp.Models;
public class Category
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("documentId")]
public string DocumentId { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string? Name { get; set; }
}Update the Article model to include categories:
// Add to Article.cs
[JsonPropertyName("categories")]
public List<Category>? Categories { get; set; }Extend the Service Interface and Implementation
// Add to IStrapiContentService.cs
Task<StrapiListResponse<Article>?> SearchArticlesAsync(
string? query = null, string? categorySlug = null,
int page = 1, CancellationToken ct = default);
Task<StrapiListResponse<Category>?> GetCategoriesAsync(CancellationToken ct = default);// Add to StrapiContentService.cs
public async Task<StrapiListResponse<Article>?> SearchArticlesAsync(
string? query = null, string? categorySlug = null,
int page = 1, CancellationToken ct = default)
{
try
{
var url = $"/api/articles?pagination[page]={page}&pagination[pageSize]=25"
+ "&sort=createdAt:desc&populate=cover&populate=categories";
if (!string.IsNullOrWhiteSpace(query))
url += $"&filters[title][$containsi]={Uri.EscapeDataString(query)}";
if (!string.IsNullOrWhiteSpace(categorySlug))
url += $"&filters[categories][name][$eqi]={Uri.EscapeDataString(categorySlug)}";
var result = await _httpClient.GetFromJsonAsync<StrapiListResponse<Article>>(
url, JsonOptions, ct);
if (result?.Data is not null)
ResolveMediaUrls(result.Data);
return result;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error searching articles: {StatusCode}", ex.StatusCode);
return null;
}
}
public async Task<StrapiListResponse<Category>?> GetCategoriesAsync(
CancellationToken ct = default)
{
try
{
return await _httpClient.GetFromJsonAsync<StrapiListResponse<Category>>(
"/api/categories?sort=name:asc", JsonOptions, ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error fetching categories: {StatusCode}", ex.StatusCode);
return null;
}
}The filter $containsi performs a case-insensitive substring match on the title field. Filtering on categories[name] is a deep filter—it traverses the relation and matches against the category's name field. Both use Strapi's LHS bracket syntax for query parameters.
Build the Knowledge Base Controller
// Controllers/KnowledgeBaseController.cs
using Microsoft.AspNetCore.Mvc;
using StrapiDotnetApp.Models;
using StrapiDotnetApp.Services;
namespace StrapiDotnetApp.Controllers;
public class KnowledgeBaseController : Controller
{
private readonly IStrapiContentService _strapiService;
public KnowledgeBaseController(IStrapiContentService strapiService)
=> _strapiService = strapiService;
public async Task<IActionResult> Index(
string? q = null, string? category = null, int page = 1)
{
var categoriesTask = _strapiService.GetCategoriesAsync();
var articlesTask = _strapiService.SearchArticlesAsync(q, category, page);
await Task.WhenAll(categoriesTask, articlesTask);
ViewBag.Categories = categoriesTask.Result?.Data ?? new List<Category>();
ViewBag.CurrentQuery = q;
ViewBag.CurrentCategory = category;
return View(articlesTask.Result);
}
}Both API calls execute concurrently with Task.WhenAll, cutting the page load time roughly in half compared to sequential requests. This is one area where async patterns in ASP.NET Core really pay off.
Create the Knowledge Base View
@* Views/KnowledgeBase/Index.cshtml *@
@using StrapiDotnetApp.Models
@model StrapiListResponse<Article>
@{
ViewData["Title"] = "Knowledge Base";
var categories = ViewBag.Categories as List<Category> ?? new();
var currentQuery = ViewBag.CurrentQuery as string;
var currentCategory = ViewBag.CurrentCategory as string;
}
<h1>Knowledge Base</h1>
<form method="get" asp-action="Index" class="mb-4">
<div class="row g-2">
<div class="col-md-6">
<input type="text" name="q" value="@currentQuery"
placeholder="Search articles..." class="form-control" />
</div>
<div class="col-md-4">
<select name="category" class="form-select">
<option value="">All categories</option>
@foreach (var cat in categories)
{
<option value="@cat.Name"
selected="@(cat.Name == currentCategory)">
@cat.Name
</option>
}
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">Search</button>
</div>
</div>
</form>
@if (Model?.Data is not null && Model.Data.Any())
{
@foreach (var article in Model.Data)
{
<article class="card mb-3">
<div class="card-body">
<h2 class="card-title">
<a asp-controller="Articles" asp-action="Detail"
asp-route-documentId="@article.DocumentId">
@article.Title
</a>
</h2>
@if (article.Categories?.Any() == true)
{
<div>
@foreach (var cat in article.Categories)
{
<span class="badge bg-secondary me-1">@cat.Name</span>
}
</div>
}
</div>
</article>
}
}
else
{
<p>No articles found.</p>
}Strapi Open Office Hours
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, at 6:00 am CST and 12:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and .NET documentation.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.FAQ
Can I use Strapi's GraphQL API instead of REST with .NET?
Yes. Install the GraphQL plugin with npm install @strapi/plugin-graphql in your Strapi project. The endpoint is available at /graphql and accepts standard HTTP POST requests with query and variables fields. Any .NET GraphQL client library, such as the GraphQL.Client NuGet package, can query this endpoint using the same Authorization: Bearer <token> header. Strapi auto-generates the GraphQL schema from your content types when shadowCRUD is enabled (the default).
How do I handle Strapi's Blocks rich text format in ASP.NET Core Razor views?
Strapi v5's Blocks rich text format returns structured JSON with typed nodes like paragraph, heading, and list—not raw HTML. Create C# model classes for StrapiBlock and StrapiBlockChild that map each node's type, children, text, and formatting properties. Then build a Razor partial view or tag helper that switches on the block type and renders the corresponding HTML elements. This approach gives you full control over output markup and styling.
Does the Strapi v5 response format differ from v4 for .NET model classes?
Significantly. Strapi v5 uses a flattened response format where fields sit directly on the data object. The v4 pattern of data.attributes.fieldName is removed. Your C# classes should map title to data.attributes.title, not data.title. Additionally, content is identified by an alphanumeric documentId string rather than an integer id. The publicationState parameter is replaced with status, accepting draft or published values.
How should I deploy Strapi and my .NET application together in production?
The recommended approach is separate containers in a shared Docker Compose network. Strapi runs as one service, your .NET app as another, and they communicate over the internal Docker network using service names as hostnames (e.g., http://strapi:1337). Set connection.pool.min to 0 in Strapi's database configuration for Docker environments, use environment variables for all secrets, and build the Strapi admin panel with NODE_ENV=production yarn build before starting.
Can I auto-generate C# client code from Strapi's API instead of writing models manually?
Strapi v5 includes an OpenAPI specification package that programmatically generates a spec describing all your content APIs. Feed this spec into NSwag or a similar .NET code generation tool to produce typed C# client classes automatically. This eliminates manual model creation for every content type and keeps your .NET client in sync with schema changes made in Strapi's Content-Type Builder. Regenerate the client whenever content types change.