DEV Community

Cover image for Structuring Reusable Search Screens in Razor Pages
Y.Arakawa
Y.Arakawa

Posted on

Structuring Reusable Search Screens in Razor Pages

Search screens look simple when you only have one or two of them.

That was not the situation I was dealing with.

I had many business screens with the same basic shape: a few search inputs, paging, sorting, and a table of results. The problem was not that any single screen was hard to build. The problem was that each new screen repeated the same query-building work in a slightly different way, and small changes started spreading across too many PageModel classes.

So the reason I changed the structure was straightforward: I wanted each screen to describe its own search conditions without re-implementing the same filtering and paging mechanics every time.

What worked better for me was to keep the search input on the PageModel, map those properties to entity fields with attributes, and let shared infrastructure build the EF Core query.

This is not a universal solution for every kind of search UI. It fits CRUD-heavy business screens where GET-based filtering, paging, and predictable extension matter more than highly custom search behavior.

The Shape I Wanted

The screen itself stays ordinary.

It is still a GET form with a results table.

@page
@model UsersModel

<form method="get">
    <div>
        <label asp-for="Name"></label>
        <input asp-for="Name" />
    </div>

    <div>
        <label asp-for="Phone"></label>
        <input asp-for="Phone" />
    </div>

    <button type="submit">Search</button>
</form>

<table>
    <!-- result rows -->
</table>
Enter fullscreen mode Exit fullscreen mode

The important part is not the Razor markup. The important part is that search state stays in the query string, so reload, sharing, and debugging remain simple.

On the server side, I wanted the screen model to declare only the fields it cares about.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

[BindProperties(SupportsGet = true)]
public abstract class SearchPageModel<TEntity> : PageModel where TEntity : BaseEntity
{
    protected readonly IRepository<TEntity> Repository;

    protected SearchPageModel(IRepository<TEntity> repository)
    {
        Repository = repository;
    }

    public int Skip { get; set; }
    public int Take { get; set; } = 20;
    public string? Order { get; set; }

    public PagedResult<TEntity>? Results { get; protected set; }

    public virtual async Task OnGetAsync()
    {
        var options = QueryOptionsBuilder.Build<TEntity>(this);
        Results = await Repository.QueryAsync(options);
    }
}

public sealed class UsersModel : SearchPageModel<User>
{
    public UsersModel(IRepository<User> repository) : base(repository)
    {
    }

    [Filter(FilterComparison.Contains, nameof(User.Name))]
    public string? Name { get; set; }

    [Filter(FilterComparison.Contains, nameof(User.Phone))]
    public string? Phone { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

That is the boundary I cared about.

The screen declares search intent.
Shared code handles the repetitive mechanics.

Moving Query Rules Out of the Screen

The small piece that made this scale better was a simple mapping attribute.

[AttributeUsage(AttributeTargets.Property, Inherited = true)]
public sealed class FilterAttribute : Attribute
{
    public FilterAttribute(FilterComparison comparison = FilterComparison.Equal, string? entityFieldName = null)
    {
        Comparison = comparison;
        EntityFieldName = entityFieldName;
    }

    public FilterComparison Comparison { get; }
    public string? EntityFieldName { get; }
}

public enum FilterComparison
{
    Equal,
    Contains,
    StartsWith
}
Enter fullscreen mode Exit fullscreen mode

Then shared infrastructure reads those properties and turns them into query options.

using System.Linq.Expressions;
using System.Reflection;

public static class QueryOptionsBuilder
{
    public static QueryOptions<T> Build<T>(object conditions) where T : BaseEntity
    {
        var filters = new List<Expression<Func<T, bool>>>();

        foreach (var property in conditions.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
        {
            var filter = property.GetCustomAttribute<FilterAttribute>();
            var value = property.GetValue(conditions) as string;

            if (filter is null || string.IsNullOrWhiteSpace(value))
            {
                continue;
            }

            var entityFieldName = filter.EntityFieldName ?? property.Name;
            var parameter = Expression.Parameter(typeof(T), "x");
            var member = Expression.Property(parameter, entityFieldName);
            var contains = typeof(string).GetMethod(nameof(string.Contains), new[] { typeof(string) })!;
            var body = Expression.Call(member, contains, Expression.Constant(value.Trim()));

            filters.Add(Expression.Lambda<Func<T, bool>>(body, parameter));
        }

        // In a real implementation, sorting and paging would typically come from the request (e.g., UI input).
        // Hard-coded here for simplicity since they are not the focus of this article. 
        return new QueryOptions<T>(filters, orderBy: "Name ASC", skip: 0, take: 20);
    }
}
Enter fullscreen mode Exit fullscreen mode

This example is intentionally simplified, but the design point is the same:
the PageModel should describe search fields, not rebuild query assembly logic over and over.

Why This Worked Better

The main benefit was not fewer lines of code by itself.

It was that the extension path became predictable.

If a screen needed one more condition, I added one property and one attribute.
If I wanted to adjust shared search behavior, I changed the infrastructure once.

Using nameof also removed a lot of fragile string matching between screen fields and entity fields, which matters more than it first appears when CRUD screens start multiplying.

This structure also kept the screens naturally URL-driven.
For this kind of business search screen, that is usually what I want.

Trade-Offs

This approach is useful when the search model is mostly declarative.

If a screen has deeply custom filtering rules, highly dynamic query composition, or search behavior that no longer maps cleanly to a property-per-condition model, I would not force it into this shape.

The point is not to abstract every search screen.
The point is to stop repeating the same mechanical work where the screens are actually similar.

Closing

What I wanted was simple: each screen should describe what it searches, and shared infrastructure should handle how the query gets assembled.

That boundary made Razor Pages search screens easier to extend without turning each PageModel into a copy of the previous one.

This kind of structural thinking is also part of what led me to build Cotomy. If you want the longer architectural background behind how I structure business-oriented web screens, I’ve been writing more of that here: https://blog.cotomy.net/

Top comments (0)