Google's Custom Search JSON API is closed to new customers, and existing customers have until 2027-01-01 to move off it. That deadline takes searchType=image with it.
I maintain cse-bridge, a small self-hosted service that speaks Google's customsearch/v1 wire format on top of your own SearXNG instance, so migrating is a base-URL change rather than a rewrite. Web search shipped first. This week I added image search — and it turned out to be much less mechanical than "map some more fields", because two of the assumptions that hold for web results are actively wrong for image results.
Both are worth knowing whether or not you ever use my code. If you are writing anything that normalises image search results, you will hit them.
Trap 1: link is not the page
For a web result, Google's link is the URL of the page. Easy.
For an image result, link is the image file itself, and the page it was found on lives in image.contextLink:
{
"link": "https://facts.net/wp-content/uploads/2020/08/AdobeStock_209028852.jpeg",
"displayLink": "facts.net",
"image": {
"contextLink": "https://facts.net/nature/animals/red-panda-facts",
"thumbnailLink": "https://ts1.mm.bing.net/th?id=OIP.I_aIcVvl98DbktQmP297ugHaE7&pid=15.1",
"width": 4000,
"height": 2666
}
}
SearXNG has it the other way round: the result's url is the page, and the image is in a separate img_src field (documented here). So the naive mapping — reuse the web mapper, add an image object — produces items whose link points at an HTML document.
That fails silently, which is what makes it nasty. Your JSON still validates. Your item count is right. Every field is a well-formed URL. But every client that does <img src={item.link}> — which is the entire point of image search — renders nothing, and it looks like the images are broken rather than like your mapper is wrong.
The fix is a rule, not a patch: if a result has no image URL, drop the whole result. Never fall back to the page URL to keep the count up.
export function mapImageItem(result: SearxngResult): CseItem | null {
const link = typeof result.img_src === 'string' ? result.img_src.trim() : '';
if (link.length === 0) return null; // no image => not an image result
// ...
}
A test pins it: no item may ever be emitted with image present and a page URL in link.
Trap 2: deduping by URL collapses entire galleries
This one cost me more.
Any search aggregator needs de-duplication — SearXNG merges several engines per query and they overlap heavily. cse-bridge already normalised and deduped on the result's url, and that is correct for web results.
For image results it is a disaster. Ten different photos from one gallery page share one url, because url is the page. So the dedupe key was "the page this image sits on", and a ten-image gallery collapsed into a single result. The failure looks like thin results from a bad SearXNG config, not like a bug in your own dedupe — I initially went looking at engine settings.
The key has to be whatever identifies the thing you are returning:
export function dedupeKey(r: SearxngResult): string {
const imgSrc = typeof r.img_src === 'string' && r.img_src.trim().length > 0
? r.img_src.trim()
: undefined;
return normalizeUrl(imgSrc ?? r.url); // image identity, falling back to page identity
}
Generalised: de-duplicate on the identity of the returned entity, not on the container it was found in. Same bug shape shows up with products on a category page, jobs on a listings page, and papers on a proceedings page.
Parsing what the backend actually gives you
SearXNG reports image metadata as human-readable strings, not numbers: resolution is "1920 x 1080" and filesize is "412 KB" or "1MB". Google wants width/height/byteSize as integers.
Two rules make this safe:
Tolerate format variation. /(\d+)\s*[x×]\s*(\d+)/ handles 1920 x 1080, 800x600 and 1024 × 768. Filesizes parse B/KB/MB/GB at 1 KB = 1024, which is what SearXNG documents (1MB = 1024×1024 bytes).
When it doesn't parse, emit nothing. A resolution of "unknown" yields no width and no height — not 0, not NaN, not a guess. "huge" as a filesize yields no byteSize.
That sounds obvious written down, but the tempting alternative is real: you have a schema with width in it, so you feel obliged to fill it. Don't. A consumer can branch on a missing field. It cannot detect a plausible wrong number.
The same rule decided a field I couldn't fill. Google's image object has thumbnailWidth and thumbnailHeight. SearXNG gives you a thumbnail URL but never its dimensions. So those two fields are simply absent, and that is documented in the README's limitations rather than papered over.
This is the same posture the project already takes on totalResults: SearXNG's JSON has no result-count field at all, so rather than the popular len(results) * 100 fabrication — which sends paging clients into empty space — cse-bridge reports an honest lower bound. Honest gaps beat synthesized numbers. Users can code around the first one.
Say clearly what you can't do
Google's image search takes four filters: imgSize, imgType, imgColorType, imgDominantColor. SearXNG has no equivalent for any of them.
There are three options and only one is defensible:
- Reject requests using them — breaks clients over a filter they may not even care about.
- Silently accept and ignore them — the client believes it is filtering, and gets unfiltered results forever.
- Validate them against Google's exact enums, accept them, document loudly that they do not filter.
cse-bridge does (3). imgSize=gigantic gets Google's real 400 envelope, because Google rejects it too and a migrating client may well depend on that. imgSize=huge is accepted and inert, and the README and migration guide both say so in as many words. The project already had this shape for sort expressions beyond date, so it was a matter of applying an existing rule rather than inventing a policy.
Option 2 is where these projects rot. It's the one that produces bug reports two years later from someone who never knew the filter was a no-op.
Using it
Same one-line change as web search — point your existing Google client at the bridge:
- customsearch({version: 'v1'})
+ customsearch({version: 'v1', rootUrl: 'http://localhost:8080/'})
then set searchType=image exactly as you did against Google:
curl 'http://localhost:8080/customsearch/v1?key=k&cx=default&q=red%20panda&searchType=image&num=1'
Real, unedited output from the compose stack:
{
"kind": "customsearch#search",
"queries": {
"request": [
{
"title": "Google Custom Search - red panda",
"searchTerms": "red panda",
"count": 1,
"startIndex": 1,
"cx": "default",
"searchType": "image"
}
]
},
"items": [
{
"kind": "customsearch#result",
"title": "50 Adorable Facts About The Red Pandas You Have To Know | Facts.net",
"link": "https://facts.net/wp-content/uploads/2020/08/AdobeStock_209028852.jpeg",
"displayLink": "facts.net",
"image": {
"contextLink": "https://facts.net/nature/animals/red-panda-facts",
"thumbnailLink": "https://ts1.mm.bing.net/th?id=OIP.I_aIcVvl98DbktQmP297ugHaE7&pid=15.1",
"width": 4000,
"height": 2666
}
}
]
}
No API key, no per-query fees, no account — your machine talking to your SearXNG.
git clone https://github.com/Booyaka101/cse-bridge.git
cd cse-bridge && docker compose up -d
Node 22+, zero runtime dependencies, MIT. It's on npm as cse-bridge and on GHCR as ghcr.io/booyaka101/cse-bridge.
The one thing I'd like reported
The entire premise is that your client library will accept an endpoint override. Node, Python and LangChain are verified end to end; Go, Java, Ruby and PHP follow the same documented mechanism but aren't covered by my acceptance checks.
If you hit a client that refuses to be repointed, please open an issue. That is the case that breaks the premise, and I want to know about it before 2027-01-01 rather than after.
Top comments (0)