Integration guide (Node.js)

This page gives you a ready-to-use client to call Goodays GEO fragment API from your server. It implements every recommendation of Integration best practices.


What this client does

A developer copies the client into your website's server code. Each time a visitor or a crawler opens the page of a point of sale, the client:

  1. asks Goodays for the fragment of that point of sale;
  2. keeps it in a cache for 24 hours, so your pages don't call Goodays on every visit;
  3. gives up after 2 seconds if Goodays is slow, so your page is never delayed;
  4. if Goodays is unreachable, serves the last version it received, or renders the page without the reviews block.

Your code then only calls one function with the external ID of the point of sale, and inserts the result in the page.


Requirements

Client: goodays-geo.js. It needs Node.js 20+ (it uses the native fetch) and lru-cache for the default in-memory store.

In the examples below:

  • madrigal-los-pollos-hermanos is a universe ID (one of your brands): replace it with yours. In the client, it is the clientId parameter.
  • ABQ-001 is an external ID (your own code for the point of sale): replace it with the code of the point of sale the page is about.

Your Goodays project contact gives you your universe ID and confirms your external IDs.


Use it in your project

Copy goodays-geo.js into your project, then create one client per process. The client holds the cache.

import { createGeoFragmentClient } from './goodays-geo.js';

const geo = createGeoFragmentClient({ clientId: 'madrigal-los-pollos-hermanos' });

const fragment = await geo.getFragment('ABQ-001'); // string, or null: render the page without reviews

Output the fragment unescaped in your server-side template:

EngineSyntax
EJS<%- fragment %>
Handlebars{{{fragment}}}
Nunjucks{{ fragment | safe }}
Pugdiv!= fragment
React (Server Components / SSR)<div dangerouslySetInnerHTML={{ __html: fragment }} />

Render it on the server only. If you inject it from client-side JavaScript, crawlers won't see it.

Options

createGeoFragmentClient({
  clientId: 'madrigal-los-pollos-hermanos',
  baseUrl: 'https://geo.goodays.co',
  cache,                    // { get(key), set(key, value, ttlSeconds) }, may be async
  timeoutMs: 2000,          // whole request, body included
  freshTtl: 24 * 3600,      // seconds; fragments are regenerated every 24 h
  notFoundTtl: 3600,
  errorBackoff: 60,
  staleMax: 7 * 24 * 3600,
  live: false,              // development only: live data, no cache
  logger: console,          // anything with info() and warn()
});

Shared cache (several workers)

The default cache lives inside one process. With cluster mode, PM2 or several instances, share the cache, for example with node-redis:

import { createClient } from 'redis';

const redis = await createClient({ url: process.env.REDIS_URL }).connect();

const geo = createGeoFragmentClient({
  clientId: 'madrigal-los-pollos-hermanos',
  cache: {
    get: async (key) => JSON.parse((await redis.get(key)) ?? 'null'),
    set: (key, value, ttlSeconds) => redis.set(key, JSON.stringify(value), { EX: ttlSeconds }),
  },
});

Static sites and Next.js

If your pages are generated at build time or with ISR, fetch the fragment at render time and let the framework cache the page. In Next.js:

async function getFragment(place) {
  try {
    const response = await fetch(
      `https://geo.goodays.co/clients/madrigal-los-pollos-hermanos/${encodeURIComponent(place)}`,
      { next: { revalidate: 86400 }, signal: AbortSignal.timeout(2000) },
    );
    const isHtml = response.headers.get('content-type')?.startsWith('text/html');
    return response.ok && isHtml ? await response.text() : null;
  } catch {
    return null; // timeout or network error: render without reviews
  }
}

Client code

Copy this file into your project as goodays-geo.js.

/**
 * Goodays GEO Fragments API client.
 *
 * Fetches the pre-rendered reviews fragment (HTML + JSON-LD) of a place and
 * keeps it in a local cache, so rendering your pages never depends on a live
 * call to Goodays:
 *
 * - fresh cache hit: no network call at all;
 * - expired entry: conditional request (ETag / Last-Modified), usually a 304;
 * - error, timeout or unexpected response: the last good fragment is served
 *   (stale-on-error) and the origin is not retried before `errorBackoff`;
 * - unknown place (404) or nothing cached: `null`, render the page without
 *   the reviews block.
 *
 * Copy this file into your project. Node.js 20+ (native fetch), plus
 * `lru-cache` for the default in-memory store.
 */

import { LRUCache } from 'lru-cache';

export const DEFAULT_BASE_URL = 'https://geo.goodays.co';
const USER_AGENT = 'goodays-geo-snippets/nodejs/1.0';

