diff --git a/JsonApiToolkit.Tests/Integration/PaginationLinkPreservationTests.cs b/JsonApiToolkit.Tests/Integration/PaginationLinkPreservationTests.cs
new file mode 100644
index 0000000..6ce1fe1
--- /dev/null
+++ b/JsonApiToolkit.Tests/Integration/PaginationLinkPreservationTests.cs
@@ -0,0 +1,145 @@
+using System.Text.Json;
+using JsonApiToolkit.Extensions;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace JsonApiToolkit.Tests.Integration;
+
+///
+/// Integration tests for PreserveQueryInPaginationLinks: first/last/prev/next
+/// keep the request's query string with only the page parameters replaced.
+/// Re-uses the QueryTest fixtures from JsonApiQueryAsyncTests.
+///
+public class PaginationLinkPreservationTests : IDisposable
+{
+ private readonly IHost _host;
+ private readonly HttpClient _client;
+
+ public PaginationLinkPreservationTests()
+ {
+ var databaseName = $"PaginationLinksTestDb_{Guid.NewGuid()}";
+
+ _host = new HostBuilder()
+ .ConfigureWebHost(webBuilder =>
+ {
+ webBuilder
+ .UseTestServer()
+ .ConfigureServices(services =>
+ {
+ services.AddDbContext(options =>
+ options.UseInMemoryDatabase(databaseName)
+ );
+ services.AddControllers();
+ services.AddJsonApiToolkit(options =>
+ {
+ options.PreserveQueryInPaginationLinks = true;
+ });
+ })
+ .Configure(app =>
+ {
+ app.UseRouting();
+ app.UseEndpoints(endpoints => endpoints.MapControllers());
+
+ using var scope = app.ApplicationServices.CreateScope();
+ var context =
+ scope.ServiceProvider.GetRequiredService();
+ SeedData(context);
+ });
+ })
+ .Build();
+
+ _host.Start();
+ _client = _host.GetTestClient();
+ }
+
+ private static void SeedData(QueryTestDbContext context)
+ {
+ for (int i = 1; i <= 6; i++)
+ {
+ context.Articles.Add(
+ new QueryTestArticle
+ {
+ Id = i,
+ Title = $"Article {i}",
+ Content = $"Content {i}",
+ CreatedAt = new DateTime(2024, 1, i),
+ IsPublished = true,
+ ViewCount = i * 10,
+ }
+ );
+ }
+ context.SaveChanges();
+ }
+
+ private async Task> GetLinksAsync(string url)
+ {
+ var response = await _client.GetAsync(url);
+ response.EnsureSuccessStatusCode();
+
+ using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ return doc
+ .RootElement.GetProperty("links")
+ .EnumerateObject()
+ .ToDictionary(p => p.Name, p => p.Value.GetString());
+ }
+
+ [Fact]
+ public async Task Links_PreserveFilterAndSort_ReplaceOnlyPageParamsAsync()
+ {
+ var links = await GetLinksAsync(
+ "/api/articles?filter[isPublished]=true&sort=-viewCount&page[number]=2&page[size]=2"
+ );
+
+ // Keys are re-encoded by the link builder, so brackets appear as %5B/%5D
+ Assert.Equal(
+ "http://localhost/api/articles"
+ + "?filter%5BisPublished%5D=true&sort=-viewCount&page%5Bnumber%5D=3&page%5Bsize%5D=2",
+ links["next"]
+ );
+ Assert.Contains("filter%5BisPublished%5D=true", links["prev"]);
+ Assert.Contains("page%5Bnumber%5D=1", links["prev"]);
+ Assert.Contains("page%5Bnumber%5D=1", links["first"]);
+ Assert.Contains("page%5Bnumber%5D=3", links["last"]);
+ }
+
+ [Fact]
+ public async Task Links_WithOnlyPageParams_ContainJustPageParamsAsync()
+ {
+ var links = await GetLinksAsync("/api/articles?page[number]=1&page[size]=2");
+
+ Assert.Equal(
+ "http://localhost/api/articles?page%5Bnumber%5D=2&page%5Bsize%5D=2",
+ links["next"]
+ );
+ Assert.False(links.ContainsKey("prev"));
+ }
+
+ [Fact]
+ public async Task NextLink_IsFollowable_AndKeepsFilterAppliedAsync()
+ {
+ var links = await GetLinksAsync(
+ "/api/articles?filter[viewCount][gt]=20&page[number]=1&page[size]=2"
+ );
+
+ // 4 matches (30..60), page size 2 -> next is page 2 with the filter intact
+ var next = new Uri(links["next"]!);
+ var response = await _client.GetAsync(next.PathAndQuery);
+ response.EnsureSuccessStatusCode();
+
+ using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ var pagination = doc.RootElement.GetProperty("meta").GetProperty("pagination");
+ Assert.Equal(4, pagination.GetProperty("totalResources").GetInt32());
+ Assert.Equal(2, pagination.GetProperty("currentPage").GetInt32());
+ }
+
+ public void Dispose()
+ {
+ _client.Dispose();
+ _host.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/JsonApiToolkit/Configuration/JsonApiOptions.cs b/JsonApiToolkit/Configuration/JsonApiOptions.cs
index 0fa6cd2..c0d231a 100644
--- a/JsonApiToolkit/Configuration/JsonApiOptions.cs
+++ b/JsonApiToolkit/Configuration/JsonApiOptions.cs
@@ -63,6 +63,14 @@ public class JsonApiOptions
///
public bool StrictQueryValidation { get; set; }
+ ///
+ /// When true, pagination links (first/last/prev/next) preserve the request's full
+ /// query string (filter, sort, include, fields) with only the page parameters
+ /// replaced. Default: false (links are rebuilt from the bare path and drop all
+ /// other query parameters, for backwards compatibility).
+ ///
+ public bool PreserveQueryInPaginationLinks { get; set; }
+
///
/// When true, applies database-level column filtering via EF Core Select() projection
/// when fields[type] is specified in the request. Only fetches requested columns from
diff --git a/JsonApiToolkit/Controllers/JsonApiController.cs b/JsonApiToolkit/Controllers/JsonApiController.cs
index 05dc4de..339a5d3 100644
--- a/JsonApiToolkit/Controllers/JsonApiController.cs
+++ b/JsonApiToolkit/Controllers/JsonApiController.cs
@@ -178,7 +178,8 @@ protected IActionResult JsonApiOk(
paginationMeta,
mappedIncludes,
Logger,
- parameters.Fields
+ parameters.Fields,
+ Options.PreserveQueryInPaginationLinks
);
return Ok(document);
}
@@ -254,7 +255,8 @@ string resourceType
paginationMeta,
mappedIncludes,
Logger,
- parameters.Fields
+ parameters.Fields,
+ Options.PreserveQueryInPaginationLinks
);
return Ok(document);
@@ -589,7 +591,8 @@ QueryParameters parameters
paginationMeta,
mappedIncludes,
Logger,
- parameters.Fields
+ parameters.Fields,
+ Options.PreserveQueryInPaginationLinks
);
return Ok(projectedDocument);
diff --git a/JsonApiToolkit/Mapping/JsonApiMapper.cs b/JsonApiToolkit/Mapping/JsonApiMapper.cs
index a929d0f..cb938e8 100644
--- a/JsonApiToolkit/Mapping/JsonApiMapper.cs
+++ b/JsonApiToolkit/Mapping/JsonApiMapper.cs
@@ -265,6 +265,7 @@ public static JsonApiDocument ToDocument(
/// Optional list of relationship paths to include.
/// Optional logger for debugging and tracing
/// Optional sparse fieldsets per resource type
+ /// When true, pagination links keep the full query string with only the page parameters replaced
/// The JSON:API collection document.
public static JsonApiCollectionDocument ToCollectionDocument(
IEnumerable entities,
@@ -273,7 +274,8 @@ public static JsonApiCollectionDocument ToCollectionDocument(
PaginationMeta? paginationMeta = null,
List? includedRelationships = null,
ILogger? logger = null,
- Dictionary>? fields = null
+ Dictionary>? fields = null,
+ bool preserveQueryInLinks = false
)
where T : class
{
@@ -321,20 +323,20 @@ public static JsonApiCollectionDocument ToCollectionDocument(
int pageSize = paginationMeta.PageSize;
- document.Links.First = $"{baseUrl}?page[number]=1&page[size]={pageSize}";
- document.Links.Last =
- $"{baseUrl}?page[number]={paginationMeta.TotalPages}&page[size]={pageSize}";
+ string PageLink(int pageNumber) =>
+ BuildPaginationLink(baseUrl, selfLink, pageNumber, pageSize, preserveQueryInLinks);
+
+ document.Links.First = PageLink(1);
+ document.Links.Last = PageLink(paginationMeta.TotalPages);
if (paginationMeta.CurrentPage > 1)
{
- document.Links.Prev =
- $"{baseUrl}?page[number]={paginationMeta.CurrentPage - 1}&page[size]={pageSize}";
+ document.Links.Prev = PageLink(paginationMeta.CurrentPage - 1);
}
if (paginationMeta.CurrentPage < paginationMeta.TotalPages)
{
- document.Links.Next =
- $"{baseUrl}?page[number]={paginationMeta.CurrentPage + 1}&page[size]={pageSize}";
+ document.Links.Next = PageLink(paginationMeta.CurrentPage + 1);
}
}
@@ -375,4 +377,42 @@ public static JsonApiCollectionDocument ToCollectionDocument(
return document;
}
+
+ ///
+ /// Builds a pagination link. Default: bare path + page params only (legacy).
+ /// With : the original query string with only
+ /// the page parameters replaced, so filters/sort/include/fields survive.
+ ///
+ private static string BuildPaginationLink(
+ string baseUrl,
+ string selfLink,
+ int pageNumber,
+ int pageSize,
+ bool preserveQuery
+ )
+ {
+ if (!preserveQuery)
+ return $"{baseUrl}?page[number]={pageNumber}&page[size]={pageSize}";
+
+ int queryStart = selfLink.IndexOf('?');
+ string query = queryStart >= 0 ? selfLink[queryStart..] : string.Empty;
+
+ var builder = new Microsoft.AspNetCore.Http.Extensions.QueryBuilder();
+ foreach (var kvp in Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(query))
+ {
+ if (
+ kvp.Key.Equals("page[number]", StringComparison.OrdinalIgnoreCase)
+ || kvp.Key.Equals("page[size]", StringComparison.OrdinalIgnoreCase)
+ )
+ continue;
+
+ foreach (string? value in kvp.Value)
+ builder.Add(kvp.Key, value ?? string.Empty);
+ }
+
+ builder.Add("page[number]", pageNumber.ToString());
+ builder.Add("page[size]", pageSize.ToString());
+
+ return baseUrl + builder.ToQueryString().Value;
+ }
}
diff --git a/clients/typescript/contract/pagination_contract_test.ts b/clients/typescript/contract/pagination_contract_test.ts
index 003eaff..61f256c 100644
--- a/clients/typescript/contract/pagination_contract_test.ts
+++ b/clients/typescript/contract/pagination_contract_test.ts
@@ -6,6 +6,7 @@ import { assert, assertEquals, assertFalse } from '@std/assert';
import {
BASE_URL,
getDoc,
+ PUBLISHED_ARTICLES,
STRICT_BASE_URL,
total,
TOTAL_ARTICLES,
@@ -144,3 +145,30 @@ Deno.test('strict pagination (StrictPagination = true)', async (t) => {
},
);
});
+
+Deno.test(
+ 'pagination links with PreserveQueryInPaginationLinks (opt-in, strict instance)',
+ async (t) => {
+ await t.step('links keep the query, only page params change', async () => {
+ const { doc } = await getDoc(
+ 'articles?filter%5Bpublished%5D=true&sort=-viewCount&page%5Bsize%5D=2',
+ STRICT_BASE_URL,
+ );
+ // keys are re-encoded by the link builder (%5B/%5D)
+ assertEquals(
+ doc.links.next,
+ `${STRICT_BASE_URL}/articles` +
+ '?filter%5Bpublished%5D=true&sort=-viewCount&page%5Bnumber%5D=2&page%5Bsize%5D=2',
+ );
+
+ // following next keeps the filtered, sorted result set
+ const next = new URL(doc.links.next);
+ const { doc: page2 } = await getDoc(
+ `articles${next.search}`,
+ STRICT_BASE_URL,
+ );
+ assertEquals(total(page2), PUBLISHED_ARTICLES);
+ assertEquals(page2.data[0].id, '21'); // -viewCount page 2: 21, 19
+ });
+ },
+);
diff --git a/docs/querying.md b/docs/querying.md
index 2489c20..554f5ca 100644
--- a/docs/querying.md
+++ b/docs/querying.md
@@ -54,6 +54,12 @@ page[number]=2&page[size]=10
By default, invalid values are silently clamped (`page[number]=0` → 1, oversized → `MaxPageSize`). Enable `StrictPagination` to return errors instead. See [Security](security.md#strict-pagination).
+Pagination links (`first`/`last`/`prev`/`next`) are rebuilt from the bare path by default and drop all other query parameters (filter, sort, include, fields), so do not follow them. Enable `PreserveQueryInPaginationLinks` to keep the full query string with only the page parameters replaced:
+
+```csharp
+builder.Services.AddJsonApiToolkit(options => options.PreserveQueryInPaginationLinks = true);
+```
+
## Includes
`include=author,reviews` for top-level relationships, dot-separated for nesting:
diff --git a/samples/ContractApi/Program.cs b/samples/ContractApi/Program.cs
index 8e29620..515e25d 100644
--- a/samples/ContractApi/Program.cs
+++ b/samples/ContractApi/Program.cs
@@ -8,10 +8,12 @@
builder.Services.AddControllers();
builder.Services.AddJsonApiToolkit(o =>
{
- // Contract tests run one fully strict instance and one default instance.
+ // Contract tests run one default instance and one instance with every
+ // opt-in behavior enabled.
bool strict = builder.Configuration.GetValue("JSONAPI_STRICT");
o.StrictPagination = strict;
o.StrictQueryValidation = strict;
+ o.PreserveQueryInPaginationLinks = strict;
});
var app = builder.Build();