The typed client for
Allerhande recipes
Search, filter, and fetch recipes from Albert Heijn's undocumented API with full TypeScript types and automatic auth.
npm install allerhande-client
Auth handled for you
Anonymous tokens are fetched and refreshed automatically with a 60-second guard band. You never touch a token.
Fully typed
Every field has a TypeScript type. Nutrition, tags, ingredient names, preparation steps — all typed.
Async pagination
searchAll() is an async generator that pages through all results automatically.
100% test coverage
DO-178C style unit tests with MC/DC branch coverage, plus daily E2E smoke tests against the live API.
Injectable fetch
Pass a custom fetch for proxying, middleware, or test mocks. Defaults to globalThis.fetch.
Automated releases
Conventional commits drive semantic versioning. Every feat: or fix: merged to main publishes a new version to npm.
Quick start
Search recipes
import { AllerhandeClient } from "allerhande-client";
const client = new AllerhandeClient();
const { page, result } = await client.searchRecipes("pasta carbonara");
console.log(page.total); // e.g. 2953
console.log(page.hasNextPage); // true
console.log(result[0].title); // "Pasta carbonara van Roberta Pagnier"
console.log(result[0].slug); // "pasta-carbonara-van-roberta-pagnier"
console.log(result[0].tags); // [{ key: "keuken", value: "italiaans" }, ...]
console.log(result[0].nutrition?.energy); // { value: 550, unit: "kcal" } or null
Fetch a full recipe
const recipe = await client.getRecipe(1202199);
console.log(recipe.description); // full Dutch description
console.log(recipe.cookTime); // 25 (minutes)
console.log(recipe.nutrition?.energy); // { value: 550, unit: "kcal" } or null
console.log(recipe.preparation.steps); // string[] — may contain HTML links
console.log(recipe.tips); // [{ type: "bereidingstip", value: "..." }, ...]
// Ingredient names are resolved — no extra lookup needed
for (const ing of recipe.ingredients) {
const label = ing.quantity > 1 && ing.name.plural
? ing.name.plural
: ing.name.singular;
console.log(`${ing.quantity} ${label}`); // e.g. "200 guanciale"
}
Page through all results
for await (const recipe of client.searchAll("soep")) {
console.log(recipe.title);
// Fetches the next page automatically; stops when hasNextPage is false
}
Examples
Sort and filter
// Most popular Italian main courses
const { result } = await client.searchRecipes("pasta", {
size: 20,
sortBy: "POPULAR",
filters: [
{ group: "menugang", values: ["hoofdgerecht"] },
{ group: "keuken", values: ["italiaans"] },
],
});
Filter by required ingredients
const { result } = await client.searchRecipes("pasta", {
ingredients: ["ei", "pancetta"],
});
Ingredient names with plural forms
const recipe = await client.getRecipe(1202199);
for (const ing of recipe.ingredients) {
// Use plural when quantity > 1 and a plural form exists
const name = ing.quantity !== 1 && ing.name.plural
? ing.name.plural
: ing.name.singular;
console.log(`${ing.quantity}× ${name}`);
}
// → "200× guanciale"
// → "5× middelgrote scharreleieren"
// → "150× Pecorino Romano"
// → "400× spaghetti"
Rendering HTML content
Tips, preparation steps, and descriptions may contain raw HTML from the AH CMS, including links with unquoted href attributes. Normalise before rendering:
// Normalise bare href values and ensure links open in a new tab
function safeHtml(raw: string): string {
return raw
.trim()
.replace(/href=(?!["'])([\S]+)/g, 'href="$1"')
.replace(/<a(\s)/gi, '<a target="_blank" rel="noopener noreferrer"$1');
}
// React example
<p dangerouslySetInnerHTML={{ __html: safeHtml(recipe.description) }} />
Get the recipe URL
import { getRecipeUrl } from "allerhande-client";
const url = getRecipeUrl(1202199, "pasta-carbonara");
// → https://www.ah.nl/allerhande/recept/R-R1202199/pasta-carbonara
slug is only available on RecipeSummary (search results), not on Recipe (fetched by ID). Store the slug when you have it from search, or construct the URL from search results directly.
Injectable fetch — browser CORS proxy
The API has no browser CORS headers. In a Vite app, proxy requests through the dev server and inject a rewriting fetch:
// vite.config.ts
export default defineConfig({
server: {
proxy: {
"/api/ah": {
target: "https://api.ah.nl",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/ah/, ""),
configure: (proxy) => {
// Strip Set-Cookie so the browser doesn't log CORS rejections
proxy.on("proxyRes", (res) => { delete res.headers["set-cookie"]; });
},
},
},
},
});
// src/client.ts
import { AllerhandeClient } from "allerhande-client";
const proxyFetch: typeof fetch = (input, init) =>
fetch(
String(input instanceof Request ? input.url : input)
.replace("https://api.ah.nl", "/api/ah"),
init
);
export const client = new AllerhandeClient({ fetch: proxyFetch });
Typed errors
import {
AllerhandeAuthError,
AllerhandeApiError,
AllerhandeGraphQLError,
} from "allerhande-client";
try {
const recipe = await client.getRecipe(id);
} catch (err) {
if (err instanceof AllerhandeAuthError) {
console.error("Auth failed:", err.statusCode, err.statusText);
} else if (err instanceof AllerhandeApiError) {
console.error("HTTP error:", err.statusCode);
} else if (err instanceof AllerhandeGraphQLError) {
console.error("GraphQL error:", err.messages);
}
}
Demo
The repository ships two runnable demos — no package publish required.
Node.js scripts
Three standalone scripts in examples/ — run directly with node after npm run build && cd examples && npm install.
| Script | What it shows |
|---|---|
node search.js "pasta" | Search with --size and --sort flags, formatted output |
node get-recipe.js [id] | Full recipe: description, ingredient names, numbered steps, tips |
node search-all.js "soep" --limit=50 | Async generator — streams all pages until the limit |
API Reference
AllerhandeClient
new AllerhandeClient(options?: AllerhandeClientOptions)
Creates a client instance. Each instance manages its own token lifecycle in memory.
| Option | Type | Description |
|---|---|---|
fetch | typeof fetch | Custom fetch. Resolved lazily — falls back to globalThis.fetch at call time. |
client.searchRecipes(query, options?)
Returns Promise<RecipeSearchResult> where result is RecipeSummary[].
| Option | Type | Default | Description |
|---|---|---|---|
size | number | 10 | Results per page. Allowed values: 5, 10, 12, 20, 24, 25, 50, 100 |
start | number | 0 | Zero-based offset for pagination |
sortBy | "NEWEST" | "POPULAR" | — | Sort order |
filters | RecipeSearchFilter[] | — | Tag-based filters (e.g. menugang, keuken) |
ingredients | string[] | — | Required ingredient names (Dutch) |
client.getRecipe(id)
Fetches a full recipe by its numeric ID. Returns Promise<Recipe>.
client.searchAll(query, options?)
Async generator. Accepts the same options as searchRecipes except start (managed internally). Yields one RecipeSummary at a time until all pages are exhausted.
getRecipeUrl(id, slug)
Returns the canonical https://www.ah.nl/allerhande/recept/R-R{id}/{slug} URL. The slug is only available on RecipeSummary.
Schema: RecipeSummary vs Recipe
The upstream GraphQL API has two distinct recipe types that do not share fields beyond the common set below.
| Field | RecipeSummary | Recipe |
|---|---|---|
id, title, publishedAt, images, tags, author | ✓ | ✓ |
slug | ✓ | ✗ |
nutrition | ✓ (fields nullable) | ✓ (fields nullable) |
description, cookTime, preparation, tips | ✗ | ✓ |
ingredients (with name) | ✗ | ✓ |
Types
interface RecipeIngredient {
id: number;
quantity: number; // raw numeric quantity — no unit returned by the API
name: {
singular: string; // e.g. "middelgroot scharrelei"
plural: string | null; // e.g. "middelgrote scharreleieren"
};
}
interface RecipeNutritionInfo {
// Individual fields are nullable — some recipes have partial data
energy: RecipeNutrient | null; // kcal
fat: RecipeNutrient | null; // g
saturatedFat: RecipeNutrient | null; // g
carbohydrates: RecipeNutrient | null; // g
protein: RecipeNutrient | null; // g
sodium: RecipeNutrient | null; // mg
}
interface RecipeNutrient {
value: number;
unit: string;
}
Error types
| Class | Thrown when | Extra properties |
|---|---|---|
AllerhandeAuthError | Token endpoint returns non-2xx | statusCode, statusText |
AllerhandeApiError | GraphQL endpoint returns non-2xx | statusCode, statusText |
AllerhandeGraphQLError | Response has errors[] or no data | messages: string[] |