/**
 * Bounded in-process store. Good enough for a single process; when you run
 * several workers or instances, pass a shared cache (Redis, Memcached...)
 * exposing `get(key)` and `set(key, value, ttlSeconds)` (may return promises).
 */
export function createMemoryCache({ max = 1000 } = {}) {
  const lru = new LRUCache({ max });
  return {
    get: (key) => lru.get(key),
    set: (key, value, ttlSeconds) => {
      lru.set(key, value, { ttl: ttlSeconds * 1000 });
    },
  };
}

export function createGeoFragmentClient({
  clientId,
  baseUrl = DEFAULT_BASE_URL,
  cache = createMemoryCache(),
  timeoutMs = 2000,
  freshTtl = 24 * 3600, // seconds; fragments are regenerated every 24 h
  notFoundTtl = 3600,
  errorBackoff = 60,
  staleMax = 7 * 24 * 3600,
  live = false, // development only: bypass every cache and render live data
  logger = console,
}) {
  const inFlight = new Map();

  function fragmentUrl(place) {
    return (
      `${baseUrl.replace(/\/+$/, '')}/clients/${encodeURIComponent(clientId)}` +
      `/${encodeURIComponent(place)}${live ? '?live=true' : ''}`
    );
  }

  /** Resolves to the fragment HTML to inject as-is, or `null`. */
  async function getFragment(place) {
    const key = `goodays-geo:${clientId}:${place}`;
    const entry = await cache.get(key);
    if (!live && entry && Date.now() < entry.freshUntil) return entry.html;

    // One refresh per place at a time: concurrent requests share it.
    if (!inFlight.has(key)) {
      const refreshing = refresh(key, place, entry).finally(() => inFlight.delete(key));
      inFlight.set(key, refreshing);
    }
    return inFlight.get(key);
  }

  async function refresh(key, place, entry) {
    const headers = { Accept: 'text/html', 'User-Agent': USER_AGENT };
    if (entry?.etag) headers['If-None-Match'] = entry.etag;
    if (entry?.lastModified) headers['If-Modified-Since'] = entry.lastModified;

    let response;
    let html = '';
    try {
      // The timeout covers the whole exchange, body included.
      response = await fetch(fragmentUrl(place), {
        headers,
        signal: AbortSignal.timeout(timeoutMs),
      });
      if (response.status === 200) html = await response.text();
      else await response.body?.cancel();
    } catch (error) {
      return fallback(key, entry, `request failed: ${error.message}`);
    }

    if (response.status === 304 && entry) {
      return store(key, entry.html, entry.etag, entry.lastModified);
    }

    const contentType = response.headers.get('content-type') ?? '';
    if (response.status === 200 && contentType.startsWith('text/html') && html.trim()) {
      return store(key, html, response.headers.get('etag'), response.headers.get('last-modified'));
    }

    if (response.status === 404) {
      logger.info(`No GEO fragment for ${clientId}/${place} (404)`);
      await storeMiss(key, notFoundTtl);
      return null;
    }

    return fallback(key, entry, `unexpected response: HTTP ${response.status} ${contentType}`);
  }

  async function store(key, html, etag, lastModified) {
    const now = Date.now();
    await cache.set(
      key,
      {
        html,
        etag,
        lastModified,
        freshUntil: now + freshTtl * 1000,
        // Kept that long to be served as a stale copy if Goodays is unreachable.
        expiresAt: now + staleMax * 1000,
      },
      staleMax,
    );
    return html;
  }

  /** Remember there is nothing to show, so the origin isn't hit on every page view. */
  async function storeMiss(key, ttl) {
    const now = Date.now();
    await cache.set(
      key,
      { html: null, etag: null, lastModified: null, freshUntil: now + ttl * 1000, expiresAt: now + ttl * 1000 },
      ttl,
    );
  }

  async function fallback(key, entry, reason) {
    logger.warn(
      `GEO fragment ${key} unavailable (${reason}), serving ` +
        (entry?.html ? 'stale copy' : 'page without reviews'),
    );
    // Don't call the origin again before errorBackoff, whatever happens.
    if (entry?.html) {
      const remaining = Math.floor((entry.expiresAt - Date.now()) / 1000);
      if (remaining > 0) {
        await cache.set(key, { ...entry, freshUntil: Date.now() + errorBackoff * 1000 }, remaining);
        return entry.html;
      }
    }
    await storeMiss(key, errorBackoff);
    return null;
  }

  return { getFragment, fragmentUrl };
}