ASP.NET MVC - Paginação (Teoria e prática)


     Neste artigo vamos mostrar como fazer uma paginação decente em uma aplicação ASP.NET MVC no .NET 10.

A paginação é a técnica de dividir um grande conjunto de dados em partes menores (páginas), exibindo apenas um subconjunto por vez.

Ela é um aspecto crucial das aplicações web, permitindo a navegação eficiente por grandes conjuntos de dados. Na ASP.NET Core MVC, existem duas abordagens comuns para paginação: paginação no cliente (client-side) e paginação no servidor (server-side).



Podemos dizer que objetivo da paginação é :  Reduzir carga no servidor,  Melhorar performance, Melhorar UX, Evitar sobrecarga de memória

Paginação Server-Side

A paginação do lado do servidor (Server-side) ocorre quando o banco de dados retorna apenas os registros da página solicitada.

O servidor recebe:
   Número da página
   Tamanho da página
   E executa a consulta já limitada.

As principais características da paginação server-side são:
✔ É Escalável
✔ È Eficiente
✔ È ideal para grandes volumes
✔ Executa paginação no banco
✔ Pode usar índices

Quando usar :  Para listagens grandes, em sistemas corporativos, em APIs , em qualquer cenário com milhares/milhões de registros

Nota:
     
Um erro muito comum é buscar tudo e depois usar:   .ToList().Skip().Take()
     
Isso NÃO é paginação server-side real.

Paginação Client-Side

A paginação do lado do cliente ou Client-side ocorre quando todos os dados são carregados uma única vez, e a paginação acontece no navegador.

O fluxo correto é o seguinte:
    Servidor envia TODOS os registros
    Navegador armazena em memória
    JavaScript divide os dados em páginas
    Troca de página NÃO chama o servidor

Principais características desta abordagem:
✔ Interação instantânea
✔ Sem novas requisições
✔ Simples de implementar
❌ Não escalável
❌ Alto consumo de memória
❌ Inicialmente pode ser pesado

Quando usar a paginação do lado do cliente:
   Em pequenas listas (até algumas centenas de registros)
   Em dados estáticos
   EmDashboards simples
   Em aplicações internas pequenas

Existem dois modelos principais para implementar a paginação:

1- Offset Pagination (Skip/Take)

OFFSET X FETCH NEXT Y

Simples, mas pode degradar em tabelas muito grandes.

2- Keyset Pagination (Seek Method)

Usa:
    WHERE Id > últimoId
    ORDER BY Id

Esta abordagem tem melhor desempenho e estabilidade
Ela é usada em sistemas de alto volume (ex: redes sociais).

Resumindo:

 A paginação Server-side => Ccontrole no banco, escalável, padrão profissional.
 A paginação Client-side => Ccontrole no navegador, rápido para poucos dados, limitado.

Projeto Prático : Paginação do lado do servidor

Para ilustrar o uso da paginação usando uma abordagem correta vamos criar um projeto ASP.NET MVC no .NET 10 onde vamos mostrar como implemjentar a paginação real no banco usando ViewModel com extensão reutilizável e async/await com AsNoTracking e separação por camadas. Vamos usar o EF Core e o banco de dados SQL Server.

Estrutura do projeto

AspNetCoreMvcPagingSample├── src
│   ├── PagingSample.Web
│   │   ├── Controllers
│   │   │   └── ProductsController.cs
│   │   │
│   │   ├── Data
│   │   │   ├── AppDbContext.cs
│   │   │   └── DbInitializer.cs
│   │   │
│   │   ├── Extensions
│   │   │   └── IQueryableExtensions.cs
│   │   │
│   │   ├── Models
│   │   │   └── Product.cs
│   │   │
│   │   ├── ViewModels
│   │   │   └── PagedResult.cs
│   │   │
│   │   ├── Views
│   │   │   ├── Products
│   │   │   │   └── Index.cshtml
│   │   │   └── Shared
│   │   │
│   │   └── Program.cs
│   │
│   └── PagingSample.sln

1- Criando o projeto

A seguir temos os comandos usados para criar o projeto no VS Code. No Visual Studio basta selecionar o template ASP.NET Core Web App (Model-View-Controller)

dotnet new mvc -n PagingSample.Web
cd PagingSample.Web

dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Microsoft.EntityFrameworkCore.Design

2- Entidade (Models/Product.cs)

namespace PagingSample.Web.Models;

public sealed class Product
{
    public int Id { get; init; }
    public required string Name { get; set; }
    public decimal Price { get; set; }
}

Aqui usamos uam classe selada para evitar herança, o required para exigir que o nome seja obrigatório.

3- DbContext (Data/AppDbContext.cs)

using Microsoft.EntityFrameworkCore;
using PagingSample.Web.Models;
namespace PagingSample.Web.Data;
public sealed class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options) { }
    public DbSet<Product> Products => Set<Product>();
}

Vamos definir a classe DbInitializer  para inicializar os dados na tabela Products:

3- Seed de Dados (Data/DbInitializer.cs)

using PagingSample.Web.Models;
namespace PagingSample.Web.Data;
public static class DbInitializer
{
    public static async Task SeedAsync(AppDbContext context)
    {
        if (context.Products.Any())
            return;
        var products = Enumerable.Range(1, 200)
            .Select(i => new Product
            {
                Name = $"Product {i}",
                Price = Random.Shared.Next(10, 500)
            });
        context.Products.AddRange(products);
        await context.SaveChangesAsync();
    }
}

Temos aqui uma classe utilitária para popular o banco com dados iniciais (seed).

Ela Verifica se já existem produtos e evita duplicação e gera 200 produtos automaticamente usando Enumerable.Range.

A seguir atribui nome sequencial e preço aleatório com Random.Shared e insere os dados no contexto e persiste com SaveChangesAsync.

4- ViewModel Genérico (ViewModels/PagedResult.cs)

namespace PagingSample.Web.ViewModels;
public sealed record PagedResult<T>(
    IReadOnlyList<T> Items,
    int Page,
    int PageSize,
    int TotalCount)
{
    public int TotalPages =>
        (int)Math.Ceiling((double)TotalCount / PageSize);
    public bool HasPrevious => Page > 1;
    public bool HasNext => Page < TotalPages;
}

Neste código PagedResult<T> é um modelo de resultado paginado usado para transportar dados da aplicação (Controller → View).

Ela encapsula:
   os dados da página atual
   informações da paginação
   propriedades auxiliares para navegação

Atenção: Se alguém passar PageSize = 0, vai dar divisão por zero. Em produção, você normalmente valida isso antes.

5- Extensão Reutilizável (Extensions/IQueryableExtensions.cs)

using Microsoft.EntityFrameworkCore;
using PagingSample.Web.ViewModels;
namespace PagingSample.Web.Extensions;
public static class IQueryableExtensions
{
    public static async Task<PagedResult<T>> ToPagedResultAsync<T>(
        this IQueryable<T> query,
        int page,
        int pageSize,
        CancellationToken cancellationToken = default)
    {
        if (page <= 0)
            page = 1;
        if (pageSize <= 0)
            pageSize = 10;
        var totalCount = await query.CountAsync(cancellationToken);
        var items = await query
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync(cancellationToken);
        return new PagedResult<T>(
            items,
            page,
            pageSize,
            totalCount);
    }
}

Temos aqui a paginação realizada no banco de forma assíncrona e que pode ser reutilizável por qualquer entidade:

- Temos um método de extensão para IQueryable<T> que cria paginação assíncrona com EF Core.
- Ele garante valores válidos para page e pageSize (mínimo 1 e 10)
- Obtém o total de registros usando CountAsync.
- Aplica paginação no banco com
Skip e Take, evitando trazer tudo para memória.
- Executa a consulta com ToListAsync, respeitando o CancellationToken.
- Retorna um PagedResult<T> com os itens da página e os metadados da paginação.

6- Controller (Controllers/ProductsController.cs)

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PagingSample.Web.Data;
using PagingSample.Web.Extensions;
using PagingSample.Web.Models;
namespace PagingSample.Web.Controllers;
public sealed class ProductsController : Controller
{
    private readonly AppDbContext _context;
    public ProductsController(AppDbContext context)
    {
        _context = context;
    }
    public async Task<IActionResult> Index(
        int page = 1,
        int pageSize = 10,
        CancellationToken cancellationToken = default)
    {
        var query = _context.Products
            .AsNoTracking()
            .OrderBy(p => p.Id);
        var result = await query.ToPagedResultAsync(
            page,
            pageSize,
            cancellationToken);
        return View(result);
    }
}

