Algolia with tRPC
What happens when you combine the precision of a tRPC API with the lightning-fast search of Algolia? I was curious to explore the possibilities, and what I discovered was a powerful search experience that came with some TypeScript headaches along the way.
During a recent project, I rebuilt our product, and as part of this, I wanted to offer a search feature to customers to look for products quickly and easily, and integrate it with our expanding tRPC API.
One of the things I love the most about tRPC is that it makes creating end-to-end typesafe APIs a breeze. The project had been using Algolia for a while before, integrating their react-instantsearch SDK into the codebase and making calls directly from the client. This works fine and it is the default that Algolia expects, but during this project I wanted to move the search to our API so that the client would only interface with tRPC, abstracting away all of the search and data enrichment processing to a single API endpoint.
The Algolia react-instantsearch package which wraps a React app in a kind of search context provider, allows developers to point to their own "backend search" API if needed, as long as it confirms to their expected interface. Fair enough. We'd continue to use the standard Algolia search hooks like useHits, useRefinementList, etc.
Creating the Algolia request
When writing a custom Algolia backend, you need to pass all of the requests being fired from a search or a refinement. This wasn't very clear in the docs and I lost a lot of time here. "But I only changed one thing?". When you check the logs, you will see that Algolia typically fires 1 or more requests containing the refinements and any searches you made. All of these must be sent to the Algolia API, otherwise you will end up narrowing your result refinements for each request.
Under the hood, Algolia works its magic through multiple requests. Here's the breakdown:
- Initial Search: You type a query or apply a filter. Algolia sends the initial search request to its servers.
- Refinements: As you refine your search (adding filters, changing keywords), Algolia sends additional requests for each refinement. This ensures your results dynamically update with every change.
- Combined Results: Algolia aggregates the responses from all these requests (initial search and refinements) and delivers the final, comprehensive result set to your application.
With this in mind, it's important to know that passing all requests is crucial. When building a custom backend, sending only the latest request won't work. You need to include all requests (initial search + refinements) to get the full picture. It's not mentioned in the docs though.
Now that we know this, we can start to handle the search request by passing the search requests to the algoliasearch client, with a given index. Once we get a response from Algolia, we can then either return the result, or enrich it with our own custom data:
import type {
MultipleQueriesResponse,
SearchResponse,
} from '@algolia/client-search';
import algoliasearch from 'algoliasearch';
// CUSTOM TYPES
interface AlgoliaAttributes {
objectID: string;
}
interface AlgoliaResponse extends Omit<SearchResponse, 'facets'> {
facets?: Record<FacetTypes, Record<string, number>>;
}
interface AlgoliaQueryResponse
extends Omit<MultipleQueriesResponse<AlgoliaAttributes>, 'results'> {
results: AlgoliaResponse[];
}
// FUNCTIONALITY
const search = (requests: readonly MultipleQueriesQuery[]) => {
const INDEX = "MY_INDEX";
const client = algoliasearch(process.env.ALGOLIA_APP_ID, process.env.ALGOLIA_ADMIN_KEY);
const params = requests.map((query) => ({
...query,
indexName: INDEX,
attributesToRetrieve: ["objectID"],
attributesToHighlight: [],
facets: ["*"],
maxFacetHits: 100,
}));
const res = (await client.search(params)) as AlgoliaQueryResponse;
return {
...res,
results: await Promise.all(
res.results.map(async (result) => {
return {
...result,
hits: await Promise.all(
result.hits.map(async (hit) => {
return {
...res,
// INFO: here is where you can enrich each returned item
};
})
),
};
})
),
};
};
You will see that I wrote a bunch of custom types too. This is because the Algolia SDK does not expect any data enrichment of a result, even though the docs give examples.
It does not try to infer the attributesToRetrieve in the result type (in my testing at least), meaning that if you request objectID, when you map over the result hits, objectID won't be in there. You'll also see that I'm mapping over each of the requests and adding the index and the attributes I want back from the API to each request.
Creating the tRPC backend
Creating the tRPC procedure is straightforward, taking the requests from the client and passing them on to the search service:
import type { MultipleQueriesQuery } from '@algolia/client-search';
export const t = initTRPC.create();
const publicProcedure = t.procedure;
export const appRouter = t.router({
search: publicProcedure
.input((input) => {
// INFO: skipping validation as we trust the Algolia input and types
return input as readonly MultipleQueriesQuery[];
})
.query(async ({ ctx: { services }, input }) => {
const res = await search(input); // INFO: the previous code example
return res;
}),
});
Here we have some conflicting TypeScript types since tRPC wants us to validate our inputs but making a zod equivalent validator wasn't possible for me. Because of this, I decided to skip a validator and trust that since we are not mutating the request from any of the Algolia search hooks or search inputs, we can go ahead and assume that the requests conform to the expected interface without validation.
Creating the custom client
Connecting the frontend Algolia client to our custom tRPC backend was simple enough:
import type { MultipleQueriesQuery } from '@algolia/client-search';
import type { SearchClient } from 'instantsearch.js';
import { trpc } from './trpc';
/**
* Creates a custom Algolia search client connected to the tRPC backend.
*
* @see https://www.algolia.com/doc/guides/building-search-ui/going-further/backend-search/in-depth/backend-instantsearch/react/
*/
export const useCustomAlgoliaClient = () => {
const utils = trpc.useUtils();
const client = {
async search(requests: readonly MultipleQueriesQuery[]) {
return utils.search.fetch(requests);
},
};
return client as unknown as SearchClient;
};
Note here that we're using the non-hook based .fetch method. tRPC exports a useUtils library of utilities to call your routers and manage the cache. They're very useful and there is good documentation on how they work in the tRPC docs.
Finally, we pass our custom client into the standard <InstantSearch> component just like normal:
import { InstantSearch } from 'react-instantsearch';
import { MyApp, useCustomAlgoliaClient } from '.';
export const MyApp = () => {
const client = useCustomAlgoliaClient();
return (
<InstantSearch
indexName="MY_INDEX"
searchClient={client}
routing={true}
>
<App />
</InstantSearch>
);
};
Why all the casting?
Algolia is amazing at search, but very bad at opening up their SDK to custom integrations. Despite their documentation including a "backend search" example, the docs on how to do that and what the response interface should look like are missing.
I spent hours looking through Algolia's type definitions trying to find an answer and I just couldn't find any. In their example, the use a classic fetch call (here is another example they give), but this is all JavaScript based and gives few details on how to add enrichment and custom types.
const customSearchClient = {
search(requests) {
return fetch("http://localhost:3000/search", {
method: "post",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ requests }),
}).then((res) => res.json());
},
};
If your custom client returns Promise<any> you're all good, but since we're calling tRPC with a response with something like Promise<{results: [ { hits: [] } ]}>, the hard type definitions seem to conflict with Algolia's opinionated API.
I even tested writing a router that just returned a standard Algolia search response with no enrichment, and got the same error.
I really hope this is something the Algolia team can investigate and solve.
So (unfortunately), to make this work in TypeScript you need to:
- Make your Algolia search call on the backend and cast the response
- Make your tRPC router to handle the requests and cast the input
- Make your custom client on the frontend and cast the SearchClient method
Integrating Algolia with tRPC proved challenging, but ultimately yielded a powerful and user-friendly search experience.