+
+ Ranked-keyword suggestions aren't available for this country.
+ Continue and add the keywords you want to track manually.
+
+
+
+ >
+ );
+ }
+
// Loading state
if (suggestionsQuery.isLoading) {
return (
diff --git a/src/client/features/search-tabs/SearchTabStrip.tsx b/src/client/features/search-tabs/SearchTabStrip.tsx
index 16e1695..e3fa634 100644
--- a/src/client/features/search-tabs/SearchTabStrip.tsx
+++ b/src/client/features/search-tabs/SearchTabStrip.tsx
@@ -220,6 +220,7 @@ function getSearchTabQueryConfig(
locationCode: input.locationCode,
resultLimit: input.resultLimit,
mode: input.mode,
+ clickstream: input.clickstream,
});
return {
diff --git a/src/client/features/search-tabs/types.ts b/src/client/features/search-tabs/types.ts
index c85a2c8..ee21d64 100644
--- a/src/client/features/search-tabs/types.ts
+++ b/src/client/features/search-tabs/types.ts
@@ -23,6 +23,7 @@ export type KeywordSearchTabInput = {
locationCode: number;
resultLimit: ResultLimit;
mode: KeywordMode;
+ clickstream: boolean;
};
export type SearchTabInput =
diff --git a/src/client/features/search-tabs/useSearchTabs.ts b/src/client/features/search-tabs/useSearchTabs.ts
index 5c7ce9e..45ec299 100644
--- a/src/client/features/search-tabs/useSearchTabs.ts
+++ b/src/client/features/search-tabs/useSearchTabs.ts
@@ -77,6 +77,8 @@ function parseTabInput(value: unknown): SearchTabInput | null {
locationCode: value.locationCode,
resultLimit: value.resultLimit,
mode: value.mode,
+ // Tabs persisted before the clickstream toggle existed default to off.
+ clickstream: value.clickstream === true,
};
}
diff --git a/src/routes/_project/p/$projectId/keywords.tsx b/src/routes/_project/p/$projectId/keywords.tsx
index d51d7de..848a962 100644
--- a/src/routes/_project/p/$projectId/keywords.tsx
+++ b/src/routes/_project/p/$projectId/keywords.tsx
@@ -47,6 +47,7 @@ function KeywordResearchPageRoute() {
hasExplicitLocationCode={search.loc != null}
resultLimit={isResultLimit(resultLimit) ? resultLimit : 150}
keywordMode={normalizeKeywordMode(keywordMode)}
+ clickstream={search.cs ?? false}
sortField={normalizeSortField(sortField)}
sortDir={normalizeSortDir(sortDir)}
/>
diff --git a/src/server/features/keywords/services/research/research-data.test.ts b/src/server/features/keywords/services/research/research-data.test.ts
new file mode 100644
index 0000000..877d174
--- /dev/null
+++ b/src/server/features/keywords/services/research/research-data.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@/server/lib/dataforseo", () => ({
+ createDataforseoClient: vi.fn(),
+}));
+
+import {
+ KeywordsDataGoogleAdsKeywordsForKeywordsLiveResultInfo,
+ MonthlySearchesInfo,
+} from "dataforseo-client";
+import { mapAdsKeywordItems } from "./research-data";
+
+const adsItem = (
+ data: ConstructorParameters<
+ typeof KeywordsDataGoogleAdsKeywordsForKeywordsLiveResultInfo
+ >[0],
+) => new KeywordsDataGoogleAdsKeywordsForKeywordsLiveResultInfo(data);
+
+describe("mapAdsKeywordItems", () => {
+ it("maps Google Ads items to research rows without KD/intent", () => {
+ const rows = mapAdsKeywordItems([
+ adsItem({
+ keyword: "Hotel Reykjavik",
+ search_volume: 1300,
+ cpc: 2.54,
+ competition: "HIGH",
+ competition_index: 42,
+ monthly_searches: [
+ new MonthlySearchesInfo({
+ year: 2026,
+ month: 5,
+ search_volume: 1300,
+ }),
+ ],
+ }),
+ ]);
+
+ expect(rows).toEqual([
+ {
+ keyword: "hotel reykjavik",
+ searchVolume: 1300,
+ trend: [{ year: 2026, month: 5, searchVolume: 1300 }],
+ cpc: 2.54,
+ competition: 0.42,
+ keywordDifficulty: null,
+ intent: "unknown",
+ },
+ ]);
+ });
+
+ it("dedupes case-variant keywords and skips empty ones", () => {
+ const rows = mapAdsKeywordItems([
+ adsItem({ keyword: "northern lights tour", search_volume: 320 }),
+ adsItem({ keyword: "Northern Lights Tour", search_volume: 320 }),
+ adsItem({ keyword: undefined }),
+ ]);
+
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ keyword: "northern lights tour",
+ searchVolume: 320,
+ competition: null,
+ cpc: null,
+ trend: [],
+ });
+ });
+});
diff --git a/src/server/features/keywords/services/research/research-data.ts b/src/server/features/keywords/services/research/research-data.ts
index 6af70ea..fe5d3b9 100644
--- a/src/server/features/keywords/services/research/research-data.ts
+++ b/src/server/features/keywords/services/research/research-data.ts
@@ -1,4 +1,7 @@
-import { type LabsKeywordDataItem } from "@/server/lib/dataforseo";
+import {
+ type AdsKeywordIdeaItem,
+ type LabsKeywordDataItem,
+} from "@/server/lib/dataforseo";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseo";
import {
@@ -14,6 +17,7 @@ type FetchResearchRowsParams = {
languageCode: string;
resultLimit: number;
source: KeywordSource;
+ includeClickstreamData?: boolean;
};
function mapKeywordDataItems(items: LabsKeywordDataItem[]): EnrichedKeyword[] {
@@ -28,6 +32,8 @@ function mapKeywordDataItems(items: LabsKeywordDataItem[]): EnrichedKeyword[] {
if (seen.has(normalized)) continue;
seen.add(normalized);
+ // The clickstream-normalized block only exists when the caller opted into
+ // clickstream data (it doubles the request cost); prefer it when present.
const keywordInfo = item.keyword_info_normalized_with_clickstream
?.search_volume
? item.keyword_info_normalized_with_clickstream
@@ -51,6 +57,59 @@ function mapKeywordDataItems(items: LabsKeywordDataItem[]): EnrichedKeyword[] {
return rows;
}
+/**
+ * Google Ads items carry volume / CPC / paid competition but no keyword
+ * difficulty or search intent (those are Labs-only).
+ */
+export function mapAdsKeywordItems(
+ items: AdsKeywordIdeaItem[],
+): EnrichedKeyword[] {
+ const rows: EnrichedKeyword[] = [];
+ const seen = new Set