Neste código usamos AsNoTracking e OrderBy que é obrigatório para paginação. Usamos também o CancellationToken que permiter cancelar a operação.

7- View (Views/Products/Index.cshtml)

@model PagingSample.Web.ViewModels.PagedResult<PagingSample.Web.Models.Product>
<h2>Products</h2>
<table class="table table-striped">
    <thead>
        <tr>
            <th>Id</th>
            <th>Name</th>
            <th>Price</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Items)
        {
            <tr>
                <td>@item.Id</td>
                <td>@item.Name</td>
                <td>@item.Price.ToString("C")</td>
            </tr>
        }
    </tbody>
</table>
<nav>
    <ul class="pagination">
        @if (Model.HasPrevious)
        {
            <li class="page-item">
                <a class="page-link"
                   asp-route-page="@(Model.Page - 1)"
                   asp-route-pageSize="@Model.PageSize">
                    Previous
                </a>
            </li>
        }
        @for (int i = 1; i <= Model.TotalPages; i++)
        {
            <li class="page-item @(i == Model.Page ? "active" : "")">
                <a class="page-link"
                   asp-route-page="@i"
                   asp-route-pageSize="@Model.PageSize">
                    @i
                </a>
            </li>
        }
        @if (Model.HasNext)
        {
            <li class="page-item">
                <a class="page-link"
                   asp-route-page="@(Model.Page + 1)"
                   asp-route-pageSize="@Model.PageSize">
                    Next
                </a>
            </li>
        }
    </ul>
</nav>

Aqui fazemos a paginação completa compatível com o Bootstrap sem lógica de acesso a dados na View.

8- Classe Program

using Microsoft.EntityFrameworkCore;
using PagingSample.Web.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("DefaultConnection")));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
    var context = scope.ServiceProvider
        .GetRequiredService<AppDbContext>();
    await context.Database.EnsureCreatedAsync();
    await DbInitializer.SeedAsync(context);
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Products}/{action=Index}/{id?}");
app.Run();

A seguir temos o código em appsettings.json:

{
   "ConnectionStrings": {
      "DefaultConnection": "Server=MacorattiPC;Database=PagingSampleDb;Trusted_Connection=True;TrustServerCertificate=True;"
   }
}

Agora é só alegria...

Basta executar no Visual Studio ou no VS Code digitar : dotnet run

Com isso iremos obter:

Após implementar corretamente a paginação server-side e executar a aplicação, o resultado exibido confirma que a paginação está funcionando como esperado.

A página mostra:
   Apenas 10 produtos por vez
   Navegação entre páginas (1 até 20)
   Botão Next para avançar
   URL refletindo os parâmetros page e pageSize

Isso comprova que:
✔ O número da página está sendo processado no servidor
✔ Apenas os registros da página atual são retornados pelo banco
✔ Não há carregamento completo da tabela em memória
✔ A navegação é estável graças ao OrderBy
✔ A solução é escalável

Cada clique em uma página executa uma nova requisição HTTP e gera uma nova consulta SQL contendo OFFSET e FETCH, garantindo que somente o subconjunto necessário de dados seja recuperado.

O que essa implementação demonstra na prática
    Uso correto de IQueryable
    Materialização apenas após Skip/Take
    Uso de AsNoTracking() para leitura eficiente
    Separação adequada entre Controller, ViewModel e View
    Encapsulamento da lógica de paginação em extensão

O comportamento visual apresentado confirma que a paginação está correta com um bom desempenho alinhada as boas práticas modernas e adequada para cenários de produção

Não é apenas um exemplo didático — é uma implementação profissional reutilizável. Se quiser elevar ainda mais o nível, o próximo passo é integrar isso com: filtros, ordenação e busca (search).

Pegue o projeto completo em :  https://github.com/macoratti/PagingSample.Web

E estamos conversados...  

"(Disse Jesus) Na casa de meu Pai há muitas moradas; se não fosse assim, eu vo-lo teria dito. Vou preparar-vos lugar."
João 14:2

Referências:


José Carlos Macoratti