Adding a rich text box to an ASP.NET Core app usually means three separate jobs: embed Quill, keep the HTML out of XSS trouble, and teach model validation that <p><br></p> is empty. RazorRichText is a MIT-licensed NuGet package that does those jobs for you.
You bind a string with asp-for. On POST you get sanitised HTML. The editor itself is a tag helper, so the view stays markup.
dotnet add package RazorRichText
It targets ASP.NET Core on .NET 10 and ships Quill plus the editor CSS and JS as embedded assets. You do not host those files yourself.
Wire it once
Program.cs needs two calls: register the services, then map the embedded files. UseEmbeddedAssets is on by default, so the map call is required.
using RazorRichText.Extensions;
using RazorRichText.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddRazorRichText(opts =>
{
opts.DefaultHeight = 360;
opts.DefaultPlaceholder = "Start writing…";
opts.DefaultPreset = ToolbarPreset.Blog;
opts.SanitizeByDefault = true;
opts.CleanPasteFromWord = true;
});
var app = builder.Build();
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.MapRazorRichTextAssets(); // serves /_razor-rich-text/*.css and *.js
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
Prefer config over code? Bind the RazorRichText section:
builder.Services.AddRazorRichText(builder.Configuration);
{
"RazorRichText": {
"DefaultHeight": 400,
"DefaultTheme": "Dark",
"DefaultPlaceholder": "Start writing…",
"SanitizeByDefault": true,
"CleanPasteFromWord": true,
"MaxContentLength": 20000
}
}
In Views/_ViewImports.cshtml:
@using RazorRichText.Models
@using RazorRichText.Helpers
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, RazorRichText
In Views/Shared/_Layout.cshtml, inside <head>:
@Html.RenderRichTextAssets()
Call that helper once in the layout. If the toolbar paints but formatting does nothing, Quill was loaded twice. Set AutoIncludeScripts = false and keep the single layout call.
A model that understands HTML
A normal [Required] or [StringLength] attribute counts tags. A bold word is longer than the same word in plain text, and an untouched Quill editor posts <p><br></p>, which is not empty to [Required].
Mark the property with [RichText]. That tells the binder to sanitise the value before your action runs. Length rules measure the plain text.
using System.ComponentModel.DataAnnotations;
using RazorRichText.Models;
using RazorRichText.Services;
public class ArticleForm
{
[Required, StringLength(200)]
public string Title { get; set; } = "";
[RichText]
[RequiredRichText(ErrorMessage = "Write the article before publishing.")]
[MinRichTextLength(50)]
[MaxRichTextLength(20_000)]
[Display(Name = "Body")]
public string Body { get; set; } = "";
[RichText]
[MaxRichTextLength(280)]
public string Summary { get; set; } = "";
}
The view is one element per field:
@model ArticleForm
<form method="post">
@Html.AntiForgeryToken()
<label asp-for="Title"></label>
<input asp-for="Title" />
<span asp-validation-for="Title"></span>
<label asp-for="Body"></label>
<rich-text-editor asp-for="Body"
rte-preset="Blog"
rte-height="420"
rte-placeholder="Type / for a heading, list, or quote." />
<span asp-validation-for="Body"></span>
<button type="submit">Publish</button>
</form>
The controller stays a normal MVC action. model.Body is already cleaned when it arrives.
[HttpPost, ValidateAntiForgeryToken]
public IActionResult Create(ArticleForm model)
{
if (!ModelState.IsValid)
return View(model);
// model.Body is sanitised HTML. Store it.
return RedirectToAction(nameof(Index));
}
Register the binder if you want that behaviour on every [RichText] property:
builder.Services.AddControllersWithViews(options =>
options.ModelBinderProviders.Insert(0, new RichTextModelBinderProvider()));
Without the binder, inject IHtmlSanitizerService and call Sanitize yourself. The result tells you whether anything was stripped, which is useful in an audit log.
Pick a preset instead of a button list
The default chrome is slash-first: type / for commands, and a small bubble appears when text is selected. Presets choose which commands exist, so a comment box and a docs editor do not share one giant toolbar.
| Preset | What the author gets |
|---|---|
Comment |
Bold, italic, underline, strikethrough, links |
Chat |
Comment, plus lists |
Blog |
Headings, lists, links, images, quotes, undo |
Docs |
Blog, plus alignment, code, find and replace |
Full |
Every group, including tables, colours, source view |
A comment field that stays out of the way until it is focused:
<rich-text-editor asp-for="Comment"
rte-preset="Comment"
rte-toolbar="Slash"
rte-appearance="Ghost"
rte-height="140"
rte-status-bar="false"
rte-placeholder="Add a comment…" />
Ghost draws the border only while the editor is focused. Plain is unstyled chrome for apps that already have a design system. Themed is the default look (light, dark, or high contrast via rte-theme).
A documentation editor with the classic ribbon:
<rich-text-editor asp-for="Body"
rte-preset="Full"
rte-toolbar="Standard"
rte-appearance="Themed"
rte-theme="Dark"
rte-height="480"
rte-sticky-toolbar="true" />
# followed by space still becomes a heading, and pasting from Word is cleaned up (CleanPasteFromWord defaults to true). You can ignore the presets and pass groups directly: rte-toolbar-groups="TextFormatting,Lists,Links". Use the flag name only — All, not ToolbarGroups.All.
Show the saved article
@Html.Raw on stored HTML is fine only after sanitising. The display helper is the safer default, and it can build a table of contents from the headings:
@Html.RichTextDisplay(Model.Body, cssClass: "prose", showToc: true, printFriendly: true)
Listing pages rarely want the full HTML. Inject IContentConverter:
public sealed class ArticleService(IContentConverter converter)
{
public string Excerpt(string html) => converter.GetExcerpt(html, 160);
public int Words(string html) => converter.CountWords(html);
public string AsMarkdown(string html) =>
converter.HtmlToMarkdown(html).Content;
}
The same service converts Markdown back to HTML, or strips tags to plain text, when you already store one format and need another.
Drafts and images
Autosave writes to localStorage under rte_autosave_{editorId} and restores the draft when the server has no value yet.
<rich-text-editor asp-for="Body"
rte-preset="Blog"
rte-autosave="true"
rte-autosave-interval="15000" />
Images need an endpoint that accepts multipart/form-data (file) and returns { "url": "/uploads/….jpg" }.
<rich-text-editor asp-for="Body"
rte-preset="Blog"
rte-allow-images="true"
rte-image-url="/api/images/upload" />
[ApiController]
[Route("api/images")]
public class ImageUploadController(IWebHostEnvironment env) : ControllerBase
{
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
var allowed = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" };
if (file is not { Length: > 0 } || !allowed.Contains(file.ContentType))
return BadRequest(new { error = "Unsupported image." });
var dir = Path.Combine(env.WebRootPath, "uploads");
Directory.CreateDirectory(dir);
var name = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
await using var stream = System.IO.File.Create(Path.Combine(dir, name));
await file.CopyToAsync(stream);
return Ok(new { url = $"/uploads/{name}" });
}
}
Check the content type on the server. The editor will insert whatever URL you return.
When you need a hook, not a new editor
Each instance is on window.__rte by its id (rte_ plus the property name). rte-onchange and rte-onready name global functions:
<rich-text-editor asp-for="Body" rte-onchange="onBodyChange" rte-onready="onEditorReady" />
<p id="words"></p>
<script>
function onEditorReady(quill) { quill.focus(); }
function onBodyChange(html, quill) {
const words = quill.getText().trim().split(/\s+/).filter(Boolean).length;
document.getElementById("words").textContent = words + " words";
}
</script>
Optional modules stay off until you point at them:
-
rte-mention-urlfor@lookups -
rte-ai-urlfor a rewrite endpoint -
rte-collab-urlplusapp.MapHub<RichTextHub>("/hubs/rte")for a SignalR broadcast
Snippets (heading, quote, code block) ship with IRichTextSnippetProvider. Replace that service if / should insert your own blocks.
Blazor
The same package exposes a component. Render the assets from the host page, map MapRazorRichTextAssets(), and bind a string:
<RichTextEditor @bind-Value="article.Body"
Preset="ToolbarPreset.Blog"
Appearance="EditorAppearance.Themed"
Height="360"
Placeholder="Start typing…" />
ValueChanged fires with the HTML string, same as the MVC hidden field.
Three mistakes that waste an afternoon
The tag helper is unknown. _ViewImports.cshtml is missing @addTagHelper *, RazorRichText.
CS0103: The name 'Blog' does not exist. The view is missing @using RazorRichText.Models. Attribute strings such as rte-preset="Blog" do not need the using; C# expressions such as rte-preset="@ToolbarPreset.Blog" do.
The editor area is blank and the console says Quill is not loaded. MapRazorRichTextAssets() was never called, or RenderRichTextAssets() is not in the layout. Embedded files are served from /_razor-rich-text only after that map.
What you stop maintaining
You stop copying Quill boilerplate between projects, writing a sanitiser allow-list, and special-casing the empty-editor HTML in every view model. One package, one tag, and the POST payload is HTML you can store.
Source and license: github.com/StevenKamwaza/RazorRichText (MIT).
Top comments (0)