DEV Community

Steven Kamwaza
Steven Kamwaza

Posted on

Stop Wiring Quill by Hand: RazorRichText for ASP.NET Core

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Prefer config over code? Bind the RazorRichText section:

builder.Services.AddRazorRichText(builder.Configuration);
Enter fullscreen mode Exit fullscreen mode
{
  "RazorRichText": {
    "DefaultHeight": 400,
    "DefaultTheme": "Dark",
    "DefaultPlaceholder": "Start writing…",
    "SanitizeByDefault": true,
    "CleanPasteFromWord": true,
    "MaxContentLength": 20000
  }
}
Enter fullscreen mode Exit fullscreen mode

In Views/_ViewImports.cshtml:

@using RazorRichText.Models
@using RazorRichText.Helpers
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, RazorRichText
Enter fullscreen mode Exit fullscreen mode

In Views/Shared/_Layout.cshtml, inside <head>:

@Html.RenderRichTextAssets()
Enter fullscreen mode Exit fullscreen mode

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; } = "";
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

Register the binder if you want that behaviour on every [RichText] property:

builder.Services.AddControllersWithViews(options =>
    options.ModelBinderProviders.Insert(0, new RichTextModelBinderProvider()));
Enter fullscreen mode Exit fullscreen mode

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…" />
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

# 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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode
[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}" });
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Optional modules stay off until you point at them:

  • rte-mention-url for @ lookups
  • rte-ai-url for a rewrite endpoint
  • rte-collab-url plus app.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…" />
Enter fullscreen mode Exit fullscreen mode

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)