Building Search with Angular, .NET, and Full-Text Search
Search looks like a text box and a list of links, but the useful behaviour lives underneath it. A visitor types a phrase, waits a fraction of a second, and expects relevant documents, a clear result count, and a way to refine the outcome. Angular can own that interaction, while an ASP.NET Core API applies the business rules and SQL Server performs the matching.
Full-text search is not the same as applying LIKE '%term%' to every row. It builds an inverted index of words, removes common stop words, applies language rules such as stemming, and ranks documents according to relevance. That index is fast for natural-language queries, although it does not automatically understand every typo, synonym, or business-specific phrase.
That approach fits many Australian teams that already maintain .NET and SQL Server estates, from a Brisbane consultancy to a Melbourne software house. It also suits people searching during a commute on Melbourne trams or Sydney trains, where patchy mobile connections make aggressive polling especially unhelpful. The interface should remain useful on an ordinary phone screen and avoid flooding an Australian broadband connection with unnecessary requests.
Define the Search Contract
Begin with the questions the feature must answer rather than with the controls. Which document fields should users search? Which filters, such as category or publication date, must work alongside the text? Does the result count need to be exact, or can it be estimated for a large corpus? Write these decisions into a small contract that the API, Angular client, and tests can share.
A GET endpoint works well for an ordinary query: /api/search?q=angular+testing&page=1&pageSize=20. Return a consistent payload rather than SQL rows, and keep ranking details behind that boundary. An exact count can be expensive, so make it optional if the product can work with “more results” instead.
{
"items": [
{
"id": "3f9c…",
"title": "Working with SQL Server full-text search",
"snippet": "A matching passage from the document…",
"rank": 3
}
],
"page": 1,
"pageSize": 20,
"totalCount": 37
}
Define the edge cases as part of the contract: empty input, an overlong phrase, a misspelling, a page beyond the final page, and a query with no matches. Keep the maximum page size modest, use a stable sort with a document identifier as the tie-breaker, and return a consistent error shape. Those choices prevent pagination drift when two documents have the same rank.
Model the Searchable Content
Keep the authoring model separate from the searchable projection. A SearchDocuments table might contain only an identifier, title, body, snippet, category, and update timestamp. Those fields give the index a stable, narrow surface and prevent internal columns from leaking into results. Full-Text Search must be available on the SQL Server instance before you create the catalogue and index.
CREATE FULLTEXT CATALOG DocumentsCatalog AS DEFAULT;
CREATE FULLTEXT INDEX ON dbo.SearchDocuments (Title, Body)
KEY INDEX PK_SearchDocuments
WITH (CHANGE_TRACKING = AUTO);
Title and body are different signals. A title match can be stronger than a passing mention in the body, while the snippet gives the user context without returning an entire document. Choose fields deliberately, configure the catalogue’s language and stop list, and test tokenisation and stemming against your real vocabulary. Full-Text Search also supports synonyms and prefix expressions, but they require deliberate configuration rather than emerging automatically from fuzzy matching.
A simplified ranking query can use FREETEXT for broad term matching:
;WITH matches AS
(
SELECT
d.Id,
d.Title,
d.Snippet,
RANK(
OFFSET(0),
FREETEXT(d.Title, @Query),
FREETEXT(d.Body, @Query)
) AS Rank
FROM dbo.SearchDocuments AS d
WHERE FREETEXT(d.Title, @Query)
OR FREETEXT(d.Body, @Query)
)
SELECT Id, Title, Snippet, Rank
FROM matches
ORDER BY Rank ASC, Id
OFFSET @Offset ROWS
FETCH NEXT @PageSize ROWS ONLY;
Here, a lower rank is better. The example treats title and body equally, so add column weights or another ranking policy when title evidence should count more heavily. If users need quoted phrases, prefix matching, or explicit Boolean-style expressions, use CONTAINS deliberately rather than mixing query behaviours without tests.
Keep the index in step with the source data. Automatic change tracking helps, but bulk edits, deletes, and catalogue maintenance still need monitoring. A document can exist in the table yet be absent from results for a short period, so freshness belongs in your operational checks. For background on the engine’s behaviour, Mike Patt’s full-text notes offer a useful technical reference.
Expose Search Through a .NET API
Let the ASP.NET Core service own query parsing, data access, and response shaping. A small endpoint can validate the request, call a search service, and pass the cancellation token through every database operation. Keep SQL out of the controller, and return a DTO that the Angular client can consume without understanding tables, full-text operators, or rank values.
app.MapGet("/api/search", async (
SearchService search,
CancellationToken cancellationToken,
string? q,
int page = 1,
int pageSize = 20) =>
{
if (string.IsNullOrWhiteSpace(q) || q.Length > 100)
return Results.BadRequest(new { error = "Invalid query." });
if (page < 1 || pageSize is < 1 or > 50)
return Results.BadRequest(new { error = "Invalid paging values." });
var terms = SearchTerms.Normalise(q);
if (terms.Count == 0)
return Results.BadRequest(new { error = "Invalid query." });
var result = await search.SearchAsync(
terms, page, pageSize, cancellationToken);
return Results.Ok(result);
});
Register the service through dependency injection and keep SearchTerms.Normalise in a small, independently tested utility. Do not expect Entity Framework Core to translate SQL Server full-text predicates into portable LINQ; use a focused SQL query or stored procedure behind the repository.
Treat normalisation as a separate, testable step. Trim the phrase, reject an excessive length, and turn user input into ordinary terms for the chosen query mode. Use bound SQL parameters for every value, including the page size and offset. Separately, remove full-text control syntax or quote individual terms so a word such as AND cannot silently change a CONTAINS query. Test the normaliser with empty strings, punctuation, repeated spaces, and mixed case.
Authorisation must happen in the same database query that returns a document. A full-text index does not enforce access rules, and filtering an unrestricted result set in the browser would leak titles and snippets. In the private sector, the Privacy Act 1988 and Australian Privacy Principles may apply when query strings contain personal information; health, financial, or identity-related searches deserve particular care. Prefer omitting raw search terms from routine logs.
Hosting the API and an appropriate SQL Server deployment in an Australian Azure region can reduce round-trip time for Sydney and Melbourne users. It does not settle data residency by itself: backups, logs, and support access still need review.
Build the Angular Search Experience
In Angular, model the input and the request as separate streams. A reactive form control can feed an observable request pipeline: trim the value, wait for a short pause in typing, ignore duplicates, and then call the API. This keeps the component free of imperative subscription bookkeeping and makes the behaviour straightforward to test.
Inside a standalone component with the relevant Angular and RxJS imports in place, the request stream can remain compact:
readonly query = new FormControl('', { nonNullable: true });
readonly results$ = this.query.valueChanges.pipe(
startWith(this.query.value),
map(value => value.trim()),
debounceTime(250),
distinctUntilChanged(),
switchMap(value => {
if (value.length < 2) {
return of(emptyResults);
}
return this.http.get<SearchResponse>('/api/search', {
params: { q: value },
});
})
);
switchMap is important here. When a visitor types another character, Angular unsubscribes from the previous inner observable, which cancels the abandoned HTTP request and prevents a slow response from replacing newer results. Handle failures inside that inner request so one timeout does not end the outer stream. Set the busy flag inside that branch and clear it when the request finishes. A failed request needs its own message; it should not masquerade as a valid zero-result search.
Keep result rendering simple: show a labelled input, a busy state, the total count, and a short list of titles with snippets. Use Angular’s normal interpolation for snippets so document content is sanitised by default; do not bypass sanitisation to add highlighting without evaluating the risk. Put a polite status update in an aria-live region, preserve keyboard access, and make the empty state explain what can be searched.
Recommendations for a Reliable Release
A release checklist is more useful than a vague promise to polish the feature later. The following practices cover the boundaries where search systems tend to fail.
- Keep the API contract stable and separate it from database rows, so a schema change does not become an accidental client break.
- Validate query length, page size, and authorisation on the server, regardless of what the Angular form checks.
- Debounce keystrokes and cancel stale requests, but provide an explicit submit action for users who do not want to wait.
- Keep raw query strings out of routine logs, and apply the Australian Privacy Principles when search analytics contain personal information.
- Test with representative documents and queries, including stop words, stemming, phrases, empty results, and records the user cannot access.
Turn those checks into automated tests. Unit-test the term normaliser and ranking policy, exercise the endpoint against a seeded SQL Server database, and add browser tests for typing, submitting, clearing the field, and recovering from an API error. Run load tests with concurrent searches and slow connections. A small judged set of queries with known good documents gives the team a practical relevance baseline without collecting real users’ sensitive searches.
Launch with Measurable Search Behaviour
Measure the experience in ways that reveal both speed and quality. Track server-side latency, timeout rate, zero-result rate, indexed freshness, and whether users open a result. Keep those metrics aggregate and release-aware rather than storing every phrase. A fast response with irrelevant documents is still a failure, so review the judged query set whenever the catalogue, synonyms, or ranking policy changes.
Roll the feature out in stages when the corpus is large or the ranking is still changing. A feature flag in ASP.NET Core can limit exposure, while an indexed copy of the catalogue lets you rebuild and compare results without disrupting the authoring database. Keep the source tables authoritative, schedule expensive maintenance outside busy hours, and make rollback a routine part of the release plan rather than an emergency exercise.
Build the endpoint, connect the Angular form, seed a representative catalogue, and ship the first measured release with a visible route for reporting missing or irrelevant results.