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:
NET - Unit of Work - Padrão Unidade de ...