From 16b92ccea3320761d4ac8ca7145a8906861b8e33 Mon Sep 17 00:00:00 2001
From: Ben Senescu <44480372+bensenescu@users.noreply.github.com>
Date: Wed, 10 Jun 2026 23:27:04 -0400
Subject: [PATCH] Backlinks: server-side pagination, sorting, filters, and a
one-per-domain view (#258)
---
scripts/backlinks-cost-profile.ts | 29 +-
.../components/table/TablePagination.tsx | 97 +++
src/client/components/table/nullSafeSort.ts | 40 +-
.../backlinks/BacklinksFilterPanel.tsx | 583 +++++++-----------
.../backlinks/BacklinksHistorySection.tsx | 3 +
.../backlinks/BacklinksOverviewPanels.tsx | 152 +++++
.../features/backlinks/BacklinksPage.tsx | 103 +++-
.../backlinks/BacklinksPageContent.tsx | 124 ++--
.../backlinks/BacklinksPageSections.tsx | 362 ++++-------
.../features/backlinks/BacklinksTable.tsx | 113 +++-
.../backlinks/BacklinksTableColumns.tsx | 545 ++++++++--------
.../backlinks/BacklinksToolbarMenus.tsx | 14 +-
.../backlinks/ReferringDomainsTable.tsx | 112 ++--
.../features/backlinks/TopPagesTable.tsx | 46 +-
.../backlinks/backlinksFilterTypes.ts | 127 +++-
.../backlinks/backlinksFiltering.test.ts | 175 ------
.../features/backlinks/backlinksFiltering.ts | 179 ------
.../features/backlinks/backlinksPageTypes.ts | 37 +-
.../backlinks/backlinksPageUtils.test.ts | 53 --
.../features/backlinks/backlinksPageUtils.ts | 70 +--
src/client/features/backlinks/export.test.ts | 187 +++---
src/client/features/backlinks/export.ts | 69 +--
.../backlinks/useBacklinksDomainExpansion.ts | 93 +++
.../features/backlinks/useBacklinksFilters.ts | 38 +-
.../backlinks/useBacklinksPageData.ts | 214 +++++--
.../domain/components/DomainFilterPanel.tsx | 27 +-
.../_project/p/$projectId/backlinks.tsx | 21 +-
.../services/BacklinksService.billing.test.ts | 277 ++++++---
.../backlinks/services/BacklinksService.ts | 86 ++-
.../services/backlinksApiFilters.test.ts | 152 +++++
.../backlinks/services/backlinksApiFilters.ts | 179 ++++++
.../services/backlinksOverviewSchema.ts | 33 +-
.../services/backlinksServiceData.ts | 241 +++++---
.../domain/services/domainKeywordFilters.ts | 80 +--
src/server/lib/dataforseo/backlinks.test.ts | 2 +-
src/server/lib/dataforseo/backlinks.ts | 92 ++-
src/server/lib/dataforseo/envelope.ts | 7 +
src/server/lib/dataforseo/filters.ts | 89 +++
.../mcp/tools/get-backlinks-overview.ts | 26 +-
src/serverFunctions/backlinks.ts | 67 +-
src/types/schemas/backlinks.ts | 185 +++++-
41 files changed, 2973 insertions(+), 2156 deletions(-)
create mode 100644 src/client/components/table/TablePagination.tsx
create mode 100644 src/client/features/backlinks/BacklinksOverviewPanels.tsx
delete mode 100644 src/client/features/backlinks/backlinksFiltering.test.ts
delete mode 100644 src/client/features/backlinks/backlinksFiltering.ts
delete mode 100644 src/client/features/backlinks/backlinksPageUtils.test.ts
create mode 100644 src/client/features/backlinks/useBacklinksDomainExpansion.ts
create mode 100644 src/server/features/backlinks/services/backlinksApiFilters.test.ts
create mode 100644 src/server/features/backlinks/services/backlinksApiFilters.ts
create mode 100644 src/server/lib/dataforseo/filters.ts
diff --git a/scripts/backlinks-cost-profile.ts b/scripts/backlinks-cost-profile.ts
index 30722bc..8f5d415 100644
--- a/scripts/backlinks-cost-profile.ts
+++ b/scripts/backlinks-cost-profile.ts
@@ -42,22 +42,45 @@ async function main() {
const includeTabs = parseBoolean(args.includeTabs, true);
const runs = [];
+ const pageInput = {
+ ...input,
+ page: 1,
+ pageSize: 100,
+ sortOrder: "desc",
+ } as const;
+
for (let index = 0; index < repeat; index += 1) {
const overview = await service.profileOverview(input, billingCustomer);
+ const rows = includeTabs
+ ? await service.profileBacklinksPage(
+ { ...pageInput, sortField: "rank", filters: {}, mode: "as_is" },
+ billingCustomer,
+ )
+ : null;
const domains = includeTabs
- ? await service.profileReferringDomains(input, billingCustomer)
+ ? await service.profileReferringDomainsPage(
+ { ...pageInput, sortField: "backlinks", filters: {} },
+ billingCustomer,
+ )
: null;
const pages = includeTabs
- ? await service.profileTopPages(input, billingCustomer)
+ ? await service.profileTopPagesPage(
+ { ...pageInput, sortField: "backlinks", filters: {} },
+ billingCustomer,
+ )
: null;
runs.push({
run: index + 1,
overview: {
- backlinksRows: overview.overview.backlinks.length,
trendRows: overview.overview.trends.length,
newLostRows: overview.overview.newLostTrends.length,
},
+ backlinksTab: rows
+ ? {
+ rows: rows.rows.length,
+ }
+ : null,
domainsTab: domains
? {
rows: domains.rows.length,
diff --git a/src/client/components/table/TablePagination.tsx b/src/client/components/table/TablePagination.tsx
new file mode 100644
index 0000000..5b6495d
--- /dev/null
+++ b/src/client/components/table/TablePagination.tsx
@@ -0,0 +1,97 @@
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+type Props = {
+ page: number;
+ pageSize: number;
+ pageSizes: readonly number[];
+ totalCount: number | null;
+ hasNextPage: boolean;
+ isLoading: boolean;
+ onPageChange: (nextPage: number) => void;
+ onPageSizeChange: (nextPageSize: number) => void;
+};
+
+function formatRange(
+ page: number,
+ pageSize: number,
+ totalCount: number | null,
+) {
+ const start = (page - 1) * pageSize + 1;
+ if (totalCount == null) {
+ return `${start.toLocaleString()}–${(start + pageSize - 1).toLocaleString()}`;
+ }
+ if (totalCount === 0) return "0";
+ const end = Math.min(totalCount, start + pageSize - 1);
+ return `${start.toLocaleString()}–${end.toLocaleString()} of ${totalCount.toLocaleString()}`;
+}
+
+export function TablePagination({
+ page,
+ pageSize,
+ pageSizes,
+ totalCount,
+ hasNextPage,
+ isLoading,
+ onPageChange,
+ onPageSizeChange,
+}: Props) {
+ const totalPages =
+ totalCount != null ? Math.max(1, Math.ceil(totalCount / pageSize)) : null;
+ const canGoPrev = page > 1;
+ const canGoNext = totalPages != null ? page < totalPages : hasNextPage;
+
+ return (
+
+
+ {formatRange(page, pageSize, totalCount)}
+ {isLoading ? (
+
+ ) : null}
+
+
+
+
+
+
+
+ Page {page.toLocaleString()}
+ {totalPages != null ? ` of ${totalPages.toLocaleString()}` : ""}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/client/components/table/nullSafeSort.ts b/src/client/components/table/nullSafeSort.ts
index 66e8333..c169abe 100644
--- a/src/client/components/table/nullSafeSort.ts
+++ b/src/client/components/table/nullSafeSort.ts
@@ -7,20 +7,16 @@ import type { Row } from "@tanstack/react-table";
* direction from the cell context and return a value that survives the flip.
*/
-export function isDescending(
- row: Row,
- columnId: string,
-): boolean {
+function isDescending(row: Row, columnId: string): boolean {
const cell = row.getAllCells().find((c) => c.column.id === columnId);
return cell?.column.getIsSorted() === "desc";
}
/**
* Compare two nullable numeric values with nulls always at the bottom,
- * regardless of the column's current sort direction. Use this directly for
- * tiebreakers or when the value isn't the column's accessor.
+ * regardless of the column's current sort direction.
*/
-export function compareNumericNullsLast(
+function compareNumericNullsLast(
a: number | null | undefined,
b: number | null | undefined,
descending: boolean,
@@ -44,33 +40,3 @@ export function numericNullsLast(
isDescending(rowA, columnId),
);
}
-
-export function stringNullsLast(
- rowA: Row,
- rowB: Row,
- columnId: string,
-): number {
- const a = rowA.getValue(columnId);
- const b = rowB.getValue(columnId);
- if (!a && !b) return 0;
- if (!a || !b) {
- const sign = isDescending(rowA, columnId) ? -1 : 1;
- return (!a ? 1 : -1) * sign;
- }
- return a.toLowerCase().localeCompare(b.toLowerCase());
-}
-
-export function dateNullsLast(
- rowA: Row,
- rowB: Row,
- columnId: string,
-): number {
- const a = rowA.getValue(columnId);
- const b = rowB.getValue(columnId);
- if (!a && !b) return 0;
- if (!a || !b) {
- const sign = isDescending(rowA, columnId) ? -1 : 1;
- return (!a ? 1 : -1) * sign;
- }
- return Date.parse(a) - Date.parse(b);
-}
diff --git a/src/client/features/backlinks/BacklinksFilterPanel.tsx b/src/client/features/backlinks/BacklinksFilterPanel.tsx
index 6dad1e9..4baaa5a 100644
--- a/src/client/features/backlinks/BacklinksFilterPanel.tsx
+++ b/src/client/features/backlinks/BacklinksFilterPanel.tsx
@@ -1,385 +1,234 @@
-import { RotateCcw } from "lucide-react";
+import { DomainFilterPanel } from "@/client/features/domain/components/DomainFilterPanel";
import type { BacklinksTab } from "@/types/schemas/backlinks";
+import {
+ BACKLINKS_FILTER_FIELDS,
+ REFERRING_DOMAINS_FILTER_FIELDS,
+ TOP_PAGES_FILTER_FIELDS,
+ countFilterConditions,
+ type BacklinksTabFilterValues,
+} from "./backlinksFilterTypes";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type AnyForm = { Field: React.ComponentType };
-
-function FilterTextInput({
- form,
- name,
- label,
- placeholder,
-}: {
- form: AnyForm;
- name: string;
- label: string;
- placeholder: string;
-}) {
- return (
-
- );
-}
-
-function FilterRangeInputs({
- form,
- title,
- minName,
- maxName,
- step,
-}: {
- form: AnyForm;
- title: string;
- minName: string;
- maxName: string;
- step?: string;
-}) {
- return (
-
- );
-}
-
-function CompactRangeInput({
- form,
- name,
- placeholder,
- step,
-}: {
- form: AnyForm;
- name: string;
- placeholder: string;
- step?: string;
-}) {
- return (
-
- {(field: {
- state: { value: string };
- handleChange: (v: string) => void;
- }) => (
- field.handleChange(event.target.value)}
- />
- )}
-
- );
-}
-
-function BacklinksTabFilters({
- form,
- showAhrefsDrFilter,
-}: {
- form: BacklinksFiltersState["backlinks"]["form"];
- showAhrefsDrFilter: boolean;
-}) {
- return (
- <>
-
-
-
-
-
-
-
- {showAhrefsDrFilter ? (
-
- ) : null}
-
-
-
-
-
-
-
- Link Type
-
-
- {(field) => (
-
- {(["", "dofollow", "nofollow"] as const).map((value) => (
-
- ))}
-
- )}
-
-
-
-
-
- >
- );
-}
-
-function ReferringDomainsFilters({
- form,
- showAhrefsDrFilter,
-}: {
- form: BacklinksFiltersState["domains"]["form"];
- showAhrefsDrFilter: boolean;
-}) {
- return (
- <>
-
-
-
-
-
-
-
-
- {showAhrefsDrFilter ? (
-
- ) : null}
-
-
- >
- );
-}
-
-function TopPagesFilters({
- form,
-}: {
- form: BacklinksFiltersState["pages"]["form"];
-}) {
- return (
- <>
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
-
+/**
+ * Filters are applied explicitly (not per keystroke) because every change
+ * triggers a billed DataForSEO request. Each include/exclude term and each
+ * set field costs one DataForSEO filter condition, capped per request —
+ * DomainFilterPanel surfaces the count and gates Apply.
+ */
export function BacklinksFilterPanel({
activeTab,
filters,
- showAhrefsDrFilter,
- activeFilterCount,
+ onApplied,
}: {
activeTab: BacklinksTab;
filters: BacklinksFiltersState;
- showAhrefsDrFilter: boolean;
- activeFilterCount: number;
+ onApplied: () => void;
}) {
- const current = filters[activeTab];
+ if (activeTab === "backlinks") {
+ const state = filters.backlinks;
+ return (
+ {
+ state.apply(values);
+ onApplied();
+ }}
+ onClear={() => {
+ state.reset();
+ onApplied();
+ }}
+ renderExtra={(draft, setValue) => (
+
+ )}
+ />
+ );
+ }
+ if (activeTab === "domains") {
+ const state = filters.domains;
+ return (
+ {
+ state.apply(values);
+ onApplied();
+ }}
+ onClear={() => {
+ state.reset();
+ onApplied();
+ }}
+ />
+ );
+ }
+
+ const state = filters.pages;
return (
-
-
-
-
Refine results
- {activeFilterCount > 0 ? (
-
- {activeFilterCount} active
-
- ) : null}
+
{
+ state.apply(values);
+ onApplied();
+ }}
+ onClear={() => {
+ state.reset();
+ onApplied();
+ }}
+ />
+ );
+}
+
+function BacklinksToggleControls({
+ draft,
+ setValue,
+}: {
+ draft: BacklinksTabFilterValues;
+ setValue: (key: keyof BacklinksTabFilterValues, value: string) => void;
+}) {
+ return (
+
+
+
+ Link Type
+
+
+ {(["", "dofollow", "nofollow"] as const).map((value) => (
+
+ ))}
-
- {activeTab === "backlinks" ? (
-
- ) : null}
- {activeTab === "domains" ? (
-
- ) : null}
- {activeTab === "pages" ? (
-
- ) : null}
+
);
}
diff --git a/src/client/features/backlinks/BacklinksHistorySection.tsx b/src/client/features/backlinks/BacklinksHistorySection.tsx
index 28afaca..e6b678d 100644
--- a/src/client/features/backlinks/BacklinksHistorySection.tsx
+++ b/src/client/features/backlinks/BacklinksHistorySection.tsx
@@ -55,6 +55,9 @@ export function BacklinksHistorySection({
target: item.target,
scope: item.scope,
tab: undefined,
+ page: undefined,
+ sort: undefined,
+ order: undefined,
})}
replace
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"
diff --git a/src/client/features/backlinks/BacklinksOverviewPanels.tsx b/src/client/features/backlinks/BacklinksOverviewPanels.tsx
new file mode 100644
index 0000000..95fb917
--- /dev/null
+++ b/src/client/features/backlinks/BacklinksOverviewPanels.tsx
@@ -0,0 +1,152 @@
+import { Link } from "@tanstack/react-router";
+import { ArrowLeft } from "lucide-react";
+import { HeaderHelpLabel } from "@/client/features/keywords/components";
+import {
+ BacklinksNewLostChart,
+ BacklinksTrendChart,
+} from "./BacklinksPageCharts";
+import type { BacklinksOverviewData } from "./backlinksPageTypes";
+import { formatRelativeTimestamp } from "./backlinksPageUtils";
+
+type SummaryStat = { label: string; value: string; description: string };
+
+export function BacklinksOverviewPanels({
+ projectId,
+ data,
+ summaryStats,
+}: {
+ projectId: string;
+ data: BacklinksOverviewData;
+ summaryStats: SummaryStat[];
+}) {
+ return (
+ <>
+
+
+
+ Recent searches
+
+
+
+ {data.scope}
+ Target: {data.displayTarget}
+ -
+ Updated {formatRelativeTimestamp(data.fetchedAt)}
+
+
+ {data.scope === "page" ? (
+
+
+ Showing backlinks for this exact page. Enter a bare domain for
+ site-wide results. Trend charts are only shown for domain-level
+ lookups.
+
+
+ ) : null}
+ >
+ );
+}
+
+function OverviewGrid({
+ data,
+ summaryStats,
+}: {
+ data: BacklinksOverviewData;
+ summaryStats: SummaryStat[];
+}) {
+ const domainScope = data.scope === "domain";
+
+ return (
+
+
+ {domainScope ? : null}
+
+ );
+}
+
+function SummaryStatsGrid({
+ data,
+ summaryStats,
+}: {
+ data: BacklinksOverviewData;
+ summaryStats: SummaryStat[];
+}) {
+ const cardClassName = `card bg-base-100 border border-base-300 ${data.scope === "domain" ? "md:col-span-2 xl:col-span-1" : ""}`;
+
+ return (
+
+
+
+ {summaryStats.map((item) => (
+
+ ))}
+
+
+
+ );
+}
+
+function TrendPanels({ data }: { data: BacklinksOverviewData }) {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+function TrendCard({
+ children,
+ description,
+ title,
+}: {
+ children: React.ReactNode;
+ description: string;
+ title: string;
+}) {
+ return (
+
+
+
+
{title}
+
{description}
+
+ {children}
+
+
+ );
+}
diff --git a/src/client/features/backlinks/BacklinksPage.tsx b/src/client/features/backlinks/BacklinksPage.tsx
index c3a2fb0..f0c50b3 100644
--- a/src/client/features/backlinks/BacklinksPage.tsx
+++ b/src/client/features/backlinks/BacklinksPage.tsx
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
+import type { SortingState, Updater } from "@tanstack/react-table";
import { BacklinksSearchCard } from "./BacklinksSearchCard";
import { BacklinksBody } from "./BacklinksPageContent";
import type { BacklinksPageProps } from "./backlinksPageTypes";
@@ -7,6 +8,7 @@ import {
navigateToBacklinksSearch,
useBacklinksPageData,
} from "./useBacklinksPageData";
+import { useBacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
import { useBacklinksFilters } from "./useBacklinksFilters";
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
import type {
@@ -14,6 +16,10 @@ import type {
SearchTabInput,
} from "@/client/features/search-tabs/types";
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
+import {
+ BACKLINKS_DEFAULT_SORT,
+ DEFAULT_BACKLINKS_PAGE_SIZE,
+} from "@/types/schemas/backlinks";
export function BacklinksPage({
projectId,
@@ -21,18 +27,94 @@ export function BacklinksPage({
navigate,
}: BacklinksPageProps) {
const filters = useBacklinksFilters();
+
+ // Sort lives in the URL so sort changes and the page reset commit in one
+ // navigation (no transient fetch of the old page with the new sort).
+ const sorting = useMemo(() => {
+ const fallback = BACKLINKS_DEFAULT_SORT[searchState.tab];
+ const field = searchState.sort ?? fallback.field;
+ const order =
+ searchState.order ?? (searchState.sort ? "desc" : fallback.order);
+ return [{ id: field, desc: order === "desc" }];
+ }, [searchState.order, searchState.sort, searchState.tab]);
+
+ const handleSortingChange = useCallback(
+ (updater: Updater) => {
+ const next = typeof updater === "function" ? updater(sorting) : updater;
+ const first = next[0];
+ navigate({
+ search: (prev) => ({
+ ...prev,
+ sort: first?.id,
+ order: first ? (first.desc ? "desc" : "asc") : undefined,
+ page: undefined,
+ }),
+ replace: true,
+ });
+ },
+ [navigate, sorting],
+ );
+
+ const handlePageChange = useCallback(
+ (nextPage: number) => {
+ navigate({
+ search: (prev) => ({
+ ...prev,
+ page: nextPage === 1 ? undefined : nextPage,
+ }),
+ replace: true,
+ });
+ },
+ [navigate],
+ );
+
+ const handlePageSizeChange = useCallback(
+ (nextPageSize: number) => {
+ navigate({
+ search: (prev) => ({
+ ...prev,
+ size:
+ nextPageSize === DEFAULT_BACKLINKS_PAGE_SIZE
+ ? undefined
+ : nextPageSize,
+ page: undefined,
+ }),
+ replace: true,
+ });
+ },
+ [navigate],
+ );
+
+ const handleViewChange = useCallback(
+ (nextView: "all" | undefined) => {
+ navigate({
+ search: (prev) => ({ ...prev, view: nextView, page: undefined }),
+ replace: true,
+ });
+ },
+ [navigate],
+ );
+
+ const domainExpansion = useBacklinksDomainExpansion({
+ projectId,
+ searchState,
+ });
+
const {
accessGate,
activeTabErrorMessage,
+ activeTabQuery,
backlinksDisabledByError,
overviewErrorMessage,
overviewQuery,
referringDomainsQuery,
+ rowsQuery,
searchCardInitialValues,
topPagesQuery,
} = useBacklinksPageData({
projectId,
searchState,
+ filters,
});
const {
@@ -71,6 +153,9 @@ export function BacklinksPage({
search: (prev) => ({
...prev,
tab: tab === "backlinks" ? undefined : tab,
+ page: undefined,
+ sort: undefined,
+ order: undefined,
}),
replace: true,
});
@@ -134,19 +219,23 @@ export function BacklinksPage({
overviewData={overviewQuery.data}
overviewError={overviewErrorMessage}
overviewLoading={overviewQuery.isLoading}
- referringDomains={referringDomainsQuery.data}
+ backlinksRowsPage={rowsQuery.data}
+ referringDomainsPage={referringDomainsQuery.data}
+ topPagesPage={topPagesQuery.data}
searchState={searchState}
filters={filters}
+ sorting={sorting}
+ domainExpansion={domainExpansion}
tabErrorMessage={activeTabErrorMessage}
- tabLoading={
- (searchState.tab === "domains" &&
- referringDomainsQuery.isLoading) ||
- (searchState.tab === "pages" && topPagesQuery.isLoading)
- }
- topPages={topPagesQuery.data}
+ tabLoading={activeTabQuery.isLoading}
+ tabFetching={activeTabQuery.isFetching}
+ onPageChange={handlePageChange}
+ onPageSizeChange={handlePageSizeChange}
onRemoveHistoryItem={removeHistoryItem}
onRetryOverview={() => void overviewQuery.refetch()}
+ onSortingChange={handleSortingChange}
onTabChange={handleResultTabChange}
+ onViewChange={handleViewChange}
searchTabs={
searchState.target
? {
diff --git a/src/client/features/backlinks/BacklinksPageContent.tsx b/src/client/features/backlinks/BacklinksPageContent.tsx
index d1604e8..63267d5 100644
--- a/src/client/features/backlinks/BacklinksPageContent.tsx
+++ b/src/client/features/backlinks/BacklinksPageContent.tsx
@@ -1,8 +1,7 @@
import { useMemo } from "react";
-import {
- BacklinksOverviewPanels,
- BacklinksResultsCard,
-} from "./BacklinksPageSections";
+import type { OnChangeFn, SortingState } from "@tanstack/react-table";
+import { BacklinksOverviewPanels } from "./BacklinksOverviewPanels";
+import { BacklinksResultsCard } from "./BacklinksPageSections";
import {
BacklinksErrorState,
BacklinksLoadingState,
@@ -13,17 +12,15 @@ import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSear
import type {
BacklinksOverviewData,
BacklinksReferringDomainsData,
+ BacklinksRowsPageData,
BacklinksSearchState,
+ BacklinksTabRows,
BacklinksTopPagesData,
} from "./backlinksPageTypes";
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate";
import { buildSummaryStats } from "./backlinksPageUtils";
-import {
- filterBacklinkRows,
- filterReferringDomainRows,
- filterTopPageRows,
-} from "./backlinksFiltering";
+import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
import {
SearchTabStrip,
@@ -39,15 +36,23 @@ type BacklinksBodyProps = {
overviewData: BacklinksOverviewData | undefined;
overviewError: string | null;
overviewLoading: boolean;
- referringDomains: BacklinksReferringDomainsData | undefined;
+ backlinksRowsPage: BacklinksRowsPageData | undefined;
+ referringDomainsPage: BacklinksReferringDomainsData | undefined;
+ topPagesPage: BacklinksTopPagesData | undefined;
searchState: BacklinksSearchState;
filters: BacklinksFiltersState;
+ sorting: SortingState;
+ domainExpansion: BacklinksDomainExpansion;
tabErrorMessage: string | null;
tabLoading: boolean;
- topPages: BacklinksTopPagesData | undefined;
+ tabFetching: boolean;
+ onPageChange: (nextPage: number) => void;
+ onPageSizeChange: (nextPageSize: number) => void;
onRemoveHistoryItem: (timestamp: number) => void;
onRetryOverview: () => void;
+ onSortingChange: OnChangeFn;
onTabChange: (tab: BacklinksSearchState["tab"]) => void;
+ onViewChange: (view: "all" | undefined) => void;
searchTabs: {
activeTabId: string | null;
tabs: SearchTab[];
@@ -66,45 +71,42 @@ export function BacklinksBody({
overviewData,
overviewError,
overviewLoading,
- referringDomains,
+ backlinksRowsPage,
+ referringDomainsPage,
+ topPagesPage,
searchState,
filters,
+ sorting,
+ domainExpansion,
tabErrorMessage,
tabLoading,
- topPages,
+ tabFetching,
+ onPageChange,
+ onPageSizeChange,
onRemoveHistoryItem,
onRetryOverview,
+ onSortingChange,
onTabChange,
+ onViewChange,
searchTabs,
}: BacklinksBodyProps) {
- const mergedData = useMemo(
- () => mergeTabData(overviewData, referringDomains, topPages),
- [overviewData, referringDomains, topPages],
+ const tabRows = useMemo(
+ () => ({
+ backlinks: backlinksRowsPage?.rows ?? [],
+ referringDomains: referringDomainsPage?.rows ?? [],
+ topPages: topPagesPage?.rows ?? [],
+ }),
+ [backlinksRowsPage, referringDomainsPage, topPagesPage],
);
- const filteredData = useMemo(() => {
- if (!mergedData) {
- return { backlinks: [], referringDomains: [], topPages: [] };
- }
- return {
- backlinks: filterBacklinkRows(
- mergedData.backlinks,
- filters.backlinks.values,
- ),
- referringDomains: filterReferringDomainRows(
- mergedData.referringDomains,
- filters.domains.values,
- ),
- topPages: filterTopPageRows(mergedData.topPages, filters.pages.values),
- };
- }, [
- mergedData,
- filters.backlinks.values,
- filters.domains.values,
- filters.pages.values,
- ]);
+ const activeTabPage =
+ searchState.tab === "backlinks"
+ ? backlinksRowsPage
+ : searchState.tab === "domains"
+ ? referringDomainsPage
+ : topPagesPage;
const summaryStats = useMemo(
- () => buildSummaryStats(mergedData),
- [mergedData],
+ () => buildSummaryStats(overviewData),
+ [overviewData],
);
const tabStrip = searchTabs ? (
{tabStrip}
@@ -177,37 +179,33 @@ export function BacklinksBody({
{tabStrip}
>
);
}
-
-function mergeTabData(
- data: BacklinksOverviewData | undefined,
- referringDomains: BacklinksReferringDomainsData | undefined,
- topPages: BacklinksTopPagesData | undefined,
-) {
- if (!data) {
- return undefined;
- }
-
- return {
- ...data,
- referringDomains: referringDomains ?? data.referringDomains,
- topPages: topPages ?? data.topPages,
- };
-}
diff --git a/src/client/features/backlinks/BacklinksPageSections.tsx b/src/client/features/backlinks/BacklinksPageSections.tsx
index 110f608..e72e8f6 100644
--- a/src/client/features/backlinks/BacklinksPageSections.tsx
+++ b/src/client/features/backlinks/BacklinksPageSections.tsx
@@ -1,34 +1,28 @@
import { useEffect, useMemo } from "react";
-import { Link } from "@tanstack/react-router";
-import { HeaderHelpLabel } from "@/client/features/keywords/components";
-import { ArrowLeft, SlidersHorizontal } from "lucide-react";
-import {
- BacklinksNewLostChart,
- BacklinksTrendChart,
-} from "./BacklinksPageCharts";
+import { SlidersHorizontal } from "lucide-react";
+import type { OnChangeFn, SortingState } from "@tanstack/react-table";
import { BacklinksFilterPanel } from "./BacklinksFilterPanel";
import { BacklinksTable } from "./BacklinksTable";
import { ReferringDomainsTable } from "./ReferringDomainsTable";
import { TopPagesTable } from "./TopPagesTable";
import type {
- BacklinksOverviewData,
BacklinksSearchState,
+ BacklinksTabRows,
} from "./backlinksPageTypes";
-import {
- TAB_DESCRIPTIONS,
- formatRelativeTimestamp,
-} from "./backlinksPageUtils";
+import { TAB_DESCRIPTIONS } from "./backlinksPageUtils";
import {
BacklinksActionsMenu,
BacklinksExportMenu,
} from "./BacklinksToolbarMenus";
import { buildBacklinksTabExport } from "./export";
-import {
- filterBacklinkRows,
- filterReferringDomainRows,
-} from "./backlinksFiltering";
+import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
import { useAhrefsDomainRatings } from "./useAhrefsDomainRatings";
+import { TablePagination } from "@/client/components/table/TablePagination";
+import {
+ BACKLINKS_PAGE_SIZES,
+ type BacklinksTab,
+} from "@/types/schemas/backlinks";
const BACKLINKS_RESULTS_TABS: Array<{
tab: BacklinksSearchState["tab"];
@@ -39,118 +33,66 @@ const BACKLINKS_RESULTS_TABS: Array<{
{ tab: "pages", label: "Top Pages" },
];
-export function BacklinksOverviewPanels({
- projectId,
- data,
- summaryStats,
-}: {
- projectId: string;
- data: BacklinksOverviewData;
- summaryStats: Array<{ label: string; value: string; description: string }>;
-}) {
- return (
- <>
-
-
-
- Recent searches
-
-
-
- {data.scope}
- Target: {data.displayTarget}
- -
- Updated {formatRelativeTimestamp(data.fetchedAt)}
-
-
- {data.scope === "page" ? (
-
-
- Showing backlinks for this exact page. Enter a bare domain for
- site-wide results. Trend charts are only shown for domain-level
- lookups.
-
-
- ) : null}
- >
- );
-}
-
export function BacklinksResultsCard({
projectId,
activeTab,
- filteredData,
+ tabRows,
filters,
+ sorting,
+ view,
+ domainExpansion,
isTabLoading,
tabErrorMessage,
exportTarget,
+ pagination,
+ onPageChange,
+ onPageSizeChange,
+ onSortingChange,
onTabChange,
+ onViewChange,
}: {
projectId: string;
activeTab: BacklinksSearchState["tab"];
- filteredData: {
- backlinks: BacklinksOverviewData["backlinks"];
- referringDomains: BacklinksOverviewData["referringDomains"];
- topPages: BacklinksOverviewData["topPages"];
- };
+ tabRows: BacklinksTabRows;
filters: BacklinksFiltersState;
+ sorting: SortingState;
+ view: "all" | undefined;
+ domainExpansion: BacklinksDomainExpansion;
isTabLoading: boolean;
tabErrorMessage: string | null;
exportTarget: string;
+ pagination: {
+ page: number;
+ pageSize: number;
+ totalCount: number | null;
+ hasNextPage: boolean;
+ isFetching: boolean;
+ };
+ onPageChange: (nextPage: number) => void;
+ onPageSizeChange: (nextPageSize: number) => void;
+ onSortingChange: OnChangeFn;
onTabChange: (tab: BacklinksSearchState["tab"]) => void;
+ onViewChange: (view: "all" | undefined) => void;
}) {
const {
ratings: domainRatings,
isLoading: isLoadingRatings,
loadRatings,
} = useAhrefsDomainRatings(projectId);
- const showAhrefsDrFilter = domainRatings !== null && activeTab !== "pages";
- const currentFilterCount = countVisibleFilters(
- filters[activeTab].values,
- showAhrefsDrFilter,
- );
- const visibleFilteredData = useMemo(
- () => ({
- backlinks: filterBacklinkRows(
- filteredData.backlinks,
- filters.backlinks.values,
- domainRatings,
- ),
- referringDomains: filterReferringDomainRows(
- filteredData.referringDomains,
- filters.domains.values,
- domainRatings,
- ),
- topPages: filteredData.topPages,
- }),
- [
- domainRatings,
- filteredData.backlinks,
- filteredData.referringDomains,
- filteredData.topPages,
- filters.backlinks.values,
- filters.domains.values,
- ],
- );
+ const activeFilterCount = filters[activeTab].activeFilterCount;
const exportTable = useMemo(
() =>
- buildBacklinksTabExport({ tab: activeTab, rows: visibleFilteredData }),
- [activeTab, visibleFilteredData],
+ buildBacklinksTabExport({ tab: activeTab, rows: tabRows, domainRatings }),
+ [activeTab, domainRatings, tabRows],
);
- // Domains keyed by both tables that the DR column can enrich. The Referring
- // Domains list loads lazily, so this grows once that tab is opened.
+ // Domains keyed by both tables that the DR column can enrich. Each table
+ // holds the currently loaded page, so this changes as the user paginates.
const ratableDomains = useMemo(
- () => collectRatableDomains(visibleFilteredData),
- [visibleFilteredData],
+ () => collectRatableDomains(tabRows),
+ [tabRows],
);
// Once the user has opted in, keep newly loaded domains enriched without a
- // re-click (e.g. after switching to the lazily-loaded Referring Domains tab).
+ // re-click (e.g. after paging or switching to the Referring Domains tab).
// KV-cached, so re-requesting already-known domains is nearly free.
useEffect(() => {
if (!domainRatings) return;
@@ -184,7 +126,6 @@ export function BacklinksResultsCard({
@@ -198,7 +139,7 @@ export function BacklinksResultsCard({
-
+
+ {activeTab === "backlinks" ? (
+
+
+
+
+ ) : null}
{filters.showFilters ? (
onPageChange(1)}
/>
) : null}
@@ -229,154 +197,72 @@ export function BacklinksResultsCard({
{tabErrorMessage}
) : null}
- {activeTab === "backlinks" ? (
-
+ {isTabLoading && !tabErrorMessage ? (
+
) : null}
- {activeTab === "domains" && isTabLoading && !tabErrorMessage ? (
-
- ) : null}
- {activeTab === "domains" && !isTabLoading && !tabErrorMessage ? (
-
- ) : null}
- {activeTab === "pages" && isTabLoading && !tabErrorMessage ? (
-
- ) : null}
- {activeTab === "pages" && !isTabLoading && !tabErrorMessage ? (
-
+ {!isTabLoading && !tabErrorMessage ? (
+ <>
+ {activeTab === "backlinks" ? (
+
+ ) : null}
+ {activeTab === "domains" ? (
+
+ ) : null}
+ {activeTab === "pages" ? (
+
+ ) : null}
+ >
) : null}
+
+ {/* Kept visible on tab errors so a failing page still offers a way back. */}
+
);
}
+const TAB_LOADING_LABELS: Record = {
+ backlinks: "Loading backlinks",
+ domains: "Loading referring domains",
+ pages: "Loading top pages",
+};
+
/** Unique domains the DR column keys on, from both the backlinks and referring
* domains tables, normalized to match how each table renders its domain. */
-function collectRatableDomains(filteredData: {
- backlinks: BacklinksOverviewData["backlinks"];
- referringDomains: BacklinksOverviewData["referringDomains"];
-}): string[] {
+function collectRatableDomains(tabRows: BacklinksTabRows): string[] {
const domains = [
- ...filteredData.backlinks.map((row) =>
- row.domainFrom?.replace(/^www\./, ""),
- ),
- ...filteredData.referringDomains.map((row) => row.domain),
+ ...tabRows.backlinks.map((row) => row.domainFrom?.replace(/^www\./, "")),
+ ...tabRows.referringDomains.map((row) => row.domain),
];
return [
...new Set(domains.filter((domain): domain is string => Boolean(domain))),
];
}
-function countVisibleFilters(
- values: Record,
- showAhrefsDrFilter: boolean,
-) {
- return Object.entries(values).filter(([key, value]) => {
- if (
- !showAhrefsDrFilter &&
- (key === "minAhrefsDr" || key === "maxAhrefsDr")
- ) {
- return false;
- }
- return value.trim() !== "";
- }).length;
-}
-
-function OverviewGrid({
- data,
- summaryStats,
-}: {
- data: BacklinksOverviewData;
- summaryStats: Array<{ label: string; value: string; description: string }>;
-}) {
- const domainScope = data.scope === "domain";
-
- return (
-
-
- {domainScope ? : null}
-
- );
-}
-
-function SummaryStatsGrid({
- data,
- summaryStats,
-}: {
- data: BacklinksOverviewData;
- summaryStats: Array<{ label: string; value: string; description: string }>;
-}) {
- const cardClassName = `card bg-base-100 border border-base-300 ${data.scope === "domain" ? "md:col-span-2 xl:col-span-1" : ""}`;
-
- return (
-
-
-
- {summaryStats.map((item) => (
-
- ))}
-
-
-
- );
-}
-
-function TrendPanels({ data }: { data: BacklinksOverviewData }) {
- return (
- <>
-
-
-
-
-
-
- >
- );
-}
-
-function TrendCard({
- children,
- description,
- title,
-}: {
- children: React.ReactNode;
- description: string;
- title: string;
-}) {
- return (
-
-
-
-
{title}
-
{description}
-
- {children}
-
-
- );
-}
-
function TabLink({
activeTab,
label,
diff --git a/src/client/features/backlinks/BacklinksTable.tsx b/src/client/features/backlinks/BacklinksTable.tsx
index e2a7f27..43663e6 100644
--- a/src/client/features/backlinks/BacklinksTable.tsx
+++ b/src/client/features/backlinks/BacklinksTable.tsx
@@ -1,37 +1,108 @@
import { useMemo } from "react";
+import type { OnChangeFn, SortingState } from "@tanstack/react-table";
import {
AppDataTable,
useAppTable,
} from "@/client/components/table/AppDataTable";
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
-import { buildBacklinksColumns } from "./BacklinksTableColumns";
-import type { BacklinksOverviewData } from "./backlinksPageTypes";
-import { groupBacklinksByDomain } from "./backlinksPageUtils";
+import {
+ buildBacklinksColumns,
+ type BacklinksDisplayRow,
+} from "./BacklinksTableColumns";
+import type { BacklinksRow } from "./backlinksPageTypes";
+import type { BacklinksDomainExpansion } from "./useBacklinksDomainExpansion";
import type { DomainRatings } from "./useAhrefsDomainRatings";
+/** Interleaves expanded domains' extra links beneath their page row. */
+function buildDisplayRows(
+ rows: BacklinksRow[],
+ expansion: BacklinksDomainExpansion | null,
+): BacklinksDisplayRow[] {
+ if (!expansion) {
+ return rows.map((row) => ({
+ kind: "link",
+ row,
+ depth: 0,
+ expandable: false,
+ expanded: false,
+ }));
+ }
+
+ const out: BacklinksDisplayRow[] = [];
+ for (const row of rows) {
+ const domain = row.domainFrom;
+ const expanded = Boolean(domain && expansion.expandedDomains.has(domain));
+ out.push({
+ kind: "link",
+ row,
+ depth: 0,
+ expandable: Boolean(domain),
+ expanded,
+ });
+ if (!expanded || !domain) continue;
+
+ const entry = expansion.entriesByDomain[domain];
+ if (!entry || entry.status === "loading") {
+ out.push({ kind: "status", domain, status: "loading" });
+ } else if (entry.status === "error") {
+ out.push({ kind: "status", domain, status: "error" });
+ } else {
+ // The page row already shows the domain's strongest link; list the rest.
+ const children = entry.rows.filter(
+ (child) =>
+ !(
+ child.urlFrom === row.urlFrom &&
+ child.urlTo === row.urlTo &&
+ child.anchor === row.anchor
+ ),
+ );
+ if (children.length === 0) {
+ out.push({ kind: "status", domain, status: "empty" });
+ } else {
+ for (const child of children) {
+ out.push({
+ kind: "link",
+ row: child,
+ depth: 1,
+ expandable: false,
+ expanded: false,
+ });
+ }
+ }
+ }
+ }
+ return out;
+}
+
export function BacklinksTable({
rows,
domainRatings,
+ sorting,
+ onSortingChange,
+ expansion,
}: {
- rows: BacklinksOverviewData["backlinks"];
+ rows: BacklinksRow[];
domainRatings: DomainRatings | null;
+ sorting: SortingState;
+ onSortingChange: OnChangeFn;
+ /** Present in the one-per-domain view; null when listing all links. */
+ expansion: BacklinksDomainExpansion | null;
}) {
- const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]);
const columns = useMemo(
- () => buildBacklinksColumns(domainRatings),
- [domainRatings],
+ () => buildBacklinksColumns(domainRatings, expansion?.toggleDomain),
+ [domainRatings, expansion?.toggleDomain],
+ );
+ const displayRows = useMemo(
+ () => buildDisplayRows(rows, expansion),
+ [rows, expansion],
);
const table = useAppTable({
- data: groupedData,
+ data: displayRows,
columns,
- initialState: {
- sorting: [{ id: "firstSeen", desc: true }],
- },
- getSubRows: (row) => row.subRows,
- withSorting: true,
- withExpanded: true,
- getRowCanExpand: (row) => row.depth === 0,
+ state: { sorting },
+ onSortingChange,
+ manualSorting: true,
});
if (rows.length === 0) {
@@ -42,13 +113,11 @@ export function BacklinksTable({
({
- className:
- row.depth === 0
- ? "cursor-pointer bg-base-200/50 transition-colors hover:bg-base-200/80"
- : "bg-base-100",
- onClick: row.depth === 0 ? row.getToggleExpandedHandler() : undefined,
- })}
+ getRowClassName={(row) =>
+ row.original.kind !== "link" || row.original.depth > 0
+ ? "bg-base-200/30"
+ : undefined
+ }
/>
);
}
diff --git a/src/client/features/backlinks/BacklinksTableColumns.tsx b/src/client/features/backlinks/BacklinksTableColumns.tsx
index e615508..ac1df4d 100644
--- a/src/client/features/backlinks/BacklinksTableColumns.tsx
+++ b/src/client/features/backlinks/BacklinksTableColumns.tsx
@@ -1,14 +1,10 @@
import type { ColumnDef } from "@tanstack/react-table";
import { ChevronRight } from "lucide-react";
import { SortableHeader } from "@/client/components/table/SortableHeader";
-import {
- dateNullsLast,
- numericNullsLast,
- stringNullsLast,
-} from "@/client/components/table/nullSafeSort";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { BacklinksSourceLink } from "./BacklinksPageLinks";
-import type { BacklinksRow, GroupedBacklinkDomain } from "./backlinksPageTypes";
+import type { BacklinksRow } from "./backlinksPageTypes";
+import type { BacklinksRowsSortField } from "@/types/schemas/backlinks";
import {
formatCompactDate,
formatDecimal,
@@ -16,6 +12,21 @@ import {
} from "./backlinksPageUtils";
import type { DomainRatings } from "./useAhrefsDomainRatings";
+/**
+ * Row model for the backlinks table. In the one-per-domain view, depth-0 rows
+ * are each domain's strongest link and can expand into the domain's remaining
+ * links (depth-1) plus a transient status row while they load.
+ */
+export type BacklinksDisplayRow =
+ | {
+ kind: "link";
+ row: BacklinksRow;
+ depth: 0 | 1;
+ expandable: boolean;
+ expanded: boolean;
+ }
+ | { kind: "status"; domain: string; status: "loading" | "error" | "empty" };
+
function BacklinkFlags({ row }: { row: BacklinksRow }) {
return (
@@ -39,332 +50,286 @@ function BacklinkFlags({ row }: { row: BacklinksRow }) {
);
}
-function DomainFlagBadges({ group }: { group: GroupedBacklinkDomain }) {
- const badges: Array<{ label: string; className: string }> = [];
- if (group.lostCount > 0) {
- badges.push({
- label: `${group.lostCount} Lost`,
- className:
- "badge badge-sm badge-error badge-outline min-w-fit whitespace-nowrap",
- });
+function StatusCell({ status }: { status: "loading" | "error" | "empty" }) {
+ if (status === "loading") {
+ return (
+
+
+ Loading links…
+
+ );
}
- if (group.brokenCount > 0) {
- badges.push({
- label: `${group.brokenCount} Broken`,
- className:
- "badge badge-sm badge-warning badge-outline min-w-fit whitespace-nowrap",
- });
- }
- if (group.nofollowCount > 0) {
- badges.push({
- label: `${group.nofollowCount} Nofollow`,
- className: "badge badge-sm badge-outline min-w-fit whitespace-nowrap",
- });
- }
- if (badges.length === 0) return null;
-
return (
-
- {badges.map((badge) => (
-
- {badge.label}
-
- ))}
+
+ {status === "error"
+ ? "Couldn't load this domain's links."
+ : "No other links from this domain."}
+
+ );
+}
+
+function SourceCell({
+ displayRow,
+ onToggleDomain,
+}: {
+ displayRow: BacklinksDisplayRow;
+ onToggleDomain?: (domain: string) => void;
+}) {
+ if (displayRow.kind === "status") {
+ return
;
+ }
+
+ const { row, depth, expandable, expanded } = displayRow;
+ if (depth > 0) {
+ return (
+
+ {row.urlFrom ? (
+
+ ) : (
+ -
+ )}
+
+ );
+ }
+
+ const domainLabel = row.domainFrom?.replace(/^www\./, "") ?? "-";
+ return (
+
+ {expandable && row.domainFrom && onToggleDomain ? (
+
+ ) : null}
+
+
{domainLabel}
+ {row.urlFrom ? (
+
+ ) : null}
+
);
}
-const baseBacklinksColumns: ColumnDef
[] = [
- {
- id: "source",
- accessorKey: "domain",
- header: ({ column }) => (
-
- ),
- size: 250,
- minSize: 180,
- cell: ({ row }) => {
- if (row.depth > 0) {
- const child = row.original._backlink;
- return (
-
- {child?.urlFrom ? (
-
- ) : (
- -
- )}
-
- );
- }
+/** Renders nothing for status rows, the link cell otherwise. */
+function linkCell(
+ render: (row: BacklinksRow) => React.ReactNode,
+): (ctx: { row: { original: BacklinksDisplayRow } }) => React.ReactNode {
+ return ({ row }) =>
+ row.original.kind === "link" ? render(row.original.row) : null;
+}
- const group = row.original;
- return (
-
-
-
-
{group.domain}
-
- {group.backlinkCount}{" "}
- {group.backlinkCount === 1 ? "backlink" : "backlinks"} ·{" "}
- {group.targetCount} {group.targetCount === 1 ? "page" : "pages"}
-
-
+function buildBaseColumns(
+ onToggleDomain?: (domain: string) => void,
+): ColumnDef
[] {
+ // Sortable column ids ("rank", "domainRank", "spamScore", "firstSeen") map
+ // to server-side sort fields — sorting re-queries DataForSEO across the
+ // full backlink profile, not just the loaded page.
+ return [
+ {
+ id: "source",
+ enableSorting: false,
+ header: () => (
+
+ ),
+ size: 250,
+ minSize: 180,
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ id: "target",
+ enableSorting: false,
+ header: () => (
+
+ ),
+ size: 220,
+ minSize: 150,
+ cell: linkCell((row) => (
+
+ {row.urlTo ? (
+
+ ) : (
+ "-"
+ )}
- );
+ )),
},
- sortingFn: stringNullsLast,
- },
- {
- id: "target",
- header: () => (
-
- ),
- size: 220,
- minSize: 150,
- enableSorting: false,
- cell: ({ row }) => {
- if (row.depth > 0) {
- const child = row.original._backlink;
- return (
-
- {child?.urlTo ? (
-
- ) : (
- "-"
- )}
-
- );
- }
-
- return null;
+ {
+ id: "anchor",
+ enableSorting: false,
+ header: () => (
+
+ ),
+ size: 150,
+ minSize: 100,
+ cell: linkCell((row) => (
+
+
{row.anchor || "No anchor text"}
+ {row.itemType ? (
+
{row.itemType}
+ ) : null}
+
+ )),
},
- },
- {
- id: "anchor",
- header: () => (
-
- ),
- size: 150,
- minSize: 100,
- enableSorting: false,
- cell: ({ row }) => {
- if (row.depth > 0) {
- const child = row.original._backlink;
- return (
-
-
{child?.anchor || "No anchor text"}
- {child?.itemType ? (
-
- {child.itemType}
-
- ) : null}
-
- );
- }
-
- return null;
- },
- },
- {
- id: "flags",
- header: () => (
-
- ),
- size: 130,
- minSize: 80,
- enableSorting: false,
- cell: ({ row }) => {
- if (row.depth > 0) {
- const child = row.original._backlink;
- const hasFlags =
- child?.isLost ||
- child?.isBroken ||
- child?.isDofollow === false ||
- (child?.linksCount != null && child.linksCount > 1);
- return hasFlags && child ? : null;
- }
-
- return ;
- },
- },
- {
- id: "linkAuthority",
- header: () => (
-
+ {
+ id: "flags",
+ enableSorting: false,
+ header: () => (
+ ),
+ size: 130,
+ minSize: 80,
+ cell: linkCell((row) => ),
+ },
+ {
+ id: "rank" satisfies BacklinksRowsSortField,
+ accessorFn: (displayRow) =>
+ displayRow.kind === "link" ? displayRow.row.rank : null,
+ header: ({ column }) => (
+
-
- ),
- size: 70,
- minSize: 50,
- enableSorting: false,
- cell: ({ row }) => {
- if (row.depth > 0) {
- const child = row.original._backlink;
+ ),
+ size: 70,
+ minSize: 50,
+ sortDescFirst: true,
+ cell: linkCell((row) => (
+
+ {formatNumber(row.rank)}
+
+ )),
+ },
+ {
+ id: "domainRank" satisfies BacklinksRowsSortField,
+ accessorFn: (displayRow) =>
+ displayRow.kind === "link" ? displayRow.row.domainFromRank : null,
+ header: ({ column }) => (
+
+ ),
+ size: 70,
+ minSize: 50,
+ sortDescFirst: true,
+ cell: linkCell((row) => (
+
+ {formatNumber(row.domainFromRank)}
+
+ )),
+ },
+ {
+ id: "spamScore" satisfies BacklinksRowsSortField,
+ accessorFn: (displayRow) =>
+ displayRow.kind === "link" ? displayRow.row.spamScore : null,
+ header: ({ column }) => (
+
+ ),
+ size: 70,
+ minSize: 50,
+ sortDescFirst: true,
+ cell: linkCell((row) => {
+ const value = row.spamScore;
return (
-
- {formatNumber(child?.rank)}
-
+ {value != null && value > 0 ? Math.round(value) : null}
);
- }
-
- return null;
+ }),
},
- },
- {
- id: "domainAuthority",
- accessorKey: "domainAuthority",
- header: ({ column }) => (
-
- ),
- size: 70,
- minSize: 50,
- cell: ({ row }) => {
- if (row.depth > 0) return null;
-
- return (
-
- {formatNumber(row.original.domainAuthority)}
-
- );
- },
- sortingFn: numericNullsLast,
- sortDescFirst: true,
- },
- {
- id: "spamScore",
- accessorKey: "spamScore",
- header: ({ column }) => (
-
- ),
- size: 70,
- minSize: 50,
- cell: ({ row }) => {
- const value =
- row.depth > 0
- ? row.original._backlink?.spamScore
- : row.original.spamScore;
-
- return (
-
- {value != null && value > 0 ? Math.round(value) : null}
-
- );
- },
- sortingFn: numericNullsLast,
- sortDescFirst: true,
- },
- {
- id: "firstSeen",
- accessorKey: "firstSeen",
- header: ({ column }) => (
-
- ),
- size: 110,
- minSize: 80,
- cell: ({ row }) => {
- if (row.depth > 0) {
- const child = row.original._backlink;
- return (
-
-
{formatCompactDate(child?.firstSeen)}
- {child?.lastSeen ? (
-
- Last {formatCompactDate(child.lastSeen)}
-
- ) : null}
-
- );
- }
-
- return (
+ {
+ id: "firstSeen" satisfies BacklinksRowsSortField,
+ accessorFn: (displayRow) =>
+ displayRow.kind === "link" ? displayRow.row.firstSeen : null,
+ header: ({ column }) => (
+
+ ),
+ size: 110,
+ minSize: 80,
+ sortDescFirst: true,
+ cell: linkCell((row) => (
- {formatCompactDate(row.original.firstSeen)}
+
{formatCompactDate(row.firstSeen)}
+ {row.lastSeen ? (
+
+ Last {formatCompactDate(row.lastSeen)}
+
+ ) : null}
- );
+ )),
},
- sortingFn: dateNullsLast,
- sortDescFirst: true,
- },
-];
+ ];
+}
/**
- * Columns for the grouped backlinks table. When `domainRatings` is provided
- * (the user clicked "Ahrefs DR"), an Ahrefs DR column is inserted after DA;
- * otherwise it stays hidden.
+ * Columns for the backlinks table. When `domainRatings` is provided (the user
+ * clicked "Ahrefs DR"), an Ahrefs DR column is inserted after DA; otherwise it
+ * stays hidden. DR is loaded client-side from Ahrefs, so it can't participate
+ * in server-side sorting.
*/
export function buildBacklinksColumns(
domainRatings: DomainRatings | null,
-): ColumnDef[] {
- if (!domainRatings) return baseBacklinksColumns;
+ onToggleDomain?: (domain: string) => void,
+): ColumnDef[] {
+ const baseColumns = buildBaseColumns(onToggleDomain);
+ if (!domainRatings) return baseColumns;
const ratings = domainRatings;
- const drColumn: ColumnDef = {
+ const drColumn: ColumnDef = {
id: "ahrefsDr",
- accessorFn: (row) => ratings[row.domain] ?? null,
- header: ({ column }) => (
-
+ enableSorting: false,
+ header: () => (
+
+
+
),
size: 90,
minSize: 70,
- cell: ({ row }) => {
- if (row.depth > 0) return null;
- const dr = ratings[row.original.domain] ?? null;
+ cell: linkCell((row) => {
+ const domain = row.domainFrom?.replace(/^www\./, "");
+ const dr = domain ? (ratings[domain] ?? null) : null;
return (
{dr == null ? "—" : formatDecimal(dr)}
);
- },
- sortingFn: numericNullsLast,
- sortDescFirst: true,
+ }),
};
const insertAt =
- baseBacklinksColumns.findIndex(
- (column) => column.id === "domainAuthority",
- ) + 1;
+ baseColumns.findIndex((column) => column.id === "domainRank") + 1;
return [
- ...baseBacklinksColumns.slice(0, insertAt),
+ ...baseColumns.slice(0, insertAt),
drColumn,
- ...baseBacklinksColumns.slice(insertAt),
+ ...baseColumns.slice(insertAt),
];
}
diff --git a/src/client/features/backlinks/BacklinksToolbarMenus.tsx b/src/client/features/backlinks/BacklinksToolbarMenus.tsx
index e63f9b0..6164702 100644
--- a/src/client/features/backlinks/BacklinksToolbarMenus.tsx
+++ b/src/client/features/backlinks/BacklinksToolbarMenus.tsx
@@ -8,26 +8,17 @@ import {
} from "lucide-react";
import type { CsvValue } from "@/client/lib/csv";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
-import type {
- BacklinksOverviewData,
- BacklinksSearchState,
-} from "./backlinksPageTypes";
+import type { BacklinksSearchState } from "./backlinksPageTypes";
import { exportBacklinksTabCsv } from "./export";
export function BacklinksExportMenu({
activeTab,
exportTarget,
- filteredData,
headers,
rows,
}: {
activeTab: BacklinksSearchState["tab"];
exportTarget: string;
- filteredData: {
- backlinks: BacklinksOverviewData["backlinks"];
- referringDomains: BacklinksOverviewData["referringDomains"];
- topPages: BacklinksOverviewData["topPages"];
- };
headers: string[];
rows: CsvValue[][];
}) {
@@ -86,7 +77,8 @@ export function BacklinksExportMenu({
exportBacklinksTabCsv({
tab: activeTab,
target: exportTarget,
- rows: filteredData,
+ headers,
+ rows,
})
}
disabled={rows.length === 0}
diff --git a/src/client/features/backlinks/ReferringDomainsTable.tsx b/src/client/features/backlinks/ReferringDomainsTable.tsx
index 11c2777..a7a8df5 100644
--- a/src/client/features/backlinks/ReferringDomainsTable.tsx
+++ b/src/client/features/backlinks/ReferringDomainsTable.tsx
@@ -1,24 +1,16 @@
-import {
- createColumnHelper,
- type SortingFn,
- type SortingState,
-} from "@tanstack/react-table";
-import { useMemo, useState } from "react";
+import { createColumnHelper } from "@tanstack/react-table";
+import type { OnChangeFn, SortingState } from "@tanstack/react-table";
+import { useMemo } from "react";
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import {
AppDataTable,
useAppTable,
} from "@/client/components/table/AppDataTable";
import { SortableHeader } from "@/client/components/table/SortableHeader";
-import {
- compareNumericNullsLast,
- dateNullsLast,
- isDescending,
- numericNullsLast,
- stringNullsLast,
-} from "@/client/components/table/nullSafeSort";
+import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
-import type { BacklinksOverviewData } from "./backlinksPageTypes";
+import type { ReferringDomainRow } from "./backlinksPageTypes";
+import type { ReferringDomainsSortField } from "@/types/schemas/backlinks";
import {
formatCompactDate,
formatDecimal,
@@ -26,30 +18,13 @@ import {
} from "./backlinksPageUtils";
import type { DomainRatings } from "./useAhrefsDomainRatings";
-type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
-
const columnHelper = createColumnHelper();
-// Nulls always to the bottom in both directions, same as the pre-TanStack
-// implementation. Secondary compare on brokenPages must also keep nulls last —
-// coercing to 0 would mix unknown values with real zeroes.
-const sortByIssues: SortingFn = (left, right, columnId) => {
- const descending = isDescending(left, columnId);
- const primary = compareNumericNullsLast(
- left.original.brokenBacklinks,
- right.original.brokenBacklinks,
- descending,
- );
- if (primary !== 0) return primary;
- return compareNumericNullsLast(
- left.original.brokenPages,
- right.original.brokenPages,
- descending,
- );
-};
-
+// Column ids map to server-side sort fields; sorting re-queries DataForSEO
+// across all referring domains, not just the loaded page.
const baseColumns = [
columnHelper.accessor("domain", {
+ id: "domain" satisfies ReferringDomainsSortField,
header: ({ column }) => (
);
},
- sortingFn: stringNullsLast,
}),
columnHelper.accessor("backlinks", {
+ id: "backlinks" satisfies ReferringDomainsSortField,
header: ({ column }) => (
),
cell: ({ getValue }) => formatNumber(getValue()),
- sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("referringPages", {
+ id: "referringPages" satisfies ReferringDomainsSortField,
header: ({ column }) => (
),
cell: ({ getValue }) => formatNumber(getValue()),
- sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("rank", {
+ id: "rank" satisfies ReferringDomainsSortField,
header: ({ column }) => (
),
cell: ({ getValue }) => formatNumber(getValue()),
- sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("spamScore", {
+ id: "spamScore" satisfies ReferringDomainsSortField,
header: ({ column }) => (
),
cell: ({ getValue }) => formatDecimal(getValue()),
- sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("firstSeen", {
+ id: "firstSeen" satisfies ReferringDomainsSortField,
header: ({ column }) => (
),
cell: ({ getValue }) => formatCompactDate(getValue()),
- sortingFn: dateNullsLast,
sortDescFirst: true,
}),
- columnHelper.display({
- id: "issues",
+ columnHelper.accessor("brokenBacklinks", {
+ id: "brokenBacklinks" satisfies ReferringDomainsSortField,
header: ({ column }) => (
),
- enableSorting: true,
- sortingFn: sortByIssues,
sortDescFirst: true,
}),
];
@@ -156,36 +128,29 @@ const baseColumns = [
/**
* Columns for the referring domains table. When `domainRatings` is provided
* (the user clicked "Ahrefs DR"), an Ahrefs DR column is inserted after Rank;
- * otherwise it stays hidden.
+ * otherwise it stays hidden. DR is loaded client-side from Ahrefs, so it can't
+ * participate in server-side sorting.
*/
function buildReferringDomainColumns(domainRatings: DomainRatings | null) {
if (!domainRatings) return baseColumns;
const ratings = domainRatings;
- const drColumn = columnHelper.accessor(
- (row) => (row.domain ? (ratings[row.domain] ?? null) : null),
- {
- id: "ahrefsDr",
- header: ({ column }) => (
-
- ),
- cell: ({ getValue }) => {
- const dr = getValue();
- return dr == null ? "—" : formatDecimal(dr);
- },
- sortingFn: numericNullsLast,
- sortDescFirst: true,
+ const drColumn = columnHelper.display({
+ id: "ahrefsDr",
+ header: () => (
+
+ ),
+ cell: ({ row }) => {
+ const domain = row.original.domain;
+ const dr = domain ? (ratings[domain] ?? null) : null;
+ return dr == null ? "—" : formatDecimal(dr);
},
- );
+ });
- const insertAt =
- baseColumns.findIndex(
- (column) => "accessorKey" in column && column.accessorKey === "rank",
- ) + 1;
+ const insertAt = baseColumns.findIndex((column) => column.id === "rank") + 1;
return [
...baseColumns.slice(0, insertAt),
drColumn,
@@ -193,8 +158,6 @@ function buildReferringDomainColumns(domainRatings: DomainRatings | null) {
];
}
-const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }];
-
function getDomainWebsiteHref(domain: string) {
try {
return new URL(domain).toString();
@@ -206,11 +169,14 @@ function getDomainWebsiteHref(domain: string) {
export function ReferringDomainsTable({
rows,
domainRatings,
+ sorting,
+ onSortingChange,
}: {
- rows: BacklinksOverviewData["referringDomains"];
+ rows: ReferringDomainRow[];
domainRatings: DomainRatings | null;
+ sorting: SortingState;
+ onSortingChange: OnChangeFn;
}) {
- const [sorting, setSorting] = useState(DEFAULT_SORTING);
const columns = useMemo(
() => buildReferringDomainColumns(domainRatings),
[domainRatings],
@@ -220,8 +186,8 @@ export function ReferringDomainsTable({
data: rows,
columns,
state: { sorting },
- onSortingChange: setSorting,
- withSorting: true,
+ onSortingChange,
+ manualSorting: true,
});
if (rows.length === 0) {
diff --git a/src/client/features/backlinks/TopPagesTable.tsx b/src/client/features/backlinks/TopPagesTable.tsx
index e8890e3..56fd268 100644
--- a/src/client/features/backlinks/TopPagesTable.tsx
+++ b/src/client/features/backlinks/TopPagesTable.tsx
@@ -1,28 +1,27 @@
-import { createColumnHelper, type SortingState } from "@tanstack/react-table";
-import { useState } from "react";
+import { createColumnHelper } from "@tanstack/react-table";
+import type { OnChangeFn, SortingState } from "@tanstack/react-table";
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import {
AppDataTable,
useAppTable,
} from "@/client/components/table/AppDataTable";
import { SortableHeader } from "@/client/components/table/SortableHeader";
-import {
- numericNullsLast,
- stringNullsLast,
-} from "@/client/components/table/nullSafeSort";
+import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
-import type { BacklinksOverviewData } from "./backlinksPageTypes";
+import type { TopPageRow } from "./backlinksPageTypes";
+import type { TopPagesSortField } from "@/types/schemas/backlinks";
import { formatNumber } from "./backlinksPageUtils";
-type TopPageRow = BacklinksOverviewData["topPages"][number];
-
const columnHelper = createColumnHelper();
+// Column ids map to server-side sort fields; sorting re-queries DataForSEO
+// across all pages, not just the loaded page of results.
const columns = [
columnHelper.accessor("page", {
- header: ({ column }) => (
- (
+
@@ -39,9 +38,9 @@ const columns = [
"-"
);
},
- sortingFn: stringNullsLast,
}),
columnHelper.accessor("backlinks", {
+ id: "backlinks" satisfies TopPagesSortField,
header: ({ column }) => (
),
cell: ({ getValue }) => formatNumber(getValue()),
- sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("referringDomains", {
+ id: "referringDomains" satisfies TopPagesSortField,
header: ({ column }) => (
),
cell: ({ getValue }) => formatNumber(getValue()),
- sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("rank", {
+ id: "rank" satisfies TopPagesSortField,
header: ({ column }) => (
),
cell: ({ getValue }) => formatNumber(getValue()),
- sortingFn: numericNullsLast,
sortDescFirst: true,
}),
columnHelper.accessor("brokenBacklinks", {
+ id: "brokenBacklinks" satisfies TopPagesSortField,
header: ({ column }) => (
),
cell: ({ getValue }) => formatNumber(getValue()),
- sortingFn: numericNullsLast,
sortDescFirst: true,
}),
];
-const DEFAULT_SORTING: SortingState = [{ id: "backlinks", desc: true }];
-
export function TopPagesTable({
rows,
+ sorting,
+ onSortingChange,
}: {
- rows: BacklinksOverviewData["topPages"];
+ rows: TopPageRow[];
+ sorting: SortingState;
+ onSortingChange: OnChangeFn;
}) {
- const [sorting, setSorting] = useState(DEFAULT_SORTING);
-
const table = useAppTable({
data: rows,
columns,
state: { sorting },
- onSortingChange: setSorting,
- withSorting: true,
+ onSortingChange,
+ manualSorting: true,
});
if (rows.length === 0) {
diff --git a/src/client/features/backlinks/backlinksFilterTypes.ts b/src/client/features/backlinks/backlinksFilterTypes.ts
index b621ecf..cf9fdb4 100644
--- a/src/client/features/backlinks/backlinksFilterTypes.ts
+++ b/src/client/features/backlinks/backlinksFilterTypes.ts
@@ -1,10 +1,14 @@
+import type {
+ BacklinksRowsFilters,
+ ReferringDomainsFilters,
+ TopPagesFilters,
+} from "@/types/schemas/backlinks";
+
export type BacklinksTabFilterValues = {
include: string;
exclude: string;
minDomainRank: string;
maxDomainRank: string;
- minAhrefsDr: string;
- maxAhrefsDr: string;
minLinkAuthority: string;
maxLinkAuthority: string;
minSpamScore: string;
@@ -21,8 +25,6 @@ export type ReferringDomainsFilterValues = {
maxBacklinks: string;
minRank: string;
maxRank: string;
- minAhrefsDr: string;
- maxAhrefsDr: string;
minSpamScore: string;
maxSpamScore: string;
};
@@ -43,8 +45,6 @@ export const EMPTY_BACKLINKS_FILTERS: BacklinksTabFilterValues = {
exclude: "",
minDomainRank: "",
maxDomainRank: "",
- minAhrefsDr: "",
- maxAhrefsDr: "",
minLinkAuthority: "",
maxLinkAuthority: "",
minSpamScore: "",
@@ -61,8 +61,6 @@ export const EMPTY_REFERRING_DOMAINS_FILTERS: ReferringDomainsFilterValues = {
maxBacklinks: "",
minRank: "",
maxRank: "",
- minAhrefsDr: "",
- maxAhrefsDr: "",
minSpamScore: "",
maxSpamScore: "",
};
@@ -77,3 +75,116 @@ export const EMPTY_TOP_PAGES_FILTERS: TopPagesFilterValues = {
minRank: "",
maxRank: "",
};
+
+export const BACKLINKS_FILTER_FIELDS = [
+ "include",
+ "exclude",
+ "minDomainRank",
+ "maxDomainRank",
+ "minLinkAuthority",
+ "maxLinkAuthority",
+ "minSpamScore",
+ "maxSpamScore",
+ "linkType",
+ "hideLost",
+ "hideBroken",
+] as const satisfies ReadonlyArray;
+export const REFERRING_DOMAINS_FILTER_FIELDS = [
+ "include",
+ "exclude",
+ "minBacklinks",
+ "maxBacklinks",
+ "minRank",
+ "maxRank",
+ "minSpamScore",
+ "maxSpamScore",
+] as const satisfies ReadonlyArray;
+export const TOP_PAGES_FILTER_FIELDS = [
+ "include",
+ "exclude",
+ "minBacklinks",
+ "maxBacklinks",
+ "minReferringDomains",
+ "maxReferringDomains",
+ "minRank",
+ "maxRank",
+] as const satisfies ReadonlyArray;
+
+export function countActiveFilters(values: Record): number {
+ return Object.values(values).filter((v) => v.trim() !== "").length;
+}
+
+/**
+ * Mirrors how the server translates filters to DataForSEO conditions: each
+ * include/exclude term is one condition, every other non-empty field is one.
+ * Used to enforce DataForSEO's per-request condition budget before applying.
+ */
+export function countFilterConditions(values: Record): number {
+ let n = 0;
+ for (const [key, value] of Object.entries(values)) {
+ if (key === "include" || key === "exclude") {
+ for (const term of value.split(/[,+]/)) if (term.trim()) n += 1;
+ continue;
+ }
+ if (value.trim() !== "") n += 1;
+ }
+ return n;
+}
+
+function toNumberOrUndefined(value: string): number | undefined {
+ const trimmed = value.trim();
+ if (trimmed === "") return undefined;
+ const parsed = Number(trimmed);
+ return Number.isFinite(parsed) ? parsed : undefined;
+}
+
+export function toBacklinksFiltersPayload(
+ values: BacklinksTabFilterValues,
+): BacklinksRowsFilters {
+ return {
+ include: values.include.trim() || undefined,
+ exclude: values.exclude.trim() || undefined,
+ minDomainRank: toNumberOrUndefined(values.minDomainRank),
+ maxDomainRank: toNumberOrUndefined(values.maxDomainRank),
+ minLinkAuthority: toNumberOrUndefined(values.minLinkAuthority),
+ maxLinkAuthority: toNumberOrUndefined(values.maxLinkAuthority),
+ minSpamScore: toNumberOrUndefined(values.minSpamScore),
+ maxSpamScore: toNumberOrUndefined(values.maxSpamScore),
+ linkType:
+ values.linkType === "dofollow" || values.linkType === "nofollow"
+ ? values.linkType
+ : undefined,
+ hideLost: values.hideLost === "true" ? true : undefined,
+ hideBroken: values.hideBroken === "true" ? true : undefined,
+ };
+}
+
+export function toReferringDomainsFiltersPayload(
+ values: ReferringDomainsFilterValues,
+): ReferringDomainsFilters {
+ return {
+ include: values.include.trim() || undefined,
+ exclude: values.exclude.trim() || undefined,
+ minBacklinks: toNumberOrUndefined(values.minBacklinks),
+ maxBacklinks: toNumberOrUndefined(values.maxBacklinks),
+ minRank: toNumberOrUndefined(values.minRank),
+ maxRank: toNumberOrUndefined(values.maxRank),
+ minSpamScore: toNumberOrUndefined(values.minSpamScore),
+ maxSpamScore: toNumberOrUndefined(values.maxSpamScore),
+ };
+}
+
+export function toTopPagesFiltersPayload(
+ values: TopPagesFilterValues,
+): TopPagesFilters {
+ return {
+ include: values.include.trim() || undefined,
+ exclude: values.exclude.trim() || undefined,
+ minBacklinks: toNumberOrUndefined(values.minBacklinks),
+ maxBacklinks: toNumberOrUndefined(values.maxBacklinks),
+ minReferringDomains: toNumberOrUndefined(values.minReferringDomains),
+ maxReferringDomains: toNumberOrUndefined(values.maxReferringDomains),
+ minRank: toNumberOrUndefined(values.minRank),
+ maxRank: toNumberOrUndefined(values.maxRank),
+ };
+}
diff --git a/src/client/features/backlinks/backlinksFiltering.test.ts b/src/client/features/backlinks/backlinksFiltering.test.ts
deleted file mode 100644
index 351c389..0000000
--- a/src/client/features/backlinks/backlinksFiltering.test.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { BacklinksOverviewData } from "./backlinksPageTypes";
-import {
- EMPTY_BACKLINKS_FILTERS,
- EMPTY_REFERRING_DOMAINS_FILTERS,
-} from "./backlinksFilterTypes";
-import {
- filterBacklinkRows,
- filterReferringDomainRows,
-} from "./backlinksFiltering";
-
-type BacklinkRow = BacklinksOverviewData["backlinks"][number];
-type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
-
-function makeBacklinkRow(overrides: Partial = {}): BacklinkRow {
- return {
- urlFrom: "https://example.com/post",
- urlTo: "https://target.example/page",
- domainFrom: "example.com",
- anchor: "Example",
- itemType: "anchor",
- rank: 10,
- domainFromRank: 20,
- pageFromRank: 10,
- spamScore: 2,
- relAttributes: [],
- firstSeen: null,
- lastSeen: null,
- linksCount: 1,
- isDofollow: true,
- isLost: false,
- isBroken: false,
- ...overrides,
- };
-}
-
-function makeReferringDomainRow(
- overrides: Partial = {},
-): ReferringDomainRow {
- return {
- domain: "example.com",
- backlinks: 10,
- referringPages: 5,
- rank: 20,
- spamScore: 2,
- firstSeen: null,
- brokenBacklinks: 0,
- brokenPages: 0,
- ...overrides,
- };
-}
-
-describe("filterBacklinkRows", () => {
- it("ignores Ahrefs DR range until ratings are loaded for the backlinks table", () => {
- const rows = [
- makeBacklinkRow({ domainFrom: "low.example" }),
- makeBacklinkRow({ domainFrom: "high.example" }),
- ];
-
- expect(
- filterBacklinkRows(rows, {
- ...EMPTY_BACKLINKS_FILTERS,
- minAhrefsDr: "50",
- }),
- ).toEqual(rows);
- });
-
- it("filters by loaded Ahrefs DR range for the backlinks table", () => {
- const rows = [
- makeBacklinkRow({ domainFrom: "low.example" }),
- makeBacklinkRow({ domainFrom: "www.high.example" }),
- makeBacklinkRow({ domainFrom: "unknown.example" }),
- ];
- const ratings = {
- "low.example": 12,
- "high.example": 64,
- "unknown.example": null,
- };
-
- expect(
- filterBacklinkRows(
- rows,
- {
- ...EMPTY_BACKLINKS_FILTERS,
- minAhrefsDr: "50",
- },
- ratings,
- ),
- ).toEqual([rows[1], rows[2]]);
-
- expect(
- filterBacklinkRows(
- rows,
- {
- ...EMPTY_BACKLINKS_FILTERS,
- maxAhrefsDr: "50",
- },
- ratings,
- ),
- ).toEqual([rows[0], rows[2]]);
- });
-});
-
-describe("filterReferringDomainRows", () => {
- it("filters by spam score range", () => {
- const rows = [
- makeReferringDomainRow({ domain: "clean.example", spamScore: 1 }),
- makeReferringDomainRow({ domain: "risky.example", spamScore: 7 }),
- makeReferringDomainRow({ domain: "unknown.example", spamScore: null }),
- ];
-
- expect(
- filterReferringDomainRows(rows, {
- ...EMPTY_REFERRING_DOMAINS_FILTERS,
- maxSpamScore: "3",
- }),
- ).toEqual([rows[0], rows[2]]);
-
- expect(
- filterReferringDomainRows(rows, {
- ...EMPTY_REFERRING_DOMAINS_FILTERS,
- minSpamScore: "3",
- }),
- ).toEqual([rows[1], rows[2]]);
- });
-
- it("ignores Ahrefs DR range until ratings are loaded for referring domains", () => {
- const rows = [
- makeReferringDomainRow({ domain: "low.example" }),
- makeReferringDomainRow({ domain: "high.example" }),
- ];
-
- expect(
- filterReferringDomainRows(rows, {
- ...EMPTY_REFERRING_DOMAINS_FILTERS,
- minAhrefsDr: "50",
- }),
- ).toEqual(rows);
- });
-
- it("filters by loaded Ahrefs DR range for referring domains", () => {
- const rows = [
- makeReferringDomainRow({ domain: "low.example" }),
- makeReferringDomainRow({ domain: "high.example" }),
- makeReferringDomainRow({ domain: "unknown.example" }),
- ];
- const ratings = {
- "low.example": 12,
- "high.example": 64,
- "unknown.example": null,
- };
-
- expect(
- filterReferringDomainRows(
- rows,
- {
- ...EMPTY_REFERRING_DOMAINS_FILTERS,
- minAhrefsDr: "50",
- },
- ratings,
- ),
- ).toEqual([rows[1], rows[2]]);
-
- expect(
- filterReferringDomainRows(
- rows,
- {
- ...EMPTY_REFERRING_DOMAINS_FILTERS,
- maxAhrefsDr: "50",
- },
- ratings,
- ),
- ).toEqual([rows[0], rows[2]]);
- });
-});
diff --git a/src/client/features/backlinks/backlinksFiltering.ts b/src/client/features/backlinks/backlinksFiltering.ts
deleted file mode 100644
index f128035..0000000
--- a/src/client/features/backlinks/backlinksFiltering.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { parseTerms } from "@/client/features/keywords/utils";
-import type { BacklinksOverviewData } from "./backlinksPageTypes";
-import type {
- BacklinksTabFilterValues,
- ReferringDomainsFilterValues,
- TopPagesFilterValues,
-} from "./backlinksFilterTypes";
-import type { DomainRatings } from "./useAhrefsDomainRatings";
-
-function passesNumericFilter(
- value: number | null | undefined,
- min: string,
- max: string,
-): boolean {
- if (value == null) return true;
- const minN = Number(min);
- if (min && !Number.isNaN(minN) && value < minN) return false;
- const maxN = Number(max);
- if (max && !Number.isNaN(maxN) && value > maxN) return false;
- return true;
-}
-
-function passesTextFilter(
- haystack: string,
- includeTerms: string[],
- excludeTerms: string[],
-): boolean {
- const lower = haystack.toLowerCase();
- if (
- includeTerms.length > 0 &&
- !includeTerms.some((term) => lower.includes(term))
- ) {
- return false;
- }
- if (excludeTerms.some((term) => lower.includes(term))) {
- return false;
- }
- return true;
-}
-
-export function filterBacklinkRows(
- rows: BacklinksOverviewData["backlinks"],
- filters: BacklinksTabFilterValues,
- domainRatings?: DomainRatings | null,
-): BacklinksOverviewData["backlinks"] {
- const includeTerms = parseTerms(filters.include);
- const excludeTerms = parseTerms(filters.exclude);
-
- return rows.filter((row) => {
- const textFields = [row.domainFrom, row.urlFrom, row.urlTo, row.anchor]
- .filter((v): v is string => Boolean(v))
- .join(" ");
-
- if (!passesTextFilter(textFields, includeTerms, excludeTerms)) return false;
- if (
- !passesNumericFilter(
- row.domainFromRank,
- filters.minDomainRank,
- filters.maxDomainRank,
- )
- )
- return false;
- if (
- domainRatings &&
- !passesNumericFilter(
- row.domainFrom
- ? domainRatings[row.domainFrom.replace(/^www\./, "")]
- : null,
- filters.minAhrefsDr,
- filters.maxAhrefsDr,
- )
- )
- return false;
- if (
- !passesNumericFilter(
- row.rank,
- filters.minLinkAuthority,
- filters.maxLinkAuthority,
- )
- )
- return false;
- if (
- !passesNumericFilter(
- row.spamScore,
- filters.minSpamScore,
- filters.maxSpamScore,
- )
- )
- return false;
-
- if (filters.linkType === "dofollow" && row.isDofollow !== true)
- return false;
- if (filters.linkType === "nofollow" && row.isDofollow !== false)
- return false;
-
- if (filters.hideLost === "true" && row.isLost) return false;
- if (filters.hideBroken === "true" && row.isBroken) return false;
-
- return true;
- });
-}
-
-export function filterReferringDomainRows(
- rows: BacklinksOverviewData["referringDomains"],
- filters: ReferringDomainsFilterValues,
- domainRatings?: DomainRatings | null,
-): BacklinksOverviewData["referringDomains"] {
- const includeTerms = parseTerms(filters.include);
- const excludeTerms = parseTerms(filters.exclude);
-
- return rows.filter((row) => {
- if (!passesTextFilter(row.domain ?? "", includeTerms, excludeTerms))
- return false;
- if (
- !passesNumericFilter(
- row.backlinks,
- filters.minBacklinks,
- filters.maxBacklinks,
- )
- )
- return false;
- if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank))
- return false;
- if (
- domainRatings &&
- !passesNumericFilter(
- row.domain ? domainRatings[row.domain] : null,
- filters.minAhrefsDr,
- filters.maxAhrefsDr,
- )
- )
- return false;
- if (
- !passesNumericFilter(
- row.spamScore,
- filters.minSpamScore,
- filters.maxSpamScore,
- )
- )
- return false;
- return true;
- });
-}
-
-export function filterTopPageRows(
- rows: BacklinksOverviewData["topPages"],
- filters: TopPagesFilterValues,
-): BacklinksOverviewData["topPages"] {
- const includeTerms = parseTerms(filters.include);
- const excludeTerms = parseTerms(filters.exclude);
-
- return rows.filter((row) => {
- if (!passesTextFilter(row.page ?? "", includeTerms, excludeTerms))
- return false;
- if (
- !passesNumericFilter(
- row.backlinks,
- filters.minBacklinks,
- filters.maxBacklinks,
- )
- )
- return false;
- if (
- !passesNumericFilter(
- row.referringDomains,
- filters.minReferringDomains,
- filters.maxReferringDomains,
- )
- )
- return false;
- if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank))
- return false;
- return true;
- });
-}
-
-export function countActiveFilters(values: Record): number {
- return Object.values(values).filter((v) => v.trim() !== "").length;
-}
diff --git a/src/client/features/backlinks/backlinksPageTypes.ts b/src/client/features/backlinks/backlinksPageTypes.ts
index 3c53a9b..78606c8 100644
--- a/src/client/features/backlinks/backlinksPageTypes.ts
+++ b/src/client/features/backlinks/backlinksPageTypes.ts
@@ -1,16 +1,21 @@
import type {
+ BacklinksSortOrder,
BacklinksTab,
BacklinksTargetScope,
} from "@/types/schemas/backlinks";
import type {
getBacklinksOverview,
getBacklinksReferringDomains,
+ getBacklinksRows,
getBacklinksTopPages,
} from "@/serverFunctions/backlinks";
export type BacklinksOverviewData = Awaited<
ReturnType
>;
+export type BacklinksRowsPageData = Awaited<
+ ReturnType
+>;
export type BacklinksReferringDomainsData = Awaited<
ReturnType
>;
@@ -18,10 +23,21 @@ export type BacklinksTopPagesData = Awaited<
ReturnType
>;
+export type BacklinksRow = BacklinksRowsPageData["rows"][number];
+export type ReferringDomainRow = BacklinksReferringDomainsData["rows"][number];
+export type TopPageRow = BacklinksTopPagesData["rows"][number];
+
export type BacklinksSearchState = {
target: string;
scope: BacklinksTargetScope;
tab: BacklinksTab;
+ page: number;
+ pageSize: number;
+ /** Sort column id for the active tab; falls back to the tab's default. */
+ sort?: string;
+ order?: BacklinksSortOrder;
+ /** Backlinks tab only: "all" lists every link; default is one per domain. */
+ view?: "all";
};
export type BacklinksNavigate = (args: {
@@ -35,20 +51,9 @@ export type BacklinksPageProps = {
navigate: BacklinksNavigate;
};
-export type BacklinksRow = BacklinksOverviewData["backlinks"][number];
-
-export type GroupedBacklinkDomain = {
- domain: string;
- domainAuthority: number | null;
- spamScore: number | null;
- firstSeen: string | null;
- backlinkCount: number;
- targetCount: number;
- lostCount: number;
- brokenCount: number;
- nofollowCount: number;
- /** Child rows for TanStack Table's getSubRows — each wraps a BacklinksRow */
- subRows: GroupedBacklinkDomain[];
- /** Set on child rows only — the original backlink data */
- _backlink?: BacklinksRow;
+/** Page rows for all three tabs; tabs that haven't loaded yet are empty. */
+export type BacklinksTabRows = {
+ backlinks: BacklinksRow[];
+ referringDomains: ReferringDomainRow[];
+ topPages: TopPageRow[];
};
diff --git a/src/client/features/backlinks/backlinksPageUtils.test.ts b/src/client/features/backlinks/backlinksPageUtils.test.ts
deleted file mode 100644
index d9789c2..0000000
--- a/src/client/features/backlinks/backlinksPageUtils.test.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { BacklinksRow } from "./backlinksPageTypes";
-import { groupBacklinksByDomain } from "./backlinksPageUtils";
-
-function makeBacklinkRow(overrides: Partial = {}): BacklinksRow {
- return {
- domainFrom: "source.example",
- urlFrom: "https://source.example/post",
- urlTo: "https://target.example/",
- anchor: null,
- itemType: null,
- isDofollow: true,
- relAttributes: [],
- rank: null,
- domainFromRank: null,
- pageFromRank: null,
- spamScore: null,
- firstSeen: null,
- lastSeen: null,
- isLost: false,
- isBroken: false,
- linksCount: null,
- ...overrides,
- };
-}
-
-describe("groupBacklinksByDomain", () => {
- it("sums grouped backlink totals from linksCount", () => {
- const groups = groupBacklinksByDomain([
- makeBacklinkRow({ linksCount: 5 }),
- makeBacklinkRow({
- urlFrom: "https://source.example/second-post",
- urlTo: "https://target.example/pricing",
- }),
- ]);
-
- expect(groups).toHaveLength(1);
- expect(groups[0]?.backlinkCount).toBe(6);
- });
-
- it("falls back to one backlink when linksCount is missing", () => {
- const groups = groupBacklinksByDomain([
- makeBacklinkRow({ linksCount: null }),
- makeBacklinkRow({
- domainFrom: "other.example",
- urlFrom: "https://other.example/post",
- linksCount: 0,
- }),
- ]);
-
- expect(groups.map((group) => group.backlinkCount)).toEqual([1, 1]);
- });
-});
diff --git a/src/client/features/backlinks/backlinksPageUtils.ts b/src/client/features/backlinks/backlinksPageUtils.ts
index a413ae5..94a3338 100644
--- a/src/client/features/backlinks/backlinksPageUtils.ts
+++ b/src/client/features/backlinks/backlinksPageUtils.ts
@@ -1,9 +1,5 @@
import type { BacklinksTab } from "@/types/schemas/backlinks";
-import type {
- BacklinksOverviewData,
- BacklinksRow,
- GroupedBacklinkDomain,
-} from "./backlinksPageTypes";
+import type { BacklinksOverviewData } from "./backlinksPageTypes";
export const TAB_DESCRIPTIONS: Record = {
backlinks:
@@ -109,70 +105,6 @@ export function formatRelativeTimestamp(value: string) {
});
}
-export function groupBacklinksByDomain(
- rows: BacklinksRow[],
-): GroupedBacklinkDomain[] {
- const groups = new Map();
-
- for (const row of rows) {
- const key = row.domainFrom?.replace(/^www\./, "") ?? "unknown";
- const existing = groups.get(key);
- if (existing) {
- existing.push(row);
- } else {
- groups.set(key, [row]);
- }
- }
-
- return Array.from(groups.entries()).map(([domain, children]) => ({
- domain,
- domainAuthority: maxNullable(children.map((r) => r.domainFromRank)),
- spamScore: maxNullable(children.map((r) => r.spamScore)),
- firstSeen: minDateString(children.map((r) => r.firstSeen)),
- backlinkCount: children.reduce(
- (total, child) => total + getBacklinkCount(child),
- 0,
- ),
- targetCount: new Set(children.map((r) => r.urlTo).filter(Boolean)).size,
- lostCount: children.filter((r) => r.isLost).length,
- brokenCount: children.filter((r) => r.isBroken).length,
- nofollowCount: children.filter((r) => r.isDofollow === false).length,
- subRows: children.map((child) => ({
- domain: child.domainFrom?.replace(/^www\./, "") ?? "unknown",
- domainAuthority: child.domainFromRank,
- spamScore: child.spamScore,
- firstSeen: child.firstSeen,
- backlinkCount: 1,
- targetCount: 1,
- lostCount: child.isLost ? 1 : 0,
- brokenCount: child.isBroken ? 1 : 0,
- nofollowCount: child.isDofollow === false ? 1 : 0,
- subRows: [],
- _backlink: child,
- })),
- }));
-}
-
-function getBacklinkCount(row: BacklinksRow) {
- return row.linksCount != null && row.linksCount > 0 ? row.linksCount : 1;
-}
-
-function maxNullable(values: (number | null)[]): number | null {
- let result: number | null = null;
- for (const v of values) {
- if (v != null && (result == null || v > result)) result = v;
- }
- return result;
-}
-
-function minDateString(values: (string | null)[]): string | null {
- let result: string | null = null;
- for (const v of values) {
- if (v && (result == null || v < result)) result = v;
- }
- return result;
-}
-
export function extractUrlPath(url: string) {
try {
const parsed = new URL(url);
diff --git a/src/client/features/backlinks/export.test.ts b/src/client/features/backlinks/export.test.ts
index c2f249f..592cf86 100644
--- a/src/client/features/backlinks/export.test.ts
+++ b/src/client/features/backlinks/export.test.ts
@@ -1,74 +1,134 @@
import { describe, expect, it } from "vitest";
-import { buildBacklinksTabCsvFile } from "./export";
+import { buildCsv } from "@/client/lib/csv";
+import type { BacklinksRow, ReferringDomainRow } from "./backlinksPageTypes";
+import {
+ buildBacklinksTabCsvFilename,
+ buildBacklinksTabExport,
+} from "./export";
-describe("buildBacklinksTabCsvFile", () => {
+function makeBacklinkRow(overrides: Partial = {}): BacklinksRow {
+ return {
+ domainFrom: "example.org",
+ urlFrom: "https://example.org/post",
+ urlTo: "https://example.com/path",
+ anchor: "Example",
+ itemType: "organic",
+ isDofollow: true,
+ relAttributes: ["noopener", "noreferrer"],
+ rank: 123,
+ domainFromRank: 45,
+ pageFromRank: 12,
+ spamScore: 10,
+ firstSeen: "2025-01-01",
+ lastSeen: "2025-01-15",
+ isLost: false,
+ isBroken: false,
+ linksCount: 2,
+ ...overrides,
+ };
+}
+
+function makeReferringDomainRow(
+ overrides: Partial = {},
+): ReferringDomainRow {
+ return {
+ domain: "source.com",
+ backlinks: 12,
+ referringPages: 7,
+ rank: 101,
+ spamScore: 4,
+ firstSeen: "2024-05-10",
+ brokenBacklinks: 1,
+ brokenPages: 0,
+ ...overrides,
+ };
+}
+
+function buildTabCsv(
+ ...args: Parameters
+): string {
+ const { headers, rows } = buildBacklinksTabExport(...args);
+ return buildCsv(headers, rows);
+}
+
+describe("buildBacklinksTabCsvFilename", () => {
+ it("normalizes the target into the filename per tab", () => {
+ expect(
+ buildBacklinksTabCsvFilename("backlinks", "https://Example.com/path?q=1"),
+ ).toBe("backlinks-backlinks-example.com-path-q-1.csv");
+ expect(buildBacklinksTabCsvFilename("domains", "Example.com")).toBe(
+ "backlinks-referring-domains-example.com.csv",
+ );
+ expect(buildBacklinksTabCsvFilename("pages", "docs.example.com")).toBe(
+ "backlinks-top-pages-docs.example.com.csv",
+ );
+ });
+});
+
+describe("buildBacklinksTabExport", () => {
it("builds backlinks csv with backlink-specific columns", () => {
- const file = buildBacklinksTabCsvFile({
+ const content = buildTabCsv({
tab: "backlinks",
- target: "https://Example.com/path?q=1",
rows: {
- backlinks: [
- {
- domainFrom: "example.org",
- urlFrom: "https://example.org/post",
- urlTo: "https://example.com/path",
- anchor: "Example",
- itemType: "organic",
- isDofollow: true,
- relAttributes: ["noopener", "noreferrer"],
- rank: 123,
- domainFromRank: 45,
- pageFromRank: 12,
- spamScore: 10,
- firstSeen: "2025-01-01",
- lastSeen: "2025-01-15",
- isLost: false,
- isBroken: false,
- linksCount: 2,
- },
- ],
+ backlinks: [makeBacklinkRow()],
referringDomains: [],
topPages: [],
},
});
- expect(file.filename).toBe("backlinks-backlinks-example.com-path-q-1.csv");
- expect(file.content).toContain('"Domain","Source URL","Target URL"');
- expect(file.content).toContain('"example.org"');
- expect(file.content).toContain('"noopener, noreferrer"');
+ expect(content).toContain('"Domain","Source URL","Target URL"');
+ expect(content).not.toContain('"Ahrefs DR"');
+ expect(content).toContain('"example.org"');
+ expect(content).toContain('"noopener, noreferrer"');
});
- it("builds referring domains csv", () => {
- const file = buildBacklinksTabCsvFile({
+ it("includes an Ahrefs DR column when ratings are loaded", () => {
+ const content = buildTabCsv({
tab: "domains",
- target: "Example.com",
+ domainRatings: { "source.com": 71.5, "other.com": null },
rows: {
backlinks: [],
- referringDomains: [
- {
- domain: "source.com",
- backlinks: 12,
- referringPages: 7,
- rank: 101,
- spamScore: 4,
- firstSeen: "2024-05-10",
- brokenBacklinks: 1,
- brokenPages: 0,
- },
- ],
+ referringDomains: [makeReferringDomainRow()],
topPages: [],
},
});
- expect(file.filename).toBe("backlinks-referring-domains-example.com.csv");
- expect(file.content).toContain('"Domain","Backlinks","Referring Pages"');
- expect(file.content).toContain('"source.com"');
+ expect(content).toContain('"Rank","Ahrefs DR","Spam Score"');
+ expect(content).toContain('"71.5"');
+ });
+
+ it("keys backlink Ahrefs DR off the www-stripped source domain", () => {
+ const content = buildTabCsv({
+ tab: "backlinks",
+ domainRatings: { "example.org": 33 },
+ rows: {
+ backlinks: [makeBacklinkRow({ domainFrom: "www.example.org" })],
+ referringDomains: [],
+ topPages: [],
+ },
+ });
+
+ expect(content).toContain('"Domain Rank","Ahrefs DR","Source Page Rank"');
+ expect(content).toContain('"33"');
+ });
+
+ it("builds referring domains csv", () => {
+ const content = buildTabCsv({
+ tab: "domains",
+ rows: {
+ backlinks: [],
+ referringDomains: [makeReferringDomainRow()],
+ topPages: [],
+ },
+ });
+
+ expect(content).toContain('"Domain","Backlinks","Referring Pages"');
+ expect(content).toContain('"source.com"');
});
it("builds top pages csv", () => {
- const file = buildBacklinksTabCsvFile({
+ const content = buildTabCsv({
tab: "pages",
- target: "docs.example.com",
rows: {
backlinks: [],
referringDomains: [],
@@ -84,45 +144,34 @@ describe("buildBacklinksTabCsvFile", () => {
},
});
- expect(file.filename).toBe("backlinks-top-pages-docs.example.com.csv");
- expect(file.content).toContain(
+ expect(content).toContain(
'"Page","Backlinks","Referring Domains","Rank","Broken Backlinks"',
);
- expect(file.content).toContain('"https://docs.example.com/start"');
+ expect(content).toContain('"https://docs.example.com/start"');
});
+
it("sanitizes formula-like cell values to prevent CSV injection", () => {
- const file = buildBacklinksTabCsvFile({
+ const content = buildTabCsv({
tab: "backlinks",
- target: "example.com",
rows: {
backlinks: [
- {
+ makeBacklinkRow({
domainFrom: "=cmd|' /C calc'!A0",
urlFrom: "+https://evil.example/source",
urlTo: "@https://evil.example/target",
anchor: "\tformula",
- itemType: "organic",
- isDofollow: true,
relAttributes: [],
- rank: 1,
- domainFromRank: 1,
- pageFromRank: 1,
- spamScore: 0,
- firstSeen: "2025-01-01",
- lastSeen: "2025-01-01",
- isLost: false,
- isBroken: false,
linksCount: 1,
- },
+ }),
],
referringDomains: [],
topPages: [],
},
});
- expect(file.content).toContain("\"'=cmd|' /C calc'!A0\"");
- expect(file.content).toContain('"\'+https://evil.example/source"');
- expect(file.content).toContain('"\'@https://evil.example/target"');
- expect(file.content).toContain('"\'\tformula"');
+ expect(content).toContain("\"'=cmd|' /C calc'!A0\"");
+ expect(content).toContain('"\'+https://evil.example/source"');
+ expect(content).toContain('"\'@https://evil.example/target"');
+ expect(content).toContain('"\'\tformula"');
});
});
diff --git a/src/client/features/backlinks/export.ts b/src/client/features/backlinks/export.ts
index 33d231d..0e989ea 100644
--- a/src/client/features/backlinks/export.ts
+++ b/src/client/features/backlinks/export.ts
@@ -1,20 +1,25 @@
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
import type {
- BacklinksOverviewData,
BacklinksSearchState,
+ BacklinksTabRows,
} from "./backlinksPageTypes";
+import type { DomainRatings } from "./useAhrefsDomainRatings";
-type BacklinksFilteredData = {
- backlinks: BacklinksOverviewData["backlinks"];
- referringDomains: BacklinksOverviewData["referringDomains"];
- topPages: BacklinksOverviewData["topPages"];
-};
-
+/**
+ * Builds the export table for the active tab. When `domainRatings` is loaded
+ * (the user clicked "Ahrefs DR"), an Ahrefs DR column is included for the
+ * Backlinks and Referring Domains tabs, matching the on-screen table.
+ */
export function buildBacklinksTabExport(args: {
tab: BacklinksSearchState["tab"];
- rows: BacklinksFilteredData;
+ rows: BacklinksTabRows;
+ domainRatings?: DomainRatings | null;
}): { headers: string[]; rows: CsvValue[][] } {
- const { tab, rows } = args;
+ const { tab, rows, domainRatings } = args;
+ const ratingFor = (domain: string | null | undefined): CsvValue => {
+ if (!domainRatings || !domain) return null;
+ return domainRatings[domain.replace(/^www\./, "")] ?? null;
+ };
if (tab === "backlinks") {
return {
@@ -27,6 +32,7 @@ export function buildBacklinksTabExport(args: {
"Dofollow",
"Rel Attributes",
"Domain Rank",
+ ...(domainRatings ? ["Ahrefs DR"] : []),
"Source Page Rank",
"Target Rank",
"Spam Score",
@@ -45,6 +51,7 @@ export function buildBacklinksTabExport(args: {
row.isDofollow,
row.relAttributes.join(", "),
row.domainFromRank,
+ ...(domainRatings ? [ratingFor(row.domainFrom)] : []),
row.pageFromRank,
row.rank,
row.spamScore,
@@ -64,6 +71,7 @@ export function buildBacklinksTabExport(args: {
"Backlinks",
"Referring Pages",
"Rank",
+ ...(domainRatings ? ["Ahrefs DR"] : []),
"Spam Score",
"First Seen",
"Broken Backlinks",
@@ -74,6 +82,7 @@ export function buildBacklinksTabExport(args: {
row.backlinks,
row.referringPages,
row.rank,
+ ...(domainRatings ? [ratingFor(row.domain)] : []),
row.spamScore,
row.firstSeen,
row.brokenBacklinks,
@@ -100,38 +109,28 @@ export function buildBacklinksTabExport(args: {
};
}
-export function buildBacklinksTabCsvFile(args: {
- tab: BacklinksSearchState["tab"];
- target: string;
- rows: BacklinksFilteredData;
-}) {
- const { headers, rows } = buildBacklinksTabExport({
- tab: args.tab,
- rows: args.rows,
- });
- const filenamePrefix =
- args.tab === "backlinks"
- ? "backlinks"
- : args.tab === "domains"
- ? "referring-domains"
- : "top-pages";
-
- return {
- filename: buildFilename(filenamePrefix, args.target),
- content: buildCsv(headers, rows),
- };
-}
-
export function exportBacklinksTabCsv(args: {
tab: BacklinksSearchState["tab"];
target: string;
- rows: BacklinksFilteredData;
+ headers: string[];
+ rows: CsvValue[][];
}) {
- const file = buildBacklinksTabCsvFile(args);
- downloadCsv(file.filename, file.content);
+ downloadCsv(
+ buildBacklinksTabCsvFilename(args.tab, args.target),
+ buildCsv(args.headers, args.rows),
+ );
}
-function buildFilename(tabPrefix: string, target: string) {
+export function buildBacklinksTabCsvFilename(
+ tab: BacklinksSearchState["tab"],
+ target: string,
+) {
+ const tabPrefix =
+ tab === "backlinks"
+ ? "backlinks"
+ : tab === "domains"
+ ? "referring-domains"
+ : "top-pages";
const normalizedTarget = target
.toLowerCase()
.trim()
diff --git a/src/client/features/backlinks/useBacklinksDomainExpansion.ts b/src/client/features/backlinks/useBacklinksDomainExpansion.ts
new file mode 100644
index 0000000..42763e8
--- /dev/null
+++ b/src/client/features/backlinks/useBacklinksDomainExpansion.ts
@@ -0,0 +1,93 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useQueries } from "@tanstack/react-query";
+import { getBacklinksRows } from "@/serverFunctions/backlinks";
+import type { BacklinksRow, BacklinksSearchState } from "./backlinksPageTypes";
+
+const DOMAIN_LINKS_PAGE_SIZE = 100;
+const DOMAIN_LINKS_STALE_TIME_MS = 5 * 60 * 1000;
+
+export type BacklinksDomainEntry =
+ | { status: "loading" }
+ | { status: "error" }
+ | { status: "ready"; rows: BacklinksRow[] };
+
+export type BacklinksDomainExpansion = {
+ expandedDomains: ReadonlySet;
+ /** One entry per expanded domain; keyed by the row's raw domainFrom. */
+ entriesByDomain: Record;
+ toggleDomain: (domain: string) => void;
+};
+
+/**
+ * Lazily loads the full link list for referring domains the user expands in
+ * the one-per-domain backlinks view. Each expansion is one billed DataForSEO
+ * request (capped at 100 links), cached client-side and in R2.
+ */
+export function useBacklinksDomainExpansion({
+ projectId,
+ searchState,
+}: {
+ projectId: string;
+ searchState: BacklinksSearchState;
+}): BacklinksDomainExpansion {
+ const { target, scope } = searchState;
+ const [expanded, setExpanded] = useState([]);
+
+ // Collapse everything when the lookup changes.
+ useEffect(() => {
+ setExpanded([]);
+ }, [projectId, target, scope]);
+
+ const queries = useQueries({
+ queries: expanded.map((domain) => ({
+ queryKey: [
+ "backlinksDomainLinks",
+ projectId,
+ scope,
+ target,
+ domain,
+ ] as const,
+ staleTime: DOMAIN_LINKS_STALE_TIME_MS,
+ queryFn: () =>
+ getBacklinksRows({
+ data: {
+ projectId,
+ target,
+ scope,
+ page: 1,
+ pageSize: DOMAIN_LINKS_PAGE_SIZE,
+ sortField: "rank",
+ sortOrder: "desc",
+ filters: { domainFrom: domain },
+ mode: "as_is",
+ },
+ }),
+ })),
+ });
+
+ const entriesByDomain = useMemo(() => {
+ const map: Record = {};
+ expanded.forEach((domain, index) => {
+ const query = queries[index];
+ if (!query) return;
+ map[domain] = query.data
+ ? { status: "ready", rows: query.data.rows }
+ : query.error
+ ? { status: "error" }
+ : { status: "loading" };
+ });
+ return map;
+ }, [expanded, queries]);
+
+ const expandedDomains = useMemo(() => new Set(expanded), [expanded]);
+
+ const toggleDomain = useCallback((domain: string) => {
+ setExpanded((current) =>
+ current.includes(domain)
+ ? current.filter((entry) => entry !== domain)
+ : [...current, domain],
+ );
+ }, []);
+
+ return { expandedDomains, entriesByDomain, toggleDomain };
+}
diff --git a/src/client/features/backlinks/useBacklinksFilters.ts b/src/client/features/backlinks/useBacklinksFilters.ts
index 479d8b6..442f3cf 100644
--- a/src/client/features/backlinks/useBacklinksFilters.ts
+++ b/src/client/features/backlinks/useBacklinksFilters.ts
@@ -1,14 +1,15 @@
-import { useCallback, useEffect, useState } from "react";
-import { useForm, useStore } from "@tanstack/react-form";
+import { useCallback, useState } from "react";
+import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
import {
EMPTY_BACKLINKS_FILTERS,
EMPTY_REFERRING_DOMAINS_FILTERS,
EMPTY_TOP_PAGES_FILTERS,
+ countActiveFilters,
+ countFilterConditions,
type BacklinksTabFilterValues,
type ReferringDomainsFilterValues,
type TopPagesFilterValues,
} from "./backlinksFilterTypes";
-import { countActiveFilters } from "./backlinksFiltering";
const STORAGE_KEY_PREFIX = "backlinks-filters:";
@@ -36,6 +37,13 @@ function loadFromStorage(tab: string, fallback: T): T {
}
}
+ // Filters persisted before the server-side-filtering change had no
+ // condition budget; values over the DataForSEO cap would fail every
+ // query on load, so start fresh instead.
+ if (countFilterConditions(result) > MAX_DATAFORSEO_FILTER_CONDITIONS) {
+ return fallbackClone;
+ }
+
return result;
} catch {
return fallbackClone;
@@ -50,24 +58,30 @@ function saveToStorage(tab: string, values: FilterValues) {
}
}
+/**
+ * Holds the *applied* filters for one tab. Draft edits live inside the filter
+ * panel; values here are what the server queries use, persisted per tab.
+ */
function useTabFilters(tab: string, emptyValues: T) {
- const [defaultValues] = useState(() =>
+ const [values, setValues] = useState(() =>
loadFromStorage(tab, { ...emptyValues }),
);
- const form = useForm({ defaultValues });
- const values = useStore(form.store, (state) => state.values);
- useEffect(() => {
- saveToStorage(tab, values);
- }, [tab, values]);
+ const apply = useCallback(
+ (next: T) => {
+ setValues(next);
+ saveToStorage(tab, next);
+ },
+ [tab],
+ );
const reset = useCallback(() => {
- form.reset({ ...emptyValues }, { keepDefaultValues: true });
- }, [emptyValues, form]);
+ apply({ ...emptyValues });
+ }, [apply, emptyValues]);
return {
- form,
values,
+ apply,
reset,
activeFilterCount: countActiveFilters(values),
};
diff --git a/src/client/features/backlinks/useBacklinksPageData.ts b/src/client/features/backlinks/useBacklinksPageData.ts
index d06a701..23a384a 100644
--- a/src/client/features/backlinks/useBacklinksPageData.ts
+++ b/src/client/features/backlinks/useBacklinksPageData.ts
@@ -12,16 +12,35 @@ import {
import {
getBacklinksOverview,
getBacklinksReferringDomains,
+ getBacklinksRows,
getBacklinksTopPages,
} from "@/serverFunctions/backlinks";
import { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess";
+import {
+ BACKLINKS_DEFAULT_SORT,
+ backlinksRowsSortFieldSchema,
+ referringDomainsSortFieldSchema,
+ topPagesSortFieldSchema,
+ type BacklinksSortOrder,
+} from "@/types/schemas/backlinks";
+import {
+ toBacklinksFiltersPayload,
+ toReferringDomainsFiltersPayload,
+ toTopPagesFiltersPayload,
+} from "./backlinksFilterTypes";
+import type { BacklinksFiltersState } from "./useBacklinksFilters";
import { getPersistedBacklinksSearchScope } from "./backlinksSearchScope";
type UseBacklinksPageDataArgs = {
projectId: string;
searchState: BacklinksSearchState;
+ filters: BacklinksFiltersState;
};
+// Five-minute client staleness on top of the server's 6h R2 cache, so window
+// refocus doesn't re-run the server functions for bytes that can't change.
+const BACKLINKS_QUERY_STALE_TIME_MS = 5 * 60 * 1000;
+
function getBacklinksErrorMessage(
error: unknown,
fallback: string,
@@ -34,9 +53,28 @@ function getBacklinksErrorMessage(
return getStandardErrorMessage(error, fallback);
}
+/**
+ * Maps the URL's sort/order params to a request's sortField/sortOrder pair.
+ * The sort param is checked against the tab's allowed sort fields; anything
+ * unexpected falls back to the tab's default sort.
+ */
+function toSort(
+ sortParam: string | undefined,
+ orderParam: BacklinksSortOrder | undefined,
+ allowedFields: readonly T[],
+ fallback: { field: T; order: BacklinksSortOrder },
+): { field: T; order: BacklinksSortOrder } {
+ const field = sortParam
+ ? allowedFields.find((candidate) => candidate === sortParam)
+ : undefined;
+ if (!field) return fallback;
+ return { field, order: orderParam ?? "desc" };
+}
+
export function useBacklinksPageData({
projectId,
searchState,
+ filters,
}: UseBacklinksPageDataArgs) {
const accessGate = useAccessGate({
queryKey: ["backlinksAccessStatus", projectId],
@@ -45,7 +83,6 @@ export function useBacklinksPageData({
});
const backlinksEnabled = accessGate.enabled;
const retryAccessGate = accessGate.onRetry;
- const requestInput = buildBacklinksRequestInput(projectId, searchState);
const searchCardInitialValues = useMemo(
() => ({
target: searchState.target,
@@ -54,33 +91,118 @@ export function useBacklinksPageData({
[searchState.scope, searchState.target],
);
- const baseQueryKeyParts = [
- projectId,
- searchState.scope,
- searchState.target,
- ] as const;
+ const { target, scope, tab, page, pageSize, sort, order, view } = searchState;
+ const rowsMode = view === "all" ? "as_is" : "one_per_domain";
+ const targetReady = backlinksEnabled && Boolean(target);
+ const baseQueryKeyParts = [projectId, scope, target] as const;
+ const pageInputBase = { projectId, target, scope, page, pageSize };
+
const overviewQuery = useQuery({
queryKey: ["backlinksOverview", ...baseQueryKeyParts],
- enabled: backlinksEnabled && Boolean(searchState.target),
- queryFn: () => getBacklinksOverview({ data: requestInput }),
+ enabled: targetReady,
+ staleTime: BACKLINKS_QUERY_STALE_TIME_MS,
+ queryFn: () => getBacklinksOverview({ data: { projectId, target, scope } }),
});
+ const rowsSort = toSort(
+ sort,
+ order,
+ backlinksRowsSortFieldSchema.options,
+ BACKLINKS_DEFAULT_SORT.backlinks,
+ );
+ const rowsFilters = useMemo(
+ () => toBacklinksFiltersPayload(filters.backlinks.values),
+ [filters.backlinks.values],
+ );
+ const rowsQuery = useQuery({
+ queryKey: [
+ "backlinksRows",
+ ...baseQueryKeyParts,
+ page,
+ pageSize,
+ rowsSort.field,
+ rowsSort.order,
+ rowsFilters,
+ rowsMode,
+ ],
+ enabled: targetReady && tab === "backlinks",
+ staleTime: BACKLINKS_QUERY_STALE_TIME_MS,
+ queryFn: () =>
+ getBacklinksRows({
+ data: {
+ ...pageInputBase,
+ sortField: rowsSort.field,
+ sortOrder: rowsSort.order,
+ filters: rowsFilters,
+ mode: rowsMode,
+ },
+ }),
+ });
+
+ const domainsSort = toSort(
+ sort,
+ order,
+ referringDomainsSortFieldSchema.options,
+ BACKLINKS_DEFAULT_SORT.domains,
+ );
+ const domainsFilters = useMemo(
+ () => toReferringDomainsFiltersPayload(filters.domains.values),
+ [filters.domains.values],
+ );
const referringDomainsQuery = useQuery({
- queryKey: ["backlinksReferringDomains", ...baseQueryKeyParts],
- enabled:
- backlinksEnabled &&
- Boolean(searchState.target) &&
- searchState.tab === "domains",
- queryFn: () => getBacklinksReferringDomains({ data: requestInput }),
+ queryKey: [
+ "backlinksReferringDomains",
+ ...baseQueryKeyParts,
+ page,
+ pageSize,
+ domainsSort.field,
+ domainsSort.order,
+ domainsFilters,
+ ],
+ enabled: targetReady && tab === "domains",
+ staleTime: BACKLINKS_QUERY_STALE_TIME_MS,
+ queryFn: () =>
+ getBacklinksReferringDomains({
+ data: {
+ ...pageInputBase,
+ sortField: domainsSort.field,
+ sortOrder: domainsSort.order,
+ filters: domainsFilters,
+ },
+ }),
});
+ const pagesSort = toSort(
+ sort,
+ order,
+ topPagesSortFieldSchema.options,
+ BACKLINKS_DEFAULT_SORT.pages,
+ );
+ const pagesFilters = useMemo(
+ () => toTopPagesFiltersPayload(filters.pages.values),
+ [filters.pages.values],
+ );
const topPagesQuery = useQuery({
- queryKey: ["backlinksTopPages", ...baseQueryKeyParts],
- enabled:
- backlinksEnabled &&
- Boolean(searchState.target) &&
- searchState.tab === "pages",
- queryFn: () => getBacklinksTopPages({ data: requestInput }),
+ queryKey: [
+ "backlinksTopPages",
+ ...baseQueryKeyParts,
+ page,
+ pageSize,
+ pagesSort.field,
+ pagesSort.order,
+ pagesFilters,
+ ],
+ enabled: targetReady && tab === "pages",
+ staleTime: BACKLINKS_QUERY_STALE_TIME_MS,
+ queryFn: () =>
+ getBacklinksTopPages({
+ data: {
+ ...pageInputBase,
+ sortField: pagesSort.field,
+ sortOrder: pagesSort.order,
+ filters: pagesFilters,
+ },
+ }),
});
const overviewErrorMessage = getBacklinksErrorMessage(
@@ -89,17 +211,18 @@ export function useBacklinksPageData({
);
const backlinksDisabledByError =
getErrorCode(overviewQuery.error) === "BACKLINKS_NOT_ENABLED";
- const activeTabError = getActiveTabError(
- searchState,
- referringDomainsQuery.error,
- topPagesQuery.error,
- );
+ const activeTabQuery =
+ tab === "backlinks"
+ ? rowsQuery
+ : tab === "domains"
+ ? referringDomainsQuery
+ : topPagesQuery;
const activeTabErrorMessage = getBacklinksErrorMessage(
- activeTabError,
+ activeTabQuery.error,
"Could not load this tab.",
);
const backlinksDisabledByTabError =
- getErrorCode(activeTabError) === "BACKLINKS_NOT_ENABLED";
+ getErrorCode(activeTabQuery.error) === "BACKLINKS_NOT_ENABLED";
useEffect(() => {
if (
@@ -118,10 +241,12 @@ export function useBacklinksPageData({
return {
accessGate,
activeTabErrorMessage,
+ activeTabQuery,
backlinksDisabledByError,
overviewErrorMessage,
overviewQuery,
referringDomainsQuery,
+ rowsQuery,
searchCardInitialValues,
topPagesQuery,
};
@@ -137,39 +262,10 @@ export function navigateToBacklinksSearch(
target: values.target,
scope: getPersistedBacklinksSearchScope(values.target, values.scope),
tab: undefined,
+ page: undefined,
+ sort: undefined,
+ order: undefined,
}),
replace: true,
});
}
-
-function buildBacklinksRequestInput(
- projectId: string,
- searchState: BacklinksSearchState,
-) {
- return {
- projectId,
- target: searchState.target,
- scope: searchState.scope,
- // Server-side spam filtering (hideSpam/spamThreshold) is available but
- // intentionally disabled. All filtering — including spam score — is applied
- // client-side so users get immediate feedback without re-fetching. This
- // trades slightly larger API responses for simpler code and flexibility.
- hideSpam: false,
- };
-}
-
-function getActiveTabError(
- searchState: BacklinksSearchState,
- referringDomainsError: unknown,
- topPagesError: unknown,
-) {
- if (searchState.tab === "domains") {
- return referringDomainsError;
- }
-
- if (searchState.tab === "pages") {
- return topPagesError;
- }
-
- return null;
-}
diff --git a/src/client/features/domain/components/DomainFilterPanel.tsx b/src/client/features/domain/components/DomainFilterPanel.tsx
index c733e22..5f705fb 100644
--- a/src/client/features/domain/components/DomainFilterPanel.tsx
+++ b/src/client/features/domain/components/DomainFilterPanel.tsx
@@ -1,4 +1,10 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
import { AlertTriangle, RotateCcw } from "lucide-react";
import {
FilterNumberInput,
@@ -36,6 +42,11 @@ type Props = {
countConditions: (values: TValues) => number;
onApply: (values: TValues) => void;
onClear: () => void;
+ /** Extra feature-specific controls (toggles etc.) bound to the draft. */
+ renderExtra?: (
+ draft: TValues,
+ setValue: (key: keyof TValues, value: string) => void,
+ ) => ReactNode;
};
export function DomainFilterPanel({
@@ -48,6 +59,7 @@ export function DomainFilterPanel({
countConditions,
onApply,
onClear,
+ renderExtra,
}: Props) {
const appliedKey = useMemo(
() => fields.map((key) => appliedFilters[key]).join("|"),
@@ -97,10 +109,19 @@ export function DomainFilterPanel({
}, [appliedFilters, debugName]);
const resetFilters = useCallback(() => {
debugDomain(`${debugName}:clear`);
+ // Also clear unapplied draft edits — when the applied filters are already
+ // empty, the applied-sync effect won't fire (appliedKey is unchanged).
+ setDraftFilters((current) => {
+ const next = { ...current };
+ for (const key of fields) Object.assign(next, { [key]: "" });
+ return next;
+ });
onClear();
- }, [debugName, onClear]);
+ }, [debugName, fields, onClear]);
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key !== "Enter") return;
+ // Let buttons (Cancel, toggles) handle their own Enter activation.
+ if (event.target instanceof HTMLButtonElement) return;
if (meta.overLimit) return;
event.preventDefault();
applyFilters();
@@ -177,6 +198,8 @@ export function DomainFilterPanel({
))}
+ {renderExtra ? renderExtra(draftFilters, handleValueChange) : null}
+
{meta.overLimit ? (
diff --git a/src/routes/_project/p/$projectId/backlinks.tsx b/src/routes/_project/p/$projectId/backlinks.tsx
index 3bceb25..7581577 100644
--- a/src/routes/_project/p/$projectId/backlinks.tsx
+++ b/src/routes/_project/p/$projectId/backlinks.tsx
@@ -1,7 +1,10 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { BacklinksPage } from "@/client/features/backlinks/BacklinksPage";
import { inferBacklinksSearchScopeFromTarget } from "@/client/features/backlinks/backlinksSearchScope";
-import { backlinksSearchSchema } from "@/types/schemas/backlinks";
+import {
+ DEFAULT_BACKLINKS_PAGE_SIZE,
+ backlinksSearchSchema,
+} from "@/types/schemas/backlinks";
export const Route = createFileRoute("/_project/p/$projectId/backlinks")({
validateSearch: backlinksSearchSchema,
@@ -11,7 +14,16 @@ export const Route = createFileRoute("/_project/p/$projectId/backlinks")({
function BacklinksRoute() {
const { projectId } = Route.useParams();
const navigate = useNavigate({ from: Route.fullPath });
- const { target = "", scope: rawScope, tab = "backlinks" } = Route.useSearch();
+ const {
+ target = "",
+ scope: rawScope,
+ tab = "backlinks",
+ page = 1,
+ size = DEFAULT_BACKLINKS_PAGE_SIZE,
+ sort,
+ order,
+ view,
+ } = Route.useSearch();
const scope = rawScope ?? inferBacklinksSearchScopeFromTarget(target);
return (
@@ -22,6 +34,11 @@ function BacklinksRoute() {
target,
scope,
tab,
+ page,
+ pageSize: size,
+ sort,
+ order,
+ view,
}}
/>
);
diff --git a/src/server/features/backlinks/services/BacklinksService.billing.test.ts b/src/server/features/backlinks/services/BacklinksService.billing.test.ts
index 0d87779..3bf8518 100644
--- a/src/server/features/backlinks/services/BacklinksService.billing.test.ts
+++ b/src/server/features/backlinks/services/BacklinksService.billing.test.ts
@@ -37,6 +37,15 @@ const billingCustomer = {
userEmail: "team@example.com",
};
+const pageInputDefaults = {
+ projectId: "project_123",
+ page: 1,
+ pageSize: 100,
+ sortOrder: "desc",
+ filters: {},
+ mode: "as_is",
+} as const;
+
const cache = new Map
();
const service = createBacklinksService({
async get(key) {
@@ -53,7 +62,7 @@ beforeEach(() => {
vi.clearAllMocks();
});
-it("profiles only the initial overview calls and reuses cache on repeat", async () => {
+it("profiles only the summary and history for the overview and reuses cache on repeat", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "example.com",
displayTarget: "example.com",
@@ -73,27 +82,6 @@ it("profiles only the initial overview calls and reuses cache on repeat", async
new_referring_domains: 8,
lost_referring_domains: 2,
});
- backlinksRowsMock.mockResolvedValue([
- {
- domain_from: "source.example",
- url_from: "https://source.example/post",
- url_to: "https://example.com/",
- anchor: "Example",
- item_type: "content",
- dofollow: true,
- rank: 77,
- domain_from_rank: 65,
- page_from_rank: 54,
- backlink_spam_score: 3,
- first_seen: "2026-01-01",
- last_visited: "2026-03-01",
- lost_date: null,
- is_lost: false,
- is_broken: false,
- links_count: 1,
- rel_attributes: ["noopener"],
- },
- ]);
backlinksHistoryMock.mockResolvedValue([
{
date: "2026-02-01",
@@ -116,8 +104,9 @@ it("profiles only the initial overview calls and reuses cache on repeat", async
billingCustomer,
);
- expect(first.overview.referringDomains).toEqual([]);
- expect(first.overview.topPages).toEqual([]);
+ expect(first.overview.summary.backlinks).toBe(1200);
+ expect(first.overview.trends).toHaveLength(1);
+ expect(backlinksRowsMock).not.toHaveBeenCalled();
expect(referringDomainsMock).not.toHaveBeenCalled();
expect(domainPagesMock).not.toHaveBeenCalled();
expect(backlinksSummaryMock).toHaveBeenCalledOnce();
@@ -125,46 +114,157 @@ it("profiles only the initial overview calls and reuses cache on repeat", async
expect(second).toEqual(first);
});
-it("profiles referring domains and top pages separately", async () => {
+it("profiles backlink rows per page with offset and total count", async () => {
+ vi.mocked(normalizeBacklinksTarget).mockReturnValue({
+ apiTarget: "example.com",
+ displayTarget: "example.com",
+ scope: "domain",
+ });
+ backlinksRowsMock.mockResolvedValue({
+ items: [
+ {
+ domain_from: "source.example",
+ url_from: "https://source.example/post",
+ url_to: "https://example.com/",
+ anchor: "Example",
+ item_type: "content",
+ dofollow: true,
+ rank: 77,
+ domain_from_rank: 65,
+ page_from_rank: 54,
+ backlink_spam_score: 3,
+ first_seen: "2026-01-01",
+ last_visited: "2026-03-01",
+ lost_date: null,
+ is_lost: false,
+ is_broken: false,
+ links_count: 1,
+ rel_attributes: ["noopener"],
+ },
+ ],
+ totalCount: 450,
+ });
+
+ const result = await service.profileBacklinksPage(
+ {
+ ...pageInputDefaults,
+ target: "example.com",
+ page: 2,
+ sortField: "rank",
+ },
+ billingCustomer,
+ { hideSpam: false },
+ );
+
+ expect(backlinksRowsMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ target: "example.com",
+ limit: 100,
+ offset: 100,
+ orderBy: ["rank,desc"],
+ hideSpam: false,
+ }),
+ );
+ expect(result.rows).toHaveLength(1);
+ expect(result.totalCount).toBe(450);
+ expect(result.hasMore).toBe(true);
+ expect(result.page).toBe(2);
+});
+
+it("translates filters into DataForSEO conditions for backlink rows", async () => {
+ vi.mocked(normalizeBacklinksTarget).mockReturnValue({
+ apiTarget: "example.com",
+ displayTarget: "example.com",
+ scope: "domain",
+ });
+ backlinksRowsMock.mockResolvedValue({ items: [], totalCount: 0 });
+
+ await service.profileBacklinksPage(
+ {
+ ...pageInputDefaults,
+ target: "example.com",
+ sortField: "rank",
+ filters: {
+ include: "blog",
+ minDomainRank: 30,
+ linkType: "dofollow",
+ hideLost: true,
+ },
+ },
+ billingCustomer,
+ { hideSpam: false },
+ );
+
+ expect(backlinksRowsMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ filters: [
+ ["url_from", "ilike", "%blog%"],
+ "and",
+ ["domain_from_rank", ">=", 30],
+ "and",
+ ["dofollow", "=", true],
+ "and",
+ ["is_lost", "=", false],
+ ],
+ }),
+ );
+});
+
+it("profiles referring domains and top pages pages separately", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "https://example.com/foo",
displayTarget: "https://example.com/foo",
scope: "page",
});
- referringDomainsMock.mockResolvedValue([
- {
- domain: "source.example",
- backlinks: 4,
- referring_pages: 2,
- rank: 65,
- first_seen: "2026-01-01",
- broken_backlinks: 0,
- broken_pages: 0,
- backlinks_spam_score: 2,
- target_spam_score: 4,
- },
- ]);
- domainPagesMock.mockResolvedValue([
- {
- page: "https://example.com/foo",
- backlinks: 100,
- referring_domains: 20,
- rank: 50,
- broken_backlinks: 0,
- },
- ]);
+ referringDomainsMock.mockResolvedValue({
+ items: [
+ {
+ domain: "source.example",
+ backlinks: 4,
+ referring_pages: 2,
+ rank: 65,
+ first_seen: "2026-01-01",
+ broken_backlinks: 0,
+ broken_pages: 0,
+ backlinks_spam_score: 2,
+ target_spam_score: 4,
+ },
+ ],
+ totalCount: 1,
+ });
+ domainPagesMock.mockResolvedValue({
+ items: [
+ {
+ page: "https://example.com/foo",
+ backlinks: 100,
+ referring_domains: 20,
+ rank: 50,
+ broken_backlinks: 0,
+ },
+ ],
+ totalCount: 1,
+ });
- const domains = await service.profileReferringDomains(
- { target: "https://example.com/foo" },
+ const domains = await service.profileReferringDomainsPage(
+ {
+ ...pageInputDefaults,
+ target: "https://example.com/foo",
+ sortField: "backlinks",
+ },
billingCustomer,
);
- const pages = await service.profileTopPages(
- { target: "https://example.com/foo" },
+ const pages = await service.profileTopPagesPage(
+ {
+ ...pageInputDefaults,
+ target: "https://example.com/foo",
+ sortField: "backlinks",
+ },
billingCustomer,
);
expect(domains.rows).toHaveLength(1);
expect(domains.rows[0]?.spamScore).toBe(2);
+ expect(domains.hasMore).toBe(false);
expect(pages.rows).toHaveLength(1);
});
@@ -174,22 +274,29 @@ it("does not fall back to target spam score for referring domains", async () =>
displayTarget: "example.com",
scope: "domain",
});
- referringDomainsMock.mockResolvedValue([
- {
- domain: "source.example",
- backlinks: 4,
- referring_pages: 2,
- rank: 65,
- first_seen: "2026-01-01",
- broken_backlinks: 0,
- broken_pages: 0,
- backlinks_spam_score: null,
- target_spam_score: 4,
- },
- ]);
+ referringDomainsMock.mockResolvedValue({
+ items: [
+ {
+ domain: "source.example",
+ backlinks: 4,
+ referring_pages: 2,
+ rank: 65,
+ first_seen: "2026-01-01",
+ broken_backlinks: 0,
+ broken_pages: 0,
+ backlinks_spam_score: null,
+ target_spam_score: 4,
+ },
+ ],
+ totalCount: 1,
+ });
- const domains = await service.profileReferringDomains(
- { target: "example.com" },
+ const domains = await service.profileReferringDomainsPage(
+ {
+ ...pageInputDefaults,
+ target: "example.com",
+ sortField: "backlinks",
+ },
billingCustomer,
);
@@ -197,39 +304,33 @@ it("does not fall back to target spam score for referring domains", async () =>
expect(domains.rows[0]?.spamScore).toBeNull();
});
-it("keeps cache entries isolated per organization", async () => {
+it("keeps page cache entries isolated per page and per organization", async () => {
vi.mocked(normalizeBacklinksTarget).mockReturnValue({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
- backlinksSummaryMock.mockResolvedValue({
- rank: 42,
- backlinks: 1200,
- referring_pages: 900,
- referring_domains: 320,
- broken_backlinks: 12,
- broken_pages: 3,
- backlinks_spam_score: 5,
- info: { target_spam_score: 4 },
- new_backlinks: 25,
- lost_backlinks: 10,
- new_referring_domains: 8,
- lost_referring_domains: 2,
- });
- backlinksRowsMock.mockResolvedValue([]);
- backlinksHistoryMock.mockResolvedValue([]);
+ backlinksRowsMock.mockResolvedValue({ items: [], totalCount: 0 });
- const input = { target: "example.com" };
+ const input = {
+ ...pageInputDefaults,
+ target: "example.com",
+ sortField: "rank",
+ } as const;
- await service.profileOverview(input, billingCustomer);
- await service.profileOverview(input, {
+ await service.profileBacklinksPage(input, billingCustomer);
+ await service.profileBacklinksPage(input, billingCustomer);
+ expect(backlinksRowsMock).toHaveBeenCalledTimes(1);
+
+ await service.profileBacklinksPage({ ...input, page: 2 }, billingCustomer);
+ expect(backlinksRowsMock).toHaveBeenCalledTimes(2);
+
+ await service.profileBacklinksPage(input, {
organizationId: "org_456",
userId: "user_456",
userEmail: "other@example.com",
});
-
- expect(backlinksSummaryMock).toHaveBeenCalledTimes(2);
+ expect(backlinksRowsMock).toHaveBeenCalledTimes(3);
});
function parseCachedValue(raw: string): unknown {
diff --git a/src/server/features/backlinks/services/BacklinksService.ts b/src/server/features/backlinks/services/BacklinksService.ts
index 37d40c9..5bdb604 100644
--- a/src/server/features/backlinks/services/BacklinksService.ts
+++ b/src/server/features/backlinks/services/BacklinksService.ts
@@ -2,37 +2,63 @@ import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
import { normalizeBacklinksTarget } from "@/server/lib/dataforseo";
import {
normalizeBacklinksSpamFilterOptions,
+ type BacklinksLookupInput,
type BacklinksSpamFilterOptions,
} from "@/types/schemas/backlinks";
import {
profileBacklinksOverview,
- profileReferringDomainsRows,
- profileTopPagesRows,
+ profileBacklinksRowsPage,
+ profileReferringDomainsPage,
+ profileTopPagesPage,
type BacklinksCache,
+ type BacklinksRowsPageServiceInput,
+ type ReferringDomainsPageServiceInput,
+ type TopPagesPageServiceInput,
} from "@/server/features/backlinks/services/backlinksServiceData";
import type { BillingCustomerContext } from "@/server/billing/subscription";
-import type { BacklinksLookupInput } from "@/types/schemas/backlinks";
const defaultCache: BacklinksCache = {
get: getCached,
set: setCached,
};
+type BacklinksPageCacheInput = {
+ target: string;
+ scope?: "domain" | "page";
+ page: number;
+ pageSize: number;
+ sortField: string;
+ sortOrder: string;
+ filters: Record;
+ /** Backlinks rows only: DataForSEO result grouping. */
+ mode?: string;
+};
+
function createBacklinksService(cache: BacklinksCache = defaultCache) {
return {
async profileOverview(
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
+ ) {
+ const cacheKey = await buildCacheKey("backlinks:overview", {
+ ...buildTargetCacheInput(input, billingCustomer),
+ });
+
+ return profileBacklinksOverview(cache, cacheKey, input, billingCustomer);
+ },
+ async profileBacklinksPage(
+ input: BacklinksRowsPageServiceInput,
+ billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions,
) {
- const cacheKey = await buildBacklinksCacheKey(
- "backlinks:overview",
+ const cacheKey = await buildPageCacheKey(
+ "backlinks:rows-page",
input,
billingCustomer,
options,
);
- return profileBacklinksOverview(
+ return profileBacklinksRowsPage(
cache,
cacheKey,
input,
@@ -40,19 +66,19 @@ function createBacklinksService(cache: BacklinksCache = defaultCache) {
options,
);
},
- async profileReferringDomains(
- input: BacklinksLookupInput,
+ async profileReferringDomainsPage(
+ input: ReferringDomainsPageServiceInput,
billingCustomer: BillingCustomerContext,
options?: BacklinksSpamFilterOptions,
) {
- const cacheKey = await buildBacklinksCacheKey(
- "backlinks:referring-domains",
+ const cacheKey = await buildPageCacheKey(
+ "backlinks:referring-domains-page",
input,
billingCustomer,
options,
);
- return profileReferringDomainsRows(
+ return profileReferringDomainsPage(
cache,
cacheKey,
input,
@@ -60,44 +86,52 @@ function createBacklinksService(cache: BacklinksCache = defaultCache) {
options,
);
},
- async profileTopPages(
- input: BacklinksLookupInput,
+ async profileTopPagesPage(
+ input: TopPagesPageServiceInput,
billingCustomer: BillingCustomerContext,
) {
- const cacheKey = await buildBacklinksCacheKey(
- "backlinks:top-pages",
+ const cacheKey = await buildPageCacheKey(
+ "backlinks:top-pages-page",
input,
billingCustomer,
);
- return profileTopPagesRows(cache, cacheKey, input, billingCustomer);
+ return profileTopPagesPage(cache, cacheKey, input, billingCustomer);
},
} as const;
}
-async function buildBacklinksCacheKey(
- prefix: string,
+function buildTargetCacheInput(
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
- options?: BacklinksSpamFilterOptions,
-): Promise {
+) {
const normalizedTarget = normalizeBacklinksTarget(input.target, {
scope: input.scope,
});
- const cacheKeyInput = {
+
+ return {
organizationId: billingCustomer.organizationId,
target: normalizedTarget.apiTarget,
scope: normalizedTarget.scope,
};
+}
- if (!options) {
- return buildCacheKey(prefix, cacheKeyInput);
- }
-
+async function buildPageCacheKey(
+ prefix: string,
+ input: BacklinksPageCacheInput,
+ billingCustomer: BillingCustomerContext,
+ options?: BacklinksSpamFilterOptions,
+): Promise {
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(options);
return buildCacheKey(prefix, {
- ...cacheKeyInput,
+ ...buildTargetCacheInput(input, billingCustomer),
+ page: input.page,
+ pageSize: input.pageSize,
+ sortField: input.sortField,
+ sortOrder: input.sortOrder,
+ filters: input.filters,
+ ...(input.mode ? { mode: input.mode } : {}),
hideSpam: String(spamFilterOptions.hideSpam),
...(spamFilterOptions.hideSpam
? { spamThreshold: String(spamFilterOptions.spamThreshold) }
diff --git a/src/server/features/backlinks/services/backlinksApiFilters.test.ts b/src/server/features/backlinks/services/backlinksApiFilters.test.ts
new file mode 100644
index 0000000..60c819d
--- /dev/null
+++ b/src/server/features/backlinks/services/backlinksApiFilters.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, it } from "vitest";
+import {
+ countFilterConditions,
+ toBacklinksFiltersPayload,
+ type BacklinksTabFilterValues,
+ EMPTY_BACKLINKS_FILTERS,
+} from "@/client/features/backlinks/backlinksFilterTypes";
+import {
+ buildBacklinksRowsApiFilters,
+ buildReferringDomainsApiFilters,
+ buildTopPagesApiFilters,
+} from "./backlinksApiFilters";
+
+describe("buildBacklinksRowsApiFilters", () => {
+ it("ORs include terms in one group and ANDs everything else", () => {
+ expect(
+ buildBacklinksRowsApiFilters({
+ include: "blog, news",
+ exclude: "spam",
+ minDomainRank: 30,
+ linkType: "dofollow",
+ hideLost: true,
+ }),
+ ).toEqual([
+ [["url_from", "ilike", "%blog%"], "or", ["url_from", "ilike", "%news%"]],
+ "and",
+ ["url_from", "not_ilike", "%spam%"],
+ "and",
+ ["domain_from_rank", ">=", 30],
+ "and",
+ ["dofollow", "=", true],
+ "and",
+ ["is_lost", "=", false],
+ ]);
+ });
+
+ it("emits a single include term as a plain condition", () => {
+ expect(buildBacklinksRowsApiFilters({ include: "blog" })).toEqual([
+ ["url_from", "ilike", "%blog%"],
+ ]);
+ });
+
+ it("escapes LIKE wildcards in terms", () => {
+ expect(buildBacklinksRowsApiFilters({ include: "wp_content" })).toEqual([
+ ["url_from", "ilike", "%wp\\_content%"],
+ ]);
+ });
+
+ it("returns no expressions for empty filters", () => {
+ expect(buildBacklinksRowsApiFilters({})).toEqual([]);
+ });
+
+ it("throws when the condition budget is exceeded", () => {
+ expect(() =>
+ buildBacklinksRowsApiFilters({
+ include: "a, b, c, d, e",
+ exclude: "f, g, h, i",
+ }),
+ ).toThrowError(/Too many filter conditions/);
+ });
+});
+
+describe("buildReferringDomainsApiFilters", () => {
+ it("filters on referring-domain fields", () => {
+ expect(
+ buildReferringDomainsApiFilters({
+ include: "edu",
+ minBacklinks: 5,
+ maxSpamScore: 30,
+ }),
+ ).toEqual([
+ ["domain", "ilike", "%edu%"],
+ "and",
+ ["backlinks", ">=", 5],
+ "and",
+ ["backlinks_spam_score", "<=", 30],
+ ]);
+ });
+});
+
+describe("buildTopPagesApiFilters", () => {
+ it("filters on the url field", () => {
+ expect(
+ buildTopPagesApiFilters({ include: "/blog", minReferringDomains: 2 }),
+ ).toEqual([
+ ["url", "ilike", "%/blog%"],
+ "and",
+ ["referring_domains", ">=", 2],
+ ]);
+ });
+});
+
+describe("client condition count vs server condition budget", () => {
+ // The client gates Apply with countFilterConditions; the server enforces the
+ // DataForSEO budget while building. They must agree on how many conditions a
+ // set of filter values produces, or users get hard errors the UI accepted.
+ function serverConditionCount(values: BacklinksTabFilterValues): number {
+ const expressions = buildBacklinksRowsApiFilters(
+ toBacklinksFiltersPayload(values),
+ );
+ let count = 0;
+ for (const expression of expressions) {
+ if (expression === "and") continue;
+ // An include OR-group contains nested clauses and "or" connectors.
+ count +=
+ Array.isArray(expression) && Array.isArray(expression[0])
+ ? Math.ceil(expression.length / 2)
+ : 1;
+ }
+ return count;
+ }
+
+ const cases: Array<[string, BacklinksTabFilterValues]> = [
+ ["empty", { ...EMPTY_BACKLINKS_FILTERS }],
+ [
+ "terms and ranges",
+ {
+ ...EMPTY_BACKLINKS_FILTERS,
+ include: "blog, news",
+ exclude: "spam",
+ minDomainRank: "30",
+ maxSpamScore: "50",
+ },
+ ],
+ [
+ "toggles",
+ {
+ ...EMPTY_BACKLINKS_FILTERS,
+ linkType: "nofollow",
+ hideLost: "true",
+ hideBroken: "true",
+ },
+ ],
+ [
+ "everything",
+ {
+ ...EMPTY_BACKLINKS_FILTERS,
+ include: "a",
+ exclude: "b",
+ minDomainRank: "1",
+ maxDomainRank: "90",
+ minLinkAuthority: "2",
+ linkType: "dofollow",
+ hideLost: "true",
+ },
+ ],
+ ];
+
+ it.each(cases)("matches for %s", (_name, values) => {
+ expect(serverConditionCount(values)).toBe(countFilterConditions(values));
+ });
+});
diff --git a/src/server/features/backlinks/services/backlinksApiFilters.ts b/src/server/features/backlinks/services/backlinksApiFilters.ts
new file mode 100644
index 0000000..6a19b54
--- /dev/null
+++ b/src/server/features/backlinks/services/backlinksApiFilters.ts
@@ -0,0 +1,179 @@
+import {
+ assertFilterConditionBudget,
+ buildIncludeOrGroup,
+ collectNumericRange,
+ escapeLikeTerm,
+ joinClauses,
+ parseFilterTerms,
+ type FilterClause,
+} from "@/server/lib/dataforseo/filters";
+import type {
+ BacklinksRowsFilters,
+ BacklinksRowsSortField,
+ BacklinksSortOrder,
+ ReferringDomainsFilters,
+ ReferringDomainsSortField,
+ TopPagesFilters,
+ TopPagesSortField,
+} from "@/types/schemas/backlinks";
+
+const BACKLINKS_ROWS_SORT_FIELDS: Record = {
+ rank: "rank",
+ domainRank: "domain_from_rank",
+ spamScore: "backlink_spam_score",
+ firstSeen: "first_seen",
+};
+
+const REFERRING_DOMAINS_SORT_FIELDS: Record =
+ {
+ domain: "domain",
+ backlinks: "backlinks",
+ referringPages: "referring_pages",
+ rank: "rank",
+ spamScore: "backlinks_spam_score",
+ firstSeen: "first_seen",
+ brokenBacklinks: "broken_backlinks",
+ };
+
+const TOP_PAGES_SORT_FIELDS: Record = {
+ backlinks: "backlinks",
+ referringDomains: "referring_domains",
+ rank: "rank",
+ brokenBacklinks: "broken_backlinks",
+};
+
+export function buildBacklinksRowsOrderBy(
+ field: BacklinksRowsSortField,
+ order: BacklinksSortOrder,
+): string[] {
+ return [`${BACKLINKS_ROWS_SORT_FIELDS[field]},${order}`];
+}
+
+export function buildReferringDomainsOrderBy(
+ field: ReferringDomainsSortField,
+ order: BacklinksSortOrder,
+): string[] {
+ return [`${REFERRING_DOMAINS_SORT_FIELDS[field]},${order}`];
+}
+
+export function buildTopPagesOrderBy(
+ field: TopPagesSortField,
+ order: BacklinksSortOrder,
+): string[] {
+ return [`${TOP_PAGES_SORT_FIELDS[field]},${order}`];
+}
+
+/**
+ * Translates the Backlinks tab filters into DataForSEO filter expressions.
+ * Include/exclude terms match the source URL (which contains the linking
+ * domain): include terms are OR'd (match any), exclude terms AND'd (drop all).
+ */
+export function buildBacklinksRowsApiFilters(
+ filters: BacklinksRowsFilters,
+): unknown[] {
+ const conditions: FilterClause[] = [];
+
+ collectExcludeConditions(conditions, "url_from", filters.exclude);
+ collectNumericRange(
+ conditions,
+ "domain_from_rank",
+ filters.minDomainRank,
+ filters.maxDomainRank,
+ );
+ collectNumericRange(
+ conditions,
+ "rank",
+ filters.minLinkAuthority,
+ filters.maxLinkAuthority,
+ );
+ collectNumericRange(
+ conditions,
+ "backlink_spam_score",
+ filters.minSpamScore,
+ filters.maxSpamScore,
+ );
+ if (filters.linkType) {
+ conditions.push(["dofollow", "=", filters.linkType === "dofollow"]);
+ }
+ if (filters.hideLost) {
+ conditions.push(["is_lost", "=", false]);
+ }
+ if (filters.hideBroken) {
+ conditions.push(["is_broken", "=", false]);
+ }
+ if (filters.domainFrom) {
+ conditions.push(["domain_from", "=", filters.domainFrom]);
+ }
+
+ return finishFilters("url_from", filters.include, conditions);
+}
+
+export function buildReferringDomainsApiFilters(
+ filters: ReferringDomainsFilters,
+): unknown[] {
+ const conditions: FilterClause[] = [];
+
+ collectExcludeConditions(conditions, "domain", filters.exclude);
+ collectNumericRange(
+ conditions,
+ "backlinks",
+ filters.minBacklinks,
+ filters.maxBacklinks,
+ );
+ collectNumericRange(conditions, "rank", filters.minRank, filters.maxRank);
+ collectNumericRange(
+ conditions,
+ "backlinks_spam_score",
+ filters.minSpamScore,
+ filters.maxSpamScore,
+ );
+
+ return finishFilters("domain", filters.include, conditions);
+}
+
+export function buildTopPagesApiFilters(filters: TopPagesFilters): unknown[] {
+ const conditions: FilterClause[] = [];
+
+ collectExcludeConditions(conditions, "url", filters.exclude);
+ collectNumericRange(
+ conditions,
+ "backlinks",
+ filters.minBacklinks,
+ filters.maxBacklinks,
+ );
+ collectNumericRange(
+ conditions,
+ "referring_domains",
+ filters.minReferringDomains,
+ filters.maxReferringDomains,
+ );
+ collectNumericRange(conditions, "rank", filters.minRank, filters.maxRank);
+
+ return finishFilters("url", filters.include, conditions);
+}
+
+function collectExcludeConditions(
+ out: FilterClause[],
+ field: string,
+ exclude: string | undefined,
+) {
+ for (const term of parseFilterTerms(exclude)) {
+ out.push([field, "not_ilike", `%${escapeLikeTerm(term)}%`]);
+ }
+}
+
+/** Prepends the OR'd include group, enforces the condition budget, joins with "and". */
+function finishFilters(
+ includeField: string,
+ include: string | undefined,
+ conditions: FilterClause[],
+): unknown[] {
+ const includeGroup = buildIncludeOrGroup(includeField, include);
+ assertFilterConditionBudget(
+ conditions.length + (includeGroup?.conditionCount ?? 0),
+ );
+ return joinClauses(
+ includeGroup ? [includeGroup.clause, ...conditions] : conditions,
+ "and",
+ );
+}
diff --git a/src/server/features/backlinks/services/backlinksOverviewSchema.ts b/src/server/features/backlinks/services/backlinksOverviewSchema.ts
index c656860..071445a 100644
--- a/src/server/features/backlinks/services/backlinksOverviewSchema.ts
+++ b/src/server/features/backlinks/services/backlinksOverviewSchema.ts
@@ -19,7 +19,7 @@ const backlinksRowSchema = z.object({
linksCount: z.number().nullable(),
});
-export const referringDomainRowSchema = z.object({
+const referringDomainRowSchema = z.object({
domain: z.string().nullable(),
backlinks: z.number().nullable(),
referringPages: z.number().nullable(),
@@ -30,7 +30,7 @@ export const referringDomainRowSchema = z.object({
brokenPages: z.number().nullable(),
});
-export const topPageRowSchema = z.object({
+const topPageRowSchema = z.object({
page: z.string().nullable(),
backlinks: z.number().nullable(),
referringDomains: z.number().nullable(),
@@ -71,12 +71,35 @@ export const backlinksOverviewSchema = z.object({
newReferringDomains: z.number().nullable(),
lostReferringDomains: z.number().nullable(),
}),
- backlinks: z.array(backlinksRowSchema),
- referringDomains: z.array(referringDomainRowSchema),
- topPages: z.array(topPageRowSchema),
trends: z.array(backlinksTrendRowSchema),
newLostTrends: z.array(backlinksNewLostTrendRowSchema),
fetchedAt: z.string(),
});
export type BacklinksOverviewResult = z.infer;
+
+function buildPageResultSchema(rowSchema: T) {
+ return z.object({
+ rows: z.array(rowSchema),
+ totalCount: z.number().nullable(),
+ hasMore: z.boolean(),
+ page: z.number(),
+ pageSize: z.number(),
+ fetchedAt: z.string(),
+ });
+}
+
+export const backlinksRowsPageResultSchema =
+ buildPageResultSchema(backlinksRowSchema);
+export const referringDomainsPageResultSchema = buildPageResultSchema(
+ referringDomainRowSchema,
+);
+export const topPagesPageResultSchema = buildPageResultSchema(topPageRowSchema);
+
+export type BacklinksRowsPageResult = z.infer<
+ typeof backlinksRowsPageResultSchema
+>;
+export type ReferringDomainsPageResult = z.infer<
+ typeof referringDomainsPageResultSchema
+>;
+export type TopPagesPageResult = z.infer;
diff --git a/src/server/features/backlinks/services/backlinksServiceData.ts b/src/server/features/backlinks/services/backlinksServiceData.ts
index 6157c84..0927a9b 100644
--- a/src/server/features/backlinks/services/backlinksServiceData.ts
+++ b/src/server/features/backlinks/services/backlinksServiceData.ts
@@ -9,21 +9,52 @@ import {
type DomainPageSummaryItem,
type ReferringDomainItem,
} from "@/server/lib/dataforseo";
-import {
- normalizeBacklinksSpamFilterOptions,
- type BacklinksSpamFilterOptions,
+import type {
+ BacklinksLookupInput,
+ BacklinksRowsPageInput,
+ BacklinksSpamFilterOptions,
+ ReferringDomainsPageInput,
+ TopPagesPageInput,
} from "@/types/schemas/backlinks";
+
import {
backlinksOverviewSchema,
- referringDomainRowSchema,
- topPageRowSchema,
+ backlinksRowsPageResultSchema,
+ referringDomainsPageResultSchema,
+ topPagesPageResultSchema,
type BacklinksOverviewResult,
+ type BacklinksRowsPageResult,
+ type ReferringDomainsPageResult,
+ type TopPagesPageResult,
} from "@/server/features/backlinks/services/backlinksOverviewSchema";
-import type { BacklinksLookupInput } from "@/types/schemas/backlinks";
+import {
+ buildBacklinksRowsApiFilters,
+ buildBacklinksRowsOrderBy,
+ buildReferringDomainsApiFilters,
+ buildReferringDomainsOrderBy,
+ buildTopPagesApiFilters,
+ buildTopPagesOrderBy,
+} from "@/server/features/backlinks/services/backlinksApiFilters";
+
+// The page-request schemas carry projectId for the web middleware; the
+// service layer is organization-scoped and never reads it.
+export type BacklinksRowsPageServiceInput = Omit<
+ BacklinksRowsPageInput,
+ "projectId"
+>;
+export type ReferringDomainsPageServiceInput = Omit<
+ ReferringDomainsPageInput,
+ "projectId"
+>;
+export type TopPagesPageServiceInput = Omit;
const BACKLINKS_OVERVIEW_TTL_SECONDS = 6 * 60 * 60;
const BACKLINKS_TAB_TTL_SECONDS = 6 * 60 * 60;
+const backlinksOverviewCacheSchema = z.object({
+ overview: backlinksOverviewSchema,
+});
+
export type BacklinksCache = {
get(key: string): Promise;
set(key: string, data: unknown, ttlSeconds: number): Promise;
@@ -33,24 +64,6 @@ type BacklinksOverviewProfile = {
overview: BacklinksOverviewResult;
};
-type ReferringDomainsProfile = {
- rows: BacklinksOverviewResult["referringDomains"];
-};
-
-type TopPagesProfile = {
- rows: BacklinksOverviewResult["topPages"];
-};
-
-const backlinksOverviewCacheSchema = z.object({
- overview: backlinksOverviewSchema,
-});
-
-const referringDomainsCacheSchema = z.object({
- rows: z.array(referringDomainRowSchema),
-});
-
-const topPagesCacheSchema = z.object({ rows: z.array(topPageRowSchema) });
-
type BacklinksDateRange = {
dateFrom: string;
dateTo: string;
@@ -61,10 +74,10 @@ export async function profileBacklinksOverview(
cacheKey: string,
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
- options?: BacklinksSpamFilterOptions,
): Promise {
- const cachedRaw = await cache.get(cacheKey);
- const cached = backlinksOverviewCacheSchema.safeParse(cachedRaw);
+ const cached = backlinksOverviewCacheSchema.safeParse(
+ await cache.get(cacheKey),
+ );
if (cached.success) {
return {
overview: cached.data.overview,
@@ -77,16 +90,10 @@ export async function profileBacklinksOverview(
const normalizedTarget = normalizeBacklinksTarget(input.target, {
scope: input.scope,
});
- const request = buildBacklinksListRequest(
- normalizedTarget.apiTarget,
- 100,
- options,
- );
const dateRange = buildBacklinksDateRange(now);
- const [summary, backlinks, history] = await Promise.all([
- dataforseo.backlinks.summary({ target: request.target }),
- dataforseo.backlinks.rows(request),
+ const [summary, history] = await Promise.all([
+ dataforseo.backlinks.summary({ target: normalizedTarget.apiTarget }),
normalizedTarget.scope === "domain"
? dataforseo.backlinks.history({
target: normalizedTarget.apiTarget,
@@ -99,7 +106,6 @@ export async function profileBacklinksOverview(
normalizedTarget,
now,
summary,
- backlinks,
history,
});
await cacheValue(
@@ -112,76 +118,131 @@ export async function profileBacklinksOverview(
return { overview };
}
-export async function profileReferringDomainsRows(
+export async function profileBacklinksRowsPage(
cache: BacklinksCache,
cacheKey: string,
- input: BacklinksLookupInput,
+ input: BacklinksRowsPageServiceInput,
billingCustomer: BillingCustomerContext,
- options?: BacklinksSpamFilterOptions,
-): Promise {
- const cachedRaw = await cache.get(cacheKey);
- const cached = referringDomainsCacheSchema.safeParse(cachedRaw);
- if (cached.success) {
- return {
- rows: cached.data.rows,
- };
- }
-
- const dataforseo = createDataforseoClient(billingCustomer);
-
- const request = buildBacklinksListRequest(
- normalizeBacklinksTarget(input.target, { scope: input.scope }).apiTarget,
- 100,
- options,
+ spamOptions?: BacklinksSpamFilterOptions,
+): Promise {
+ const cached = backlinksRowsPageResultSchema.safeParse(
+ await cache.get(cacheKey),
);
- const response = await dataforseo.backlinks.referringDomains(request);
- const rows = mapReferringDomainsRows(response);
-
- await cacheValue(cache, cacheKey, { rows }, BACKLINKS_TAB_TTL_SECONDS);
-
- return { rows };
-}
-
-export async function profileTopPagesRows(
- cache: BacklinksCache,
- cacheKey: string,
- input: BacklinksLookupInput,
- billingCustomer: BillingCustomerContext,
-): Promise {
- const cachedRaw = await cache.get(cacheKey);
- const cached = topPagesCacheSchema.safeParse(cachedRaw);
if (cached.success) {
- return {
- rows: cached.data.rows,
- };
+ return cached.data;
}
const dataforseo = createDataforseoClient(billingCustomer);
+ const offset = (input.page - 1) * input.pageSize;
+ const filters = buildBacklinksRowsApiFilters(input.filters);
- const request = {
+ const response = await dataforseo.backlinks.rows({
target: normalizeBacklinksTarget(input.target, { scope: input.scope })
.apiTarget,
- };
- const response = await dataforseo.backlinks.domainPages({
- ...request,
- limit: 100,
+ limit: input.pageSize,
+ offset,
+ orderBy: buildBacklinksRowsOrderBy(input.sortField, input.sortOrder),
+ filters: filters.length > 0 ? filters : undefined,
+ mode: input.mode,
+ ...spamOptions,
});
- const rows = mapTopPagesRows(response);
- await cacheValue(cache, cacheKey, { rows }, BACKLINKS_TAB_TTL_SECONDS);
+ const result = buildPageResult(input, offset, {
+ rows: mapBacklinksRows(response.items),
+ totalCount: response.totalCount,
+ });
+ await cacheValue(cache, cacheKey, result, BACKLINKS_TAB_TTL_SECONDS);
- return { rows };
+ return result;
}
-function buildBacklinksListRequest(
- target: string,
- limit: number,
- options?: BacklinksSpamFilterOptions,
+export async function profileReferringDomainsPage(
+ cache: BacklinksCache,
+ cacheKey: string,
+ input: ReferringDomainsPageServiceInput,
+ billingCustomer: BillingCustomerContext,
+ spamOptions?: BacklinksSpamFilterOptions,
+): Promise {
+ const cached = referringDomainsPageResultSchema.safeParse(
+ await cache.get(cacheKey),
+ );
+ if (cached.success) {
+ return cached.data;
+ }
+
+ const dataforseo = createDataforseoClient(billingCustomer);
+ const offset = (input.page - 1) * input.pageSize;
+ const filters = buildReferringDomainsApiFilters(input.filters);
+
+ const response = await dataforseo.backlinks.referringDomains({
+ target: normalizeBacklinksTarget(input.target, { scope: input.scope })
+ .apiTarget,
+ limit: input.pageSize,
+ offset,
+ orderBy: buildReferringDomainsOrderBy(input.sortField, input.sortOrder),
+ filters: filters.length > 0 ? filters : undefined,
+ ...spamOptions,
+ });
+
+ const result = buildPageResult(input, offset, {
+ rows: mapReferringDomainsRows(response.items),
+ totalCount: response.totalCount,
+ });
+ await cacheValue(cache, cacheKey, result, BACKLINKS_TAB_TTL_SECONDS);
+
+ return result;
+}
+
+export async function profileTopPagesPage(
+ cache: BacklinksCache,
+ cacheKey: string,
+ input: TopPagesPageServiceInput,
+ billingCustomer: BillingCustomerContext,
+): Promise {
+ const cached = topPagesPageResultSchema.safeParse(await cache.get(cacheKey));
+ if (cached.success) {
+ return cached.data;
+ }
+
+ const dataforseo = createDataforseoClient(billingCustomer);
+ const offset = (input.page - 1) * input.pageSize;
+ const filters = buildTopPagesApiFilters(input.filters);
+
+ const response = await dataforseo.backlinks.domainPages({
+ target: normalizeBacklinksTarget(input.target, { scope: input.scope })
+ .apiTarget,
+ limit: input.pageSize,
+ offset,
+ orderBy: buildTopPagesOrderBy(input.sortField, input.sortOrder),
+ filters: filters.length > 0 ? filters : undefined,
+ });
+
+ const result = buildPageResult(input, offset, {
+ rows: mapTopPagesRows(response.items),
+ totalCount: response.totalCount,
+ });
+ await cacheValue(cache, cacheKey, result, BACKLINKS_TAB_TTL_SECONDS);
+
+ return result;
+}
+
+function buildPageResult(
+ input: { page: number; pageSize: number },
+ offset: number,
+ data: { rows: TRow[]; totalCount: number | null },
) {
+ const hasMore =
+ data.totalCount != null
+ ? offset + data.rows.length < data.totalCount
+ : data.rows.length === input.pageSize;
+
return {
- target,
- limit,
- ...normalizeBacklinksSpamFilterOptions(options),
+ rows: data.rows,
+ totalCount: data.totalCount,
+ hasMore,
+ page: input.page,
+ pageSize: input.pageSize,
+ fetchedAt: new Date().toISOString(),
};
}
@@ -205,7 +266,6 @@ function buildOverviewResult(args: {
normalizedTarget: ReturnType;
now: Date;
summary: BacklinksSummaryItem;
- backlinks: BacklinksItem[];
history: BacklinksHistoryItem[];
}): BacklinksOverviewResult {
const historyRows = args.history
@@ -253,9 +313,6 @@ function buildOverviewResult(args: {
args.summary.lost_reffering_domains ??
null,
},
- backlinks: mapBacklinksRows(args.backlinks),
- referringDomains: [],
- topPages: [],
trends: historyRows.map((item) => ({
date: item.date,
backlinks: item.backlinks,
diff --git a/src/server/features/domain/services/domainKeywordFilters.ts b/src/server/features/domain/services/domainKeywordFilters.ts
index 778f061..e54bb70 100644
--- a/src/server/features/domain/services/domainKeywordFilters.ts
+++ b/src/server/features/domain/services/domainKeywordFilters.ts
@@ -1,8 +1,12 @@
import {
- MAX_DATAFORSEO_FILTER_CONDITIONS,
- type DomainKeywordsFilters,
-} from "@/types/schemas/domain";
-import { AppError } from "@/server/lib/errors";
+ assertFilterConditionBudget,
+ collectNumericRange,
+ escapeLikeTerm,
+ joinClauses,
+ parseFilterTerms,
+ type FilterClause,
+} from "@/server/lib/dataforseo/filters";
+import type { DomainKeywordsFilters } from "@/types/schemas/domain";
export type DomainKeywordsSortMode =
| "rank"
@@ -27,62 +31,26 @@ export function buildOrderBy(
return [`${SORT_FIELD_BY_MODE[sortMode]},${sortOrder}`];
}
-function escapeLikeTerm(term: string): string {
- return term.replace(/[\\%_]/g, (match) => `\\${match}`);
-}
-
-function pushAnd(filters: unknown[], expression: Clause) {
- if (filters.length > 0) filters.push("and");
- filters.push(expression);
-}
-
-function parseTerms(value: string | undefined): string[] {
- if (!value) return [];
- return value
- .toLowerCase()
- .split(/[,+]/)
- .map((term) => term.trim())
- .filter(Boolean);
-}
-
-function collectNumericRange(
- out: Clause[],
- field: string,
- min: number | undefined,
- max: number | undefined,
-) {
- if (typeof min === "number" && Number.isFinite(min)) {
- out.push([field, ">=", min]);
- }
- if (typeof max === "number" && Number.isFinite(max)) {
- out.push([field, "<=", max]);
- }
-}
-
/**
- * DataForSEO accepts up to 8 filter conditions per request joined by
- * "and"/"or" operators. Each include/exclude term is one ilike clause;
- * numeric ranges add one per bound; the free-text search term adds one OR-
- * group of two (keyword OR url). The client surfaces the same condition
- * count and disables Apply when over budget, so reaching the cap here
- * indicates a misbehaving client — we throw rather than silently truncate.
+ * Each include/exclude term is one ilike clause; numeric ranges add one per
+ * bound; the free-text search term adds one OR-group of two (keyword OR url).
+ * The client surfaces the same condition count and disables Apply when over
+ * the DataForSEO budget.
*/
-type Clause = unknown[];
-
export function buildKeywordFilters(
filters: DomainKeywordsFilters,
searchTerm?: string,
): unknown[] {
- const conditions: Clause[] = [];
+ const conditions: FilterClause[] = [];
- for (const term of parseTerms(filters.include)) {
+ for (const term of parseFilterTerms(filters.include)) {
conditions.push([
"keyword_data.keyword",
"ilike",
`%${escapeLikeTerm(term)}%`,
]);
}
- for (const term of parseTerms(filters.exclude)) {
+ for (const term of parseFilterTerms(filters.exclude)) {
conditions.push([
"keyword_data.keyword",
"not_ilike",
@@ -125,21 +93,15 @@ export function buildKeywordFilters(
const searchGroup = trimmedSearch ? buildSearchGroup(trimmedSearch) : null;
// The search OR-group costs 2 slots; everything else is 1.
- const totalConditions = conditions.length + (searchGroup ? 2 : 0);
- if (totalConditions > MAX_DATAFORSEO_FILTER_CONDITIONS) {
- throw new AppError(
- "VALIDATION_ERROR",
- `Too many filter conditions (${totalConditions} of ${MAX_DATAFORSEO_FILTER_CONDITIONS} max).`,
- );
- }
+ assertFilterConditionBudget(conditions.length + (searchGroup ? 2 : 0));
- const expressions: unknown[] = [];
- for (const clause of conditions) pushAnd(expressions, clause);
- if (searchGroup) pushAnd(expressions, searchGroup);
- return expressions;
+ return joinClauses(
+ searchGroup ? [...conditions, searchGroup] : conditions,
+ "and",
+ );
}
-function buildSearchGroup(term: string): Clause {
+function buildSearchGroup(term: string): FilterClause {
const escaped = escapeLikeTerm(term);
return [
["keyword_data.keyword", "ilike", `%${escaped}%`],
diff --git a/src/server/lib/dataforseo/backlinks.test.ts b/src/server/lib/dataforseo/backlinks.test.ts
index 9b976af..585ffbd 100644
--- a/src/server/lib/dataforseo/backlinks.test.ts
+++ b/src/server/lib/dataforseo/backlinks.test.ts
@@ -209,7 +209,7 @@ describe("fetchBacklinksSummary", () => {
await expect(
fetchBacklinksRows({ target: "example.com" }),
- ).resolves.toMatchObject({ data: [] });
+ ).resolves.toMatchObject({ data: { items: [], totalCount: null } });
await expect(
fetchBacklinksHistory({
target: "example.com",
diff --git a/src/server/lib/dataforseo/backlinks.ts b/src/server/lib/dataforseo/backlinks.ts
index cd8d402..ea4491b 100644
--- a/src/server/lib/dataforseo/backlinks.ts
+++ b/src/server/lib/dataforseo/backlinks.ts
@@ -17,6 +17,7 @@ import {
assertOk,
buildTaskBilling,
parseTaskItems,
+ parseTaskTotalCount,
type DataforseoApiResponse,
} from "@/server/lib/dataforseo/envelope";
@@ -24,7 +25,16 @@ export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget
type BacklinksRequest = { target: string };
type BacklinksListRequest = BacklinksRequest &
- BacklinksSpamFilterOptions & { limit?: number };
+ BacklinksSpamFilterOptions & {
+ limit?: number;
+ offset?: number;
+ /** DataForSEO order_by entries, e.g. ["rank,desc"]. */
+ orderBy?: string[];
+ /** Pre-built DataForSEO filter expressions, already joined with and/or. */
+ filters?: unknown[];
+ /** Result grouping (backlinks list only): "one_per_domain" | "as_is". */
+ mode?: string;
+ };
type BacklinksTimeseriesRequest = {
target: string;
dateFrom: string;
@@ -146,6 +156,24 @@ function buildCommonPayload(input: BacklinksRequest) {
const assertOptions = (path: string) =>
({ classify: classifyBacklinksError, classifyPath: path }) as const;
+/**
+ * Joins caller-provided filter expressions with the spam-score condition.
+ * `userFilters` arrives already and/or-joined, so the spam condition is
+ * appended with a single top-level "and".
+ */
+function combineFilters(
+ userFilters: unknown[] | undefined,
+ spamCondition: unknown[] | undefined,
+): unknown[] | undefined {
+ const merged: unknown[] = [];
+ if (userFilters && userFilters.length > 0) merged.push(...userFilters);
+ if (spamCondition) {
+ if (merged.length > 0) merged.push("and");
+ merged.push(spamCondition);
+ }
+ return merged.length > 0 ? merged : undefined;
+}
+
export async function fetchBacklinksSummary(input: BacklinksRequest) {
const response = await backlinksApi(classifyBacklinksError).summaryLive([
new BacklinksSummaryLiveRequestInfo(buildCommonPayload(input)),
@@ -179,14 +207,19 @@ export async function fetchBacklinksSummary(input: BacklinksRequest) {
export async function fetchBacklinksRows(input: BacklinksListRequest) {
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input);
- const filters = spamFilterOptions.hideSpam
- ? [["backlink_spam_score", "<=", spamFilterOptions.spamThreshold]]
- : undefined;
+ const filters = combineFilters(
+ input.filters,
+ spamFilterOptions.hideSpam
+ ? ["backlink_spam_score", "<=", spamFilterOptions.spamThreshold]
+ : undefined,
+ );
const response = await backlinksApi(classifyBacklinksError).backlinksLive([
new BacklinksBacklinksLiveRequestInfo({
...buildCommonPayload(input),
limit: input.limit ?? 100,
- order_by: ["rank,desc"],
+ offset: input.offset,
+ order_by: input.orderBy ?? ["rank,desc"],
+ mode: input.mode,
...(filters ? { filters } : {}),
}),
]);
@@ -195,23 +228,30 @@ export async function fetchBacklinksRows(input: BacklinksListRequest) {
assertOptions("/v3/backlinks/backlinks/live"),
);
return {
- data: parseTaskItems("backlinks-live", task, backlinksItemSchema),
+ data: {
+ items: parseTaskItems("backlinks-live", task, backlinksItemSchema),
+ totalCount: parseTaskTotalCount(task),
+ },
billing: buildTaskBilling(task),
};
}
export async function fetchReferringDomains(input: BacklinksListRequest) {
const spamFilterOptions = normalizeBacklinksSpamFilterOptions(input);
- const filters = spamFilterOptions.hideSpam
- ? [["backlinks_spam_score", "<=", spamFilterOptions.spamThreshold]]
- : undefined;
+ const filters = combineFilters(
+ input.filters,
+ spamFilterOptions.hideSpam
+ ? ["backlinks_spam_score", "<=", spamFilterOptions.spamThreshold]
+ : undefined,
+ );
const response = await backlinksApi(
classifyBacklinksError,
).referringDomainsLive([
new BacklinksReferringDomainsLiveRequestInfo({
...buildCommonPayload(input),
limit: input.limit ?? 100,
- order_by: ["backlinks,desc"],
+ offset: input.offset,
+ order_by: input.orderBy ?? ["backlinks,desc"],
...(filters ? { filters } : {}),
}),
]);
@@ -220,23 +260,30 @@ export async function fetchReferringDomains(input: BacklinksListRequest) {
assertOptions("/v3/backlinks/referring_domains/live"),
);
return {
- data: parseTaskItems(
- "referring-domains-live",
- task,
- referringDomainItemSchema,
- ),
+ data: {
+ items: parseTaskItems(
+ "referring-domains-live",
+ task,
+ referringDomainItemSchema,
+ ),
+ totalCount: parseTaskTotalCount(task),
+ },
billing: buildTaskBilling(task),
};
}
export async function fetchDomainPagesSummary(input: BacklinksListRequest) {
+ const filters =
+ input.filters && input.filters.length > 0 ? input.filters : undefined;
const response = await backlinksApi(
classifyBacklinksError,
).domainPagesSummaryLive([
new BacklinksDomainPagesSummaryLiveRequestInfo({
...buildCommonPayload(input),
limit: input.limit ?? 100,
- order_by: ["backlinks,desc"],
+ offset: input.offset,
+ order_by: input.orderBy ?? ["backlinks,desc"],
+ ...(filters ? { filters } : {}),
}),
]);
const task = assertOk(
@@ -244,11 +291,14 @@ export async function fetchDomainPagesSummary(input: BacklinksListRequest) {
assertOptions("/v3/backlinks/domain_pages_summary/live"),
);
return {
- data: parseTaskItems(
- "domain-pages-summary-live",
- task,
- domainPageSummaryItemSchema,
- ),
+ data: {
+ items: parseTaskItems(
+ "domain-pages-summary-live",
+ task,
+ domainPageSummaryItemSchema,
+ ),
+ totalCount: parseTaskTotalCount(task),
+ },
billing: buildTaskBilling(task),
};
}
diff --git a/src/server/lib/dataforseo/envelope.ts b/src/server/lib/dataforseo/envelope.ts
index bcb90c8..e572736 100644
--- a/src/server/lib/dataforseo/envelope.ts
+++ b/src/server/lib/dataforseo/envelope.ts
@@ -148,6 +148,13 @@ export function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null;
}
+/** Reads `task.result[0].total_count` for paginated list endpoints. */
+export function parseTaskTotalCount(task: DataforseoTaskLike): number | null {
+ const first = task.result?.[0];
+ if (!isRecord(first)) return null;
+ return typeof first.total_count === "number" ? first.total_count : null;
+}
+
/** Reads `task.result[0].items`, validating against a Zod schema for loosely-typed endpoints. */
export function parseTaskItems(
endpoint: string,
diff --git a/src/server/lib/dataforseo/filters.ts b/src/server/lib/dataforseo/filters.ts
new file mode 100644
index 0000000..035707c
--- /dev/null
+++ b/src/server/lib/dataforseo/filters.ts
@@ -0,0 +1,89 @@
+import { AppError } from "@/server/lib/errors";
+import { MAX_DATAFORSEO_FILTER_CONDITIONS } from "@/types/schemas/domain";
+
+/**
+ * Building blocks for DataForSEO `filters` expressions, shared by the
+ * feature-specific builders (domain keywords, backlinks). A "clause" is one
+ * condition tuple like ["field", "ilike", "%term%"] or a nested group.
+ */
+export type FilterClause = unknown[];
+
+export function escapeLikeTerm(term: string): string {
+ return term.replace(/[\\%_]/g, (match) => `\\${match}`);
+}
+
+/** Splits a comma/plus separated terms string into trimmed lowercase terms. */
+export function parseFilterTerms(value: string | undefined): string[] {
+ if (!value) return [];
+ return value
+ .toLowerCase()
+ .split(/[,+]/)
+ .map((term) => term.trim())
+ .filter(Boolean);
+}
+
+export function collectNumericRange(
+ out: FilterClause[],
+ field: string,
+ min: number | undefined,
+ max: number | undefined,
+) {
+ if (typeof min === "number" && Number.isFinite(min)) {
+ out.push([field, ">=", min]);
+ }
+ if (typeof max === "number" && Number.isFinite(max)) {
+ out.push([field, "<=", max]);
+ }
+}
+
+/**
+ * One ilike condition per include term, joined with "or" into a single nested
+ * group (match-any semantics). Returns the group clause plus how many of the
+ * DataForSEO condition budget it consumes.
+ */
+export function buildIncludeOrGroup(
+ field: string,
+ include: string | undefined,
+): { clause: FilterClause; conditionCount: number } | null {
+ const conditions = parseFilterTerms(include).map((term) => [
+ field,
+ "ilike",
+ `%${escapeLikeTerm(term)}%`,
+ ]);
+ if (conditions.length === 0) return null;
+ const first = conditions[0];
+ if (conditions.length === 1 && first) {
+ return { clause: first, conditionCount: 1 };
+ }
+ return {
+ clause: joinClauses(conditions, "or"),
+ conditionCount: conditions.length,
+ };
+}
+
+/**
+ * DataForSEO accepts up to 8 filter conditions per request. The clients
+ * surface the same condition count and disable Apply when over budget, so
+ * reaching the cap here indicates a misbehaving client — we throw rather
+ * than silently truncate.
+ */
+export function assertFilterConditionBudget(conditionCount: number): void {
+ if (conditionCount > MAX_DATAFORSEO_FILTER_CONDITIONS) {
+ throw new AppError(
+ "VALIDATION_ERROR",
+ `Too many filter conditions (${conditionCount} of ${MAX_DATAFORSEO_FILTER_CONDITIONS} max).`,
+ );
+ }
+}
+
+export function joinClauses(
+ clauses: FilterClause[],
+ operator: "and" | "or",
+): unknown[] {
+ const expressions: unknown[] = [];
+ for (const clause of clauses) {
+ if (expressions.length > 0) expressions.push(operator);
+ expressions.push(clause);
+ }
+ return expressions;
+}
diff --git a/src/server/mcp/tools/get-backlinks-overview.ts b/src/server/mcp/tools/get-backlinks-overview.ts
index 35ab54b..02df2f5 100644
--- a/src/server/mcp/tools/get-backlinks-overview.ts
+++ b/src/server/mcp/tools/get-backlinks-overview.ts
@@ -57,24 +57,28 @@ export const getBacklinksOverviewTool = {
const lookup = { target: args.target, scope: args.scope };
const spamOptions = { hideSpam: args.hideSpam ?? true };
const [overview, refDomains] = await Promise.all([
- BacklinksService.profileOverview(lookup, context.billing, spamOptions),
- BacklinksService.profileReferringDomains(
- lookup,
+ BacklinksService.profileOverview(lookup, context.billing),
+ BacklinksService.profileReferringDomainsPage(
+ {
+ ...lookup,
+ page: 1,
+ pageSize: 100,
+ sortField: "backlinks",
+ sortOrder: "desc",
+ filters: {},
+ },
context.billing,
spamOptions,
),
]);
const topDomains = refDomains.rows ?? [];
- const overviewRecord =
- overview && typeof overview === "object"
- ? (overview as Record)
- : {};
+ const summary = overview.overview.summary;
const text = [
`Backlinks profile for ${args.target} (${args.scope ?? "domain"}):`,
- `- backlinks: ${formatMetric(overviewRecord.backlinks)}`,
- `- referring domains: ${formatMetric(overviewRecord.referring_domains)}`,
- `- referring pages: ${formatMetric(overviewRecord.referring_pages)}`,
- `- rank: ${formatMetric(overviewRecord.rank)}`,
+ `- backlinks: ${formatMetric(summary.backlinks)}`,
+ `- referring domains: ${formatMetric(summary.referringDomains)}`,
+ `- referring pages: ${formatMetric(summary.referringPages)}`,
+ `- rank: ${formatMetric(summary.rank)}`,
"",
`Top referring domains (${Math.min(topDomains.length, 10)} shown):`,
...topDomains
diff --git a/src/serverFunctions/backlinks.ts b/src/serverFunctions/backlinks.ts
index 5b03535..d732e3c 100644
--- a/src/serverFunctions/backlinks.ts
+++ b/src/serverFunctions/backlinks.ts
@@ -1,7 +1,16 @@
import { createServerFn } from "@tanstack/react-start";
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
import { requireProjectContext } from "@/serverFunctions/middleware";
-import { backlinksOverviewInputSchema } from "@/types/schemas/backlinks";
+import {
+ backlinksOverviewInputSchema,
+ backlinksRowsPageRequestSchema,
+ referringDomainsPageRequestSchema,
+ topPagesPageRequestSchema,
+} from "@/types/schemas/backlinks";
+
+// The web UI exposes spam score as a regular user filter, so the implicit
+// DataForSEO spam-score cutoff stays off for all web requests.
+const WEB_SPAM_OPTIONS = { hideSpam: false };
export const getBacklinksOverview = createServerFn({
method: "POST",
@@ -9,49 +18,45 @@ export const getBacklinksOverview = createServerFn({
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
- const input = {
- target: data.target,
- scope: data.scope,
- };
- const spamOptions = {
- hideSpam: data.hideSpam,
- spamThreshold: data.spamThreshold,
- };
const profile = await BacklinksService.profileOverview(
- input,
+ {
+ target: data.target,
+ scope: data.scope,
+ },
context,
- spamOptions,
);
return profile.overview;
});
+export const getBacklinksRows = createServerFn({
+ method: "POST",
+})
+ .middleware(requireProjectContext)
+ .inputValidator((data: unknown) => backlinksRowsPageRequestSchema.parse(data))
+ .handler(({ data, context }) =>
+ BacklinksService.profileBacklinksPage(data, context, WEB_SPAM_OPTIONS),
+ );
+
export const getBacklinksReferringDomains = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
- .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
- .handler(async ({ data, context }) => {
- const input = {
- target: data.target,
- scope: data.scope,
- };
- const profile = await BacklinksService.profileReferringDomains(
- input,
+ .inputValidator((data: unknown) =>
+ referringDomainsPageRequestSchema.parse(data),
+ )
+ .handler(({ data, context }) =>
+ BacklinksService.profileReferringDomainsPage(
+ data,
context,
- );
- return profile.rows;
- });
+ WEB_SPAM_OPTIONS,
+ ),
+ );
export const getBacklinksTopPages = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
- .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
- .handler(async ({ data, context }) => {
- const input = {
- target: data.target,
- scope: data.scope,
- };
- const profile = await BacklinksService.profileTopPages(input, context);
- return profile.rows;
- });
+ .inputValidator((data: unknown) => topPagesPageRequestSchema.parse(data))
+ .handler(({ data, context }) =>
+ BacklinksService.profileTopPagesPage(data, context),
+ );
diff --git a/src/types/schemas/backlinks.ts b/src/types/schemas/backlinks.ts
index daaa059..08be167 100644
--- a/src/types/schemas/backlinks.ts
+++ b/src/types/schemas/backlinks.ts
@@ -40,23 +40,192 @@ export const backlinksProjectSchema = z.object({
projectId: z.string().min(1),
});
-const backlinksSpamFilterSchema = z.object({
- hideSpam: z.boolean().optional(),
- spamThreshold: z.number().int().min(0).max(100).optional(),
+export const backlinksOverviewInputSchema = backlinksLookupSchema.extend({
+ projectId: z.string().min(1),
});
-export const backlinksOverviewInputSchema = backlinksLookupSchema
- .extend({
- projectId: z.string().min(1),
- })
- .merge(backlinksSpamFilterSchema);
+/* ------------------------------------------------------------------ */
+/* Paginated tab requests */
+/* ------------------------------------------------------------------ */
+
+export const BACKLINKS_PAGE_SIZES = [50, 100, 200] as const;
+export const DEFAULT_BACKLINKS_PAGE_SIZE = 100;
+
+const optionalNumber = z
+ .union([
+ z.number(),
+ z.string().transform((value, ctx) => {
+ const trimmed = value.trim();
+ if (trimmed === "") return undefined;
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed)) {
+ ctx.addIssue({ code: "custom", message: "Invalid number" });
+ return z.NEVER;
+ }
+ return parsed;
+ }),
+ ])
+ .optional();
+
+export const backlinksRowsFiltersSchema = z.object({
+ include: z.string().optional(),
+ exclude: z.string().optional(),
+ minDomainRank: optionalNumber,
+ maxDomainRank: optionalNumber,
+ minLinkAuthority: optionalNumber,
+ maxLinkAuthority: optionalNumber,
+ minSpamScore: optionalNumber,
+ maxSpamScore: optionalNumber,
+ linkType: z.enum(["dofollow", "nofollow"]).optional(),
+ hideLost: z.boolean().optional(),
+ hideBroken: z.boolean().optional(),
+ /** Exact-match on the linking domain; used to expand one domain's links. */
+ domainFrom: z.string().max(255).optional(),
+});
+
+/**
+ * DataForSEO result grouping for the backlinks list: `one_per_domain` returns
+ * each referring domain's strongest link (the default, denoised view);
+ * `as_is` returns every individual backlink.
+ */
+const backlinksRowsModeSchema = z.enum(["one_per_domain", "as_is"]);
+
+export const referringDomainsFiltersSchema = z.object({
+ include: z.string().optional(),
+ exclude: z.string().optional(),
+ minBacklinks: optionalNumber,
+ maxBacklinks: optionalNumber,
+ minRank: optionalNumber,
+ maxRank: optionalNumber,
+ minSpamScore: optionalNumber,
+ maxSpamScore: optionalNumber,
+});
+
+export const topPagesFiltersSchema = z.object({
+ include: z.string().optional(),
+ exclude: z.string().optional(),
+ minBacklinks: optionalNumber,
+ maxBacklinks: optionalNumber,
+ minReferringDomains: optionalNumber,
+ maxReferringDomains: optionalNumber,
+ minRank: optionalNumber,
+ maxRank: optionalNumber,
+});
+
+export const backlinksSortOrderSchema = z.enum(["asc", "desc"]);
+// Sort field names double as table column ids on the client; the server maps
+// them to DataForSEO field names.
+export const backlinksRowsSortFieldSchema = z.enum([
+ "rank",
+ "domainRank",
+ "spamScore",
+ "firstSeen",
+]);
+export const referringDomainsSortFieldSchema = z.enum([
+ "domain",
+ "backlinks",
+ "referringPages",
+ "rank",
+ "spamScore",
+ "firstSeen",
+ "brokenBacklinks",
+]);
+export const topPagesSortFieldSchema = z.enum([
+ "backlinks",
+ "referringDomains",
+ "rank",
+ "brokenBacklinks",
+]);
+
+/** Single source for each tab's default sort, shared by the request-schema
+ * defaults and the client's header indicators / query fallbacks. */
+export const BACKLINKS_DEFAULT_SORT = {
+ backlinks: { field: "rank", order: "desc" },
+ domains: { field: "backlinks", order: "desc" },
+ pages: { field: "backlinks", order: "desc" },
+} as const satisfies Record<
+ z.infer,
+ { field: string; order: z.infer }
+>;
+
+const backlinksPageRequestBase = backlinksLookupSchema.extend({
+ projectId: z.string().min(1),
+ page: z.number().int().positive().default(1),
+ pageSize: z
+ .number()
+ .int()
+ .refine((value) =>
+ (BACKLINKS_PAGE_SIZES as readonly number[]).includes(value),
+ )
+ .default(DEFAULT_BACKLINKS_PAGE_SIZE),
+ sortOrder: backlinksSortOrderSchema.default("desc"),
+});
+
+export const backlinksRowsPageRequestSchema = backlinksPageRequestBase.extend({
+ sortField: backlinksRowsSortFieldSchema.default(
+ BACKLINKS_DEFAULT_SORT.backlinks.field,
+ ),
+ filters: backlinksRowsFiltersSchema.default({}),
+ mode: backlinksRowsModeSchema.default("one_per_domain"),
+});
+
+export const referringDomainsPageRequestSchema =
+ backlinksPageRequestBase.extend({
+ sortField: referringDomainsSortFieldSchema.default(
+ BACKLINKS_DEFAULT_SORT.domains.field,
+ ),
+ filters: referringDomainsFiltersSchema.default({}),
+ });
+
+export const topPagesPageRequestSchema = backlinksPageRequestBase.extend({
+ sortField: topPagesSortFieldSchema.default(
+ BACKLINKS_DEFAULT_SORT.pages.field,
+ ),
+ filters: topPagesFiltersSchema.default({}),
+});
export const backlinksSearchSchema = z.object({
target: z.string().optional(),
scope: backlinksTargetScopeSchema.optional(),
tab: backlinksTabSchema.optional(),
+ page: z.coerce.number().int().positive().optional().catch(undefined),
+ size: z.coerce
+ .number()
+ .int()
+ .refine((value) =>
+ (BACKLINKS_PAGE_SIZES as readonly number[]).includes(value),
+ )
+ .optional()
+ .catch(undefined),
+ // Sort column id for the active tab; validated against the tab's sort-field
+ // enum when building the request, so a mismatched value falls back to the
+ // tab's default sort.
+ sort: z.string().optional().catch(undefined),
+ order: backlinksSortOrderSchema.optional().catch(undefined),
+ // Backlinks tab only: "all" shows every link; default is one per domain.
+ view: z.literal("all").optional().catch(undefined),
});
export type BacklinksLookupInput = z.infer;
export type BacklinksTab = z.infer;
export type BacklinksTargetScope = z.infer;
+export type BacklinksSortOrder = z.infer;
+export type BacklinksRowsSortField = z.infer<
+ typeof backlinksRowsSortFieldSchema
+>;
+export type ReferringDomainsSortField = z.infer<
+ typeof referringDomainsSortFieldSchema
+>;
+export type TopPagesSortField = z.infer;
+export type BacklinksRowsFilters = z.infer;
+export type ReferringDomainsFilters = z.infer<
+ typeof referringDomainsFiltersSchema
+>;
+export type TopPagesFilters = z.infer;
+export type BacklinksRowsPageInput = z.infer<
+ typeof backlinksRowsPageRequestSchema
+>;
+export type ReferringDomainsPageInput = z.infer<
+ typeof referringDomainsPageRequestSchema
+>;
+export type TopPagesPageInput = z.infer;