Introduction
This article shows how to build a production-minded Notes application that supports rich-text editing, saving, and searching. The backend uses ASP.NET Core (Web API) with EF Core and the frontend is an Angular SPA. The editor used in examples is Quill (open-source, lightweight) but I also explain how to swap in CKEditor or TinyMCE.
The guide is written in simple Indian English and targets senior developers who want a clear, copy-pasteable implementation with best practices for security, storage, collaboration, and search.
What you will learn
Project structure for backend and frontend
EF Core models, migrations and storage for rich text
Angular integration with Quill editor (reactive forms)
Sanitisation, search, and attachments
Collaboration basics (optional SignalR)
Production considerations: backups, XSS protection, indexing, and performance
High-level architecture
[Angular SPA] <-- HTTPS/JWT --> [ASP.NET Core Web API] <---> [SQL Server]
| |
| +--> [Blob Storage for attachments]
+--(optional SignalR for live-collab)-->
Notes content is stored as HTML (produced by the rich-text editor). We must ensure server-side sanitisation before persisting to avoid XSS when rendering notes later.
Database Design (simple)
Keep the schema small and extensible.
Users(Id, UserName, Email, PasswordHash, CreatedAt)
Notes(Id, OwnerId, Title, ContentHtml, ContentText, IsPrivate, CreatedAt, UpdatedAt, Version)
NoteAttachments(Id, NoteId, FileName, ContentType, BlobUrl, Size, CreatedAt)
Notes explanation
ContentHtml: stores rich HTML from editor.
ContentText: plain-text extract used for search and previews.
Version: a concurrency token (rowversion or integer) to support optimistic concurrency.
Add indexes on OwnerId, UpdatedAt, and a full-text index on ContentText (or use an external search engine for better results).
Backend: Project setup and packages
Create project
dotnet new webapi -n NotesBackend
cd NotesBackend
Install packages
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Ganss.XSS // for sanitisation
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
Ganss.XSS is a simple and trusted HTML sanitizer for .NET. You can swap or add your own policies.
Backend: EF Core model examples
Note.cs
public class Note
{
public Guid Id { get; set; }
public Guid OwnerId { get; set; }
public string Title { get; set; } = string.Empty;
public string ContentHtml { get; set; } = string.Empty;
public string ContentText { get; set; } = string.Empty; // plain text for search
public bool IsPrivate { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
[Timestamp]
public byte[]? Version { get; set; }
public List<NoteAttachment> Attachments { get; set; } = new();
}
NoteAttachment.cs
public class NoteAttachment
{
public Guid Id { get; set; }
public Guid NoteId { get; set; }
public string FileName { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public string BlobUrl { get; set; } = string.Empty;
public long Size { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
Register DbSet<Note> and DbSet<NoteAttachment> in NotesDbContext and create migrations.
Backend: Sanitise HTML before saving
Never trust editor HTML. Use a sanitizer.
public class NoteService : INoteService
{
private readonly NotesDbContext _db;
private readonly HtmlSanitizer _sanitizer; // Ganss.XSS
public NoteService(NotesDbContext db)
{
_db = db;
_sanitizer = new HtmlSanitizer();
_sanitizer.AllowedSchemes.Add("data"); // if you allow inline images
}
public async Task<NoteDto> CreateOrUpdateNoteAsync(Guid userId, NoteEditDto dto)
{
var cleanHtml = _sanitizer.Sanitize(dto.ContentHtml ?? string.Empty);
var plainText = HtmlUtilities.ConvertToPlainText(cleanHtml); // simple helper
// create or update entity
var note = // mapping ...
note.ContentHtml = cleanHtml;
note.ContentText = plainText;
// save and return
}
}
HtmlUtilities.ConvertToPlainText can be a small helper that strips tags and decodes entities. Store this for search.
Backend: API endpoints
Design a small API surface:
POST /api/notes — create
PUT /api/notes/{id} — update (use RowVersion for optimistic concurrency)
GET /api/notes/{id} — get note (respect IsPrivate and owner/permissions)
GET /api/notes — list notes with pagination and search query
DELETE /api/notes/{id} — delete
POST /api/notes/{id}/attachments — upload attachment
Example: Update note with concurrency
[HttpPut("{id}")]
public async Task<IActionResult> Update(Guid id, [FromBody] NoteEditDto dto)
{
var userId = User.GetUserId();
var note = await _db.Notes.FindAsync(id);
if (note == null) return NotFound();
if (note.OwnerId != userId) return Forbid();
// sanitize
var clean = _sanitizer.Sanitize(dto.ContentHtml);
note.Title = dto.Title;
note.ContentHtml = clean;
note.ContentText = HtmlUtilities.ConvertToPlainText(clean);
note.UpdatedAt = DateTimeOffset.UtcNow;
_db.Entry(note).Property("Version").OriginalValue = dto.Version; // byte[]
try
{
await _db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
return Conflict(new { message = "Note was modified by another session" });
}
return NoContent();
}
Best practice: Return 409 Conflict if concurrency fails and provide current server version.
Frontend: Angular Setup and Editor choice
Create Angular app and add Quill:
ng new notes-app
cd notes-app
npm install quill ngx-quill @types/quill
ngx-quill integrates Quill with Angular forms. If you prefer CKEditor, @ckeditor/ckeditor5-angular is also a good choice.
Frontend: NoteEditComponent (Reactive form with Quill)
note-edit.component.ts
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({ selector: 'app-note-edit', templateUrl: './note-edit.component.html' })
export class NoteEditComponent implements OnInit {
form!: FormGroup;
editorModules = {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
[{ 'header': [1, 2, 3, false] }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
['link', 'image', 'blockquote']
]
};
constructor(private fb: FormBuilder, private api: NotesApiService) {}
ngOnInit() {
this.form = this.fb.group({
title: ['', [Validators.required, Validators.maxLength(200)]],
contentHtml: ['', Validators.required],
isPrivate: [true]
});
}
async save() {
if (this.form.invalid) return;
const dto = this.form.value;
await this.api.saveNote(dto);
}
}
note-edit.component.html
<form [formGroup]="form" (ngSubmit)="save()">
<input formControlName="title" placeholder="Title" />
<quill-editor formControlName="contentHtml" [modules]="editorModules"></quill-editor>
<label><input type="checkbox" formControlName="isPrivate" /> Private</label>
<button type="submit">Save</button>
</form>
Notes
Frontend: Sanitisation and Preview
Client-side sanitisation is useful for better UX, but it must not replace server sanitisation.
For preview, use Angular's DomSanitizer to safely render HTML:
constructor(private sanitizer: DomSanitizer) {}
getSafeHtml(html: string) {
return this.sanitizer.bypassSecurityTrustHtml(html);
}
Important: bypassSecurityTrustHtml does not sanitise — it marks the HTML as trusted for Angular's DOM. Always send clean HTML from server. Only bypass when you have sanitized on server or you trust the source.
Search: Simple vs Advanced
Simple: store ContentText plain text and use SQL LIKE searches or full-text index (recommended for moderate scale).
Advanced: use Elasticsearch or Azure Cognitive Search for better ranking and fuzzy search.
Example SQL full-text search query:
SELECT * FROM Notes WHERE CONTAINS(ContentText, '"meeting" OR "notes"')
Attachments
Upload attachments to blob storage. On upload:
Validate file size and type.
Upload to blob storage.
Store metadata in NoteAttachments.
Optionally create image thumbnails for preview.
Angular upload example
const fd = new FormData();
fd.append('file', file);
this.http.post(`/api/notes/${noteId}/attachments`, fd, { reportProgress: true, observe: 'events' })
Autosave and Drafts
Implement autosave with debounce to avoid excessive saves:
this.form.valueChanges.pipe(debounceTime(2000)).subscribe(value => this.saveDraft(value));
On server, keep a Drafts table or a flag on Notes marking unpublished drafts. Autosave must track Version for concurrency.
Collaboration (optional)
For live-collaboration (multiple editors on same note), use an operational transform (OT) or CRDT library. Quill has OT support via ShareDB and other infrastructures.
Simpler approach: implement presence and remote cursors via SignalR and broadcast deltas. This is non-trivial and outside a small app scope; for production collaborative editing prefer established services or libraries.
Security considerations
Server-side sanitisation: always sanitize HTML.
Content-length and upload limits: limit size to protect the server.
Authentication and authorization: owners can edit; others need explicit share permissions.
XSS protection: escape content where appropriate and sanitize embedded URLs.
Rate limiting: prevent abuse from automated scripts.
Backups: schedule regular DB and blob backups.
Performance and Storage
Store large attachments in blob store, not DB.
Keep ContentHtml in DB if small; else use blob store for very large notes and store pointer in DB.
Use pagination and limit content in note lists (fetch only title and content snippet).
Monitoring and Observability
Track:
Save failures and conflicts
Upload failures and storage usage
Search latency and errors
Autosave frequency and errors
Use Application Insights, Prometheus + Grafana, or ELK stack for logs and metrics.
Deployment checklist
Use HTTPS and secure JWT keys via key vault
Configure CORS strictly
Configure DB connection pooling
Run EF migrations during deployment
Configure blob storage credentials securely
Example: Minimal NoteController (ASP.NET Core)
[ApiController]
[Route("api/[controller]")]
public class NotesController : ControllerBase
{
private readonly INoteService _service;
public NotesController(INoteService service)
{
_service = service;
}
[HttpPost]
[Authorize]
public async Task<IActionResult> Create([FromBody] NoteEditDto dto)
{
var userId = User.GetUserId();
var note = await _service.CreateOrUpdateNoteAsync(userId, dto);
return CreatedAtAction(nameof(Get), new { id = note.Id }, note);
}
[HttpGet("{id}")]
[Authorize]
public async Task<IActionResult> Get(Guid id)
{
var userId = User.GetUserId();
var note = await _service.GetNoteAsync(userId, id);
if (note == null) return NotFound();
return Ok(note);
}
}
Final best practices (short list)
Sanitize at server; never rely only on client.
Use optimistic concurrency for edits.
Keep attachments in blob storage.
Implement autosave with debounce and conflict detection.
Use full-text search or dedicated search engine for larger data sets.
Secure uploads and sanitize URLs and images.
Next steps
I can now:
generate a ready-to-run GitHub repository scaffold (backend + frontend),
add EF Core migrations and seed sample data,
provide a working Angular component set with unit tests,
add example SignalR live-collaboration scaffold,
or produce PNG/SVG diagrams for architecture and flow.
Tell me which one you want next.