Unofficial · TypeScript · Zero deps

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"
Note: The API does not return a unit of measurement (g, ml, etc.) for ingredients — only the raw numeric quantity. Unit information lives in Albert Heijn's product catalog and is not exposed through the recipe endpoint.

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
Note: 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.

React + TypeScript (Vite)

Full search UI: recipe cards with images and nutrition, detail modal with ingredient names, preparation steps, and tips. Uses the injectable fetch to proxy through Vite's dev server.

  1. npm run build  — build the package from the repo root
  2. cd examples/react-demo && npm install
  3. npm run dev  — opens on localhost:5173
View source ↗

Node.js scripts

Three standalone scripts in examples/ — run directly with node after npm run build && cd examples && npm install.

ScriptWhat 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=50Async 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.

OptionTypeDescription
fetchtypeof fetchCustom fetch. Resolved lazily — falls back to globalThis.fetch at call time.

client.searchRecipes(query, options?)

Returns Promise<RecipeSearchResult> where result is RecipeSummary[].

OptionTypeDefaultDescription
sizenumber10Results per page. Allowed values: 5, 10, 12, 20, 24, 25, 50, 100
startnumber0Zero-based offset for pagination
sortBy"NEWEST" | "POPULAR"Sort order
filtersRecipeSearchFilter[]Tag-based filters (e.g. menugang, keuken)
ingredientsstring[]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.

FieldRecipeSummaryRecipe
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

ClassThrown whenExtra properties
AllerhandeAuthErrorToken endpoint returns non-2xxstatusCode, statusText
AllerhandeApiErrorGraphQL endpoint returns non-2xxstatusCode, statusText
AllerhandeGraphQLErrorResponse has errors[] or no datamessages: string[]
Disclaimer: This wraps an undocumented, unofficial API. Albert Heijn may change or remove endpoints without notice. The E2E smoke tests run daily on GitHub Actions to catch upstream breakage early.