All posts

Engineering

Fetching localized content in Next.js

A tiny client for the public pages API, with caching and revalidation.

Huy Phạm

· 3 min read

Copy managed in tixigen can be fetched by any front end through the public pages API. This article shows how to build a small client in the Next.js App Router that fetches fields by market and locale, and uses caching and revalidation to keep pages both fast and fresh. It is written for developers running headless sites and for content leads who want to understand how a publish reaches the live page.

Public pages API basics

The public API is read-only and ETag-cached. Page fields come from this endpoint:

  • GET /api/v1/public/pages/{market}/{pageKey}?locale={locale}
  • market: one of vn, ph or id.
  • pageKey: a page managed in tixigen, such as home, about, services, pricing or contact.
  • locale: one of vi, fil, id or en. An unsupported or missing value falls back to the market's default locale.
bash
curl "https://tixigen.com/api/v1/public/pages/ph/home?locale=fil" \
  -H "Accept: application/json"

# 200 OK
# {
#   "fields": {
#     "home.hero.headline": "Padaliang mundo, isang tap lang",
#     "home.hero.ctaLabel": "Mag-quote"
#   }
# }

# Unknown market: 400
# { "error": { "code": "invalid_market", "message": "Unknown market \"xx\"" } }

The response is a simple object containing only fields: keys are dotted field keys such as home.hero.headline, and every value is a string. An unknown market or pageKey returns a 400 error whose error.code is invalid_market or invalid_page.

Build a small typed client

Keep fetching in one file so pages and components only call a function; caching policy and error handling then live in one place. Model markets and locales as string literal types so impossible values are caught at compile time.

ts
// lib/cms.ts
const BASE = "https://tixigen.com";

export type Market = "vn" | "ph" | "id";
export type Locale = "vi" | "fil" | "id" | "en";
export type PageFields = Record<string, string>;

export async function getPage(
  market: Market,
  pageKey: string,
  locale: Locale,
): Promise<PageFields> {
  const url = `${BASE}/api/v1/public/pages/${market}/${pageKey}?locale=${locale}`;
  const res = await fetch(url, {
    headers: { Accept: "application/json" },
    next: {
      revalidate: 300,
      tags: [`page:${market}:${pageKey}`],
    },
  });

  if (!res.ok) {
    throw new Error(`CMS ${res.status} for ${market}/${pageKey}`);
  }

  const { fields } = (await res.json()) as { fields: PageFields };
  return fields;
}

next.revalidate sets time-based revalidation, and next.tags attaches a tag for the on-demand revalidation described below. The tag uses the page:{market}:{pageKey} format so it matches the tag tixigen sends when you publish.

Render fields in a Server Component

Fetch in a Server Component. The API call stays on the server, so no extra JavaScript ships to the browser and search engines receive finished HTML. Note that in recent versions of Next.js, params is a Promise.

tsx
// app/[market]/page.tsx
import { getPage, type Market } from "@/lib/cms";

export default async function Page({
  params,
}: {
  params: Promise<{ market: Market }>;
}) {
  const { market } = await params;
  const fields = await getPage(market, "home", "en");

  return (
    <Hero
      eyebrow={fields["home.hero.eyebrow"]}
      title={fields["home.hero.headline"] ?? ""}
      cta={fields["home.hero.ctaLabel"]}
      href={fields["home.hero.ctaHref"] ?? "/contact"}
    />
  );
}
The API falls back field by field: requested locale, then the market's default locale, then en. A field with no value in any of them is omitted from the response, so give it a default with ?? when rendering.

How caching and revalidation fit together

It helps to think in two layers. The first is the API: responses carry a Cache-Control header for shared caches (s-maxage and stale-while-revalidate) plus an ETag, and a request sending If-None-Match gets a 304 with no body when nothing has changed. The second is the Next.js data cache, controlled by next.revalidate and next.tags on fetch.

  • Time-based: after the given number of seconds, the next request refreshes the data in the background. It is simple and works without any notification.
  • On-demand: invalidate a tag at the moment of publishing. Best when editors expect changes to show right after they publish.
Even with on-demand revalidation in place, keep a time-based revalidate as a safety net. If a notification is ever missed, the page still catches up after a while.

Update the moment you publish

When page fields are published in tixigen, it sends a POST to a revalidation URL on your site that you configure in advance, with tag and secret as query parameters. For page fields the tag has the form page:{market}:{pageKey}. On your site, add a Route Handler that checks the secret and invalidates that tag.

ts
// app/api/revalidate/route.ts
import type { NextRequest } from "next/server";
import { revalidateTag } from "next/cache";

export async function POST(req: NextRequest) {
  const secret = req.nextUrl.searchParams.get("secret");
  const tag = req.nextUrl.searchParams.get("tag");

  if (!secret || secret !== process.env.REVALIDATE_SECRET) {
    return Response.json({ revalidated: false }, { status: 401 });
  }
  if (!tag) {
    return Response.json({ revalidated: false }, { status: 400 });
  }

  revalidateTag(tag, "max");
  return Response.json({ revalidated: true, tag });
}
Keep the secret in an environment variable and never commit it. Also note that the single-argument revalidateTag(tag) is deprecated in recent Next.js. Passing "max" as the second argument serves the old content while fresh data loads in the background.

Production checklist

  • Fetch only in Server Components or server code; do not call the API directly from the client.
  • Check res.ok and have a fallback that does not break the whole page on error.
  • Give required fields a default with ?? when rendering.
  • Keep the revalidation secret in an environment variable and rotate it regularly.
  • Fetch UI strings from /api/v1/public/translations/{market}/{locale} with the same approach.

With fetching in one client and a mix of time-based and on-demand revalidation, what editors publish shows up quickly while visitors always get fast, cached pages. Start by wiring up one section of the home page and watch a publish flow through to the live site.

Written by

Huy Phạm

tixigen team

See this workflow in the product.

We'll tailor a demo to your markets and workflow.

Request a demo