WESTERN CARS · CONTENT HUB

Content API documentation

The central blog platform writes and stores articles once, for every Western Cars location. Each location's own website reads the articles it needs from this API and renders them as native pages on its own domain.

What this platform is

This site is the single source of truth for Western Cars blog content. A scheduled job generates one new article per day, rotating through the enabled locations so each one is covered in turn. Every article passes a quality gate before it can be published, and anything the gate cannot clear is held for human review instead.

Consuming websites do not copy articles into their own databases. They call this API at request or build time, cache the response, and render the listing and article pages themselves. That way an article lives at one canonical address centrally, while readers stay on the local domain they arrived at.

On factual accuracy. Every published article has had its material claims traced to an approved source, and those sources are listed with the article. Tracing a claim to a source is not the same as proving it true. Fares, timetables and service details change; always confirm them with the operator or authority before relying on them.

Endpoints

The public content API is read-only and needs no API key or authentication. Generation and administration routes are separate and are protected by a server-side secret.

MethodPathReturns
GET/api/postsA paginated list of published articles, newest first. Optionally filtered to one location.
GET/api/posts/{slug}One published article, including its full body text.
GET/api/locationsEvery enabled location slug, with how many published articles each has.
GET/feed.xmlRSS 2.0 feed of published articles, optionally filtered by location.
GET/sitemap.xmlSitemap for the central hub's own article pages.
GET/posts/{slug}The article rendered on the central hub. This is each article's canonical URL.

Query parameters for /api/posts

ParameterRequiredDefaultNotes
locationNoall locationsA location slug. Omit it to list articles for every location.
pageNo1Whole number, 1 or more. A page beyond the last returns an empty array, not an error.
limitNo10Whole number from 1 to 50. Anything outside that range is a 400.
contentNosummaryEither summary or full. The default omits article bodies to keep listings small; use full only when you genuinely need every body in one call.
Getting full article bodies. The listing endpoint returns summaries. To render an article page, follow the content_url on the listing item, which points at /api/posts/{slug}. This keeps listing responses small and is why the single-article endpoint exists.

Location slugs

A slug is lowercase, with words joined by single hyphens, matching ^[a-z0-9]+(?:-[a-z0-9]+)*$. A slug in any other shape is a 400 invalid_location; a correctly shaped slug that is not an enabled location is a 404 unknown_location.

These 13 locations are enabled right now. This list is read from the database when the page is built, so it matches what the API accepts.

SlugNamePublished articles
brightonBrighton3
londonLondon1
east-grinsteadEast Grinstead1
horleyHorley1
crawleyCrawley2
horshamHorsham2
lewesLewes2
haywards-heathHaywards Heath2
gatwickGatwick2
worthingWorthing1
reigateReigate1
lingfieldLingfield1
falmerFalmer1

Pagination

Articles are ordered by publication date, newest first, with the article id as a tie-breaker. The tie-breaker matters: without it, two articles published in the same instant could swap places between requests and appear twice, or not at all, as a reader pages through.

Every listing response carries this metadata alongside the data array:

FieldTypeMeaning
pagenumberThe page that was returned.
limitnumberPage size that was applied.
totalnumberTotal published articles matching the filter, across all pages.
totalPagesnumberNumber of pages at this page size. 0 when there are no articles.
hasPreviousPagebooleanWhether a previous page exists.
hasNextPagebooleanWhether a further page exists. Use this to decide whether to render a Next link.
locationobject or nullThe resolved location, or null when no filter was applied.
contentIncludedbooleanWhether article bodies are present in this response.

Article fields

FieldTypeMeaning
idstring (uuid)Stable identifier.
titlestringArticle headline.
slugstringURL-safe identifier. Use it for your own /blog/{slug}/ route.
excerptstringOne- or two-sentence summary, for blog cards.
contentstringFull body as plain text. Paragraphs are separated by a blank line. Present only when requested.
content_urlstringAbsolute URL of the single-article endpoint for this article.
meta_descriptionstringReady-to-use meta description, 100 to 160 characters.
canonical_urlstringThe article's canonical address on this central hub. See the note below.
urlstringAlias of canonical_url.
published_atstring (ISO 8601) or nullWhen the article was published.
updated_atstring (ISO 8601) or nullWhen the article last changed. Use it for your own lastmod.
locationobject or null{ slug, name } of the location the article was written for.
location_idstring (uuid)Internal location id.
authorstring or nullByline.
categorystring or nullA single short category phrase.
tagsstring[]Up to six short topic tags. Always an array.
reading_minutesnumber or nullEstimated reading time at 220 words per minute.
sourcesstring[]Approved source URLs the article's factual claims were traced to. Worth displaying.
image_urlstring or nullFeatured image. Always check for null: not every article has one.
image_altstring or nullAlt text. Fall back to the title if absent.
image_creditstring or nullAttribution text. You must display this if you display the image.
image_source_urlstring or nullWhere the image came from.
image_licensestring or nullLicence name. Display it alongside the credit.
image_license_urlstring or nullLink to the licence terms.
image_providerstring or nullWhich library the image came from.
image_widthnumber or nullIntrinsic width in pixels. Pass it to next/image to avoid layout shift.
image_heightnumber or nullIntrinsic height in pixels.
image_mimestring or nullImage media type.
Do not reuse canonical_url as your canonical tag. When you render an article at https://www.westerncarsbrighton.co.uk/blog/my-slug/, that is the URL your canonical tag, Open Graph URL and sitemap entry must use. Pointing your canonical at the central hub tells search engines to ignore your page entirely. Treat canonical_url as information about where the article lives centrally, not as markup to copy.

Internal review data — quality scores, failure reasons, reviewer notes, verification records and draft articles — is never returned by this API. Only published articles are reachable.

Example responses

Listing: GET /api/posts?location=brighton&page=1&limit=2

{
  "data": [
    {
      "id": "7c1f0e6a-2b44-4f1e-9d31-2a9f5b7c8e10",
      "title": "Getting from Brighton Station to Gatwick without rushing",
      "slug": "brighton-station-to-gatwick-without-rushing",
      "excerpt": "What to check before you travel, and how to build a margin into an early airport run.",
      "meta_description": "Practical advice on planning a Brighton to Gatwick airport journey, including what to check before you set off and how much margin to allow.",
      "canonical_url": "https://content.westerncars.com/posts/brighton-station-to-gatwick-without-rushing",
      "url": "https://content.westerncars.com/posts/brighton-station-to-gatwick-without-rushing",
      "content_url": "https://content.westerncars.com/api/posts/brighton-station-to-gatwick-without-rushing",
      "published_at": "2026-10-20T06:12:44.182Z",
      "updated_at": "2026-10-20T06:12:44.182Z",
      "location": { "slug": "brighton", "name": "Brighton" },
      "location_id": "1b9d...",
      "author": "Western Cars Content Team",
      "category": "Airport transfers",
      "tags": ["gatwick", "airport transfers", "brighton"],
      "reading_minutes": 5,
      "sources": [
        "https://www.gatwickairport.com/",
        "https://www.nationalrail.co.uk/"
      ],
      "image_url": "https://images.pexels.com/photos/1004/...jpeg",
      "image_alt": "Brighton: airport transfer planning",
      "image_credit": "Photo by A. Photographer on Pexels",
      "image_source_url": "https://www.pexels.com/photo/example-1004/",
      "image_license": "Pexels License",
      "image_license_url": "https://www.pexels.com/license/",
      "image_provider": "Pexels",
      "image_width": 1920,
      "image_height": 1080,
      "image_mime": "image/jpeg"
    }
  ],
  "page": 1,
  "limit": 2,
  "total": 7,
  "totalPages": 4,
  "hasPreviousPage": false,
  "hasNextPage": true,
  "location": { "slug": "brighton", "name": "Brighton" },
  "contentIncluded": false
}

Single article: GET /api/posts/{slug}

{
  "data": {
    "id": "7c1f0e6a-2b44-4f1e-9d31-2a9f5b7c8e10",
    "title": "Getting from Brighton Station to Gatwick without rushing",
    "slug": "brighton-station-to-gatwick-without-rushing",
    "content": "First paragraph of the article.\n\nSecond paragraph.\n\nThird paragraph.",
    "...": "all the listing fields are present too"
  }
}

Locations: GET /api/locations

{
  "data": [
    { "slug": "brighton", "name": "Brighton", "published_posts": 7, "last_published_at": "2026-10-20T06:12:44.182Z" },
    { "slug": "crawley", "name": "Crawley", "published_posts": 3, "last_published_at": "2026-10-17T05:03:11.004Z" }
  ],
  "total": 13
}

Errors and troubleshooting

Errors return a JSON body with a human-readable error string and a stable machine-readable code. Branch on code, never on the message text.

{
  "error": "The limit parameter must be a whole number between 1 and 50.",
  "code": "invalid_limit",
  "details": { "received": "500", "maximum": 50 }
}
StatusCodeCause and fix
400invalid_locationThe location parameter is not a lowercase hyphenated slug. Check for spaces, capitals or underscores.
400invalid_pagepage was not a whole number of 1 or more. Decimals and strings are rejected rather than coerced.
400invalid_limitlimit was outside 1 to 50. Page through with several requests instead of raising the limit.
400invalid_content_modecontent was neither 'summary' nor 'full'.
404unknown_locationThe slug is well formed but is not an enabled location. Check /api/locations.
404post_not_foundNo published article has that slug. Drafts and articles held for review also return this, deliberately.
500server_errorSomething failed on our side. The request is safe to retry; back off before retrying.

An empty listing is not an error

A valid location with no published articles yet returns 200 with an empty data array and total: 0. Requesting a page past the last page behaves the same way. Your blog listing needs a real empty state for both.

Caching and update frequency

One new article is generated per day across the whole platform, rotating through the locations, so any single location gains an article every couple of weeks. There is no benefit in polling frequently.

ResponseCache-ControlIn practice
Published content endpointspublic, s-maxage=60, stale-while-revalidate=300Edge-cached for 60 seconds, then served stale while refreshing for up to 5 minutes.
RSS feedpublic, s-maxage=300, stale-while-revalidate=900Refreshed at most every 5 minutes.
Errorsno-storeNever cached.

Internally, the cached database reads are tagged and cleared the moment an article is published, so the only remaining delay is the edge cache. A newly published article is visible within about a minute.

On your own site, revalidate every hour or so. That is far more often than content actually changes, and it keeps your pages fast and your request volume negligible.

RSS and sitemap

URLPurpose
https://content.westerncars.com/feed.xmlRSS 2.0, up to 50 most recent published articles across all locations.
https://content.westerncars.com/feed.xml?location=brightonThe same feed filtered to one location.
https://content.westerncars.com/sitemap.xmlSitemap of this hub's own pages only.
Keep the two sitemaps separate. Do not add this hub's article URLs to your site's sitemap, and do not add your URLs to this one. Each sitemap lists only URLs on its own domain. Mixing them makes the two copies of an article compete in search results.

Integrating with a Next.js site

1. Environment variables

No credentials are needed. Two settings keep the integration tidy:

# .env.local on the consuming website
CONTENT_API_BASE_URL=https://content.westerncars.com
CONTENT_LOCATION_SLUG=brighton

Neither needs the NEXT_PUBLIC_ prefix: fetch the API on the server so responses are cached once per deployment rather than once per visitor.

2. A typed client

// lib/content-api.ts
const BASE = process.env.CONTENT_API_BASE_URL ?? "https://content.westerncars.com";
const LOCATION = process.env.CONTENT_LOCATION_SLUG ?? "brighton";

export type ContentPost = {
  id: string;
  title: string;
  slug: string;
  excerpt: string;
  content?: string | null;
  meta_description: string;
  canonical_url: string;
  content_url: string;
  published_at: string | null;
  updated_at: string | null;
  location: { slug: string; name: string } | null;
  author: string | null;
  category: string | null;
  tags: string[];
  reading_minutes: number | null;
  sources: string[];
  image_url: string | null;
  image_alt: string | null;
  image_credit: string | null;
  image_license: string | null;
  image_license_url: string | null;
  image_source_url: string | null;
  image_width: number | null;
  image_height: number | null;
};

export type PostsPage = {
  data: ContentPost[];
  page: number;
  limit: number;
  total: number;
  totalPages: number;
  hasPreviousPage: boolean;
  hasNextPage: boolean;
};

export const PAGE_SIZE = 9;

export async function fetchPostsPage(page: number): Promise<PostsPage> {
  const url = new URL("/api/posts", BASE);
  url.searchParams.set("location", LOCATION);
  url.searchParams.set("page", String(page));
  url.searchParams.set("limit", String(PAGE_SIZE));

  const res = await fetch(url, { next: { revalidate: 3600, tags: ["blog"] } });

  if (!res.ok) {
    // Let the caller decide between an empty state and an error state.
    throw new Error(`content_api_${res.status}`);
  }

  return res.json();
}

export async function fetchPost(slug: string): Promise<ContentPost | null> {
  const res = await fetch(new URL(`/api/posts/${slug}`, BASE), {
    next: { revalidate: 3600, tags: ["blog"] },
  });

  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`content_api_${res.status}`);

  const body = await res.json();
  return body.data;
}

3. Crawlable paginated listing

Use real routes rather than a client-side page parameter, so each page has its own URL that works without JavaScript: /blog/ for page one and /blog/page/2/ onward. Point page one's canonical at /blog/, never at /blog/page/1/.

// app/blog/page.tsx                  -> page 1
// app/blog/page/[page]/page.tsx      -> pages 2+
// Both render the same component.

import Link from "next/link";
import { notFound } from "next/navigation";
import { fetchPostsPage } from "@/lib/content-api";

export const revalidate = 3600;

export default async function BlogListing({ page = 1 }: { page?: number }) {
  let result;

  try {
    result = await fetchPostsPage(page);
  } catch {
    return <p>Our blog is temporarily unavailable. Please try again shortly.</p>;
  }

  // Page 2+ with nothing on it is a genuine 404, not an empty state.
  if (page > 1 && result.data.length === 0) notFound();

  if (result.data.length === 0) {
    return <p>New articles are on the way. Please check back soon.</p>;
  }

  return (
    <>
      <ul>
        {result.data.map((post) => (
          <li key={post.id}>
            <Link href={`/blog/${post.slug}/`}>{post.title}</Link>
            <p>{post.excerpt}</p>
            <time dateTime={post.published_at ?? undefined}>
              {post.published_at
                ? new Date(post.published_at).toLocaleDateString("en-GB")
                : null}
            </time>
          </li>
        ))}
      </ul>

      <nav aria-label="Blog pagination">
        {result.hasPreviousPage ? (
          <Link
            rel="prev"
            href={page === 2 ? "/blog/" : `/blog/page/${page - 1}/`}
          >
            Previous
          </Link>
        ) : null}

        {Array.from({ length: result.totalPages }, (_, i) => i + 1).map((n) =>
          n === page ? (
            <span key={n} aria-current="page">{n}</span>
          ) : (
            <Link key={n} href={n === 1 ? "/blog/" : `/blog/page/${n}/`}>{n}</Link>
          ),
        )}

        {result.hasNextPage ? (
          <Link rel="next" href={`/blog/page/${page + 1}/`}>Next</Link>
        ) : null}
      </nav>
    </>
  );
}

4. The article page

Render the body by splitting content on blank lines. It is plain text, so never pass it to dangerouslySetInnerHTML.

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { fetchPost } from "@/lib/content-api";

export const revalidate = 3600;

const SITE = "https://www.westerncarsbrighton.co.uk";

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> },
): Promise<Metadata> {
  const { slug } = await params;
  const post = await fetchPost(slug);

  if (!post) return { title: "Article not found", robots: { index: false } };

  // The canonical is YOUR url, because this is where the page is served.
  const canonical = `${SITE}/blog/${post.slug}/`;

  return {
    title: post.title,
    description: post.meta_description,
    alternates: { canonical },
    openGraph: {
      type: "article",
      title: post.title,
      description: post.meta_description,
      url: canonical,
      publishedTime: post.published_at ?? undefined,
      modifiedTime: post.updated_at ?? undefined,
      images: post.image_url ? [post.image_url] : undefined,
    },
  };
}

export default async function ArticlePage(
  { params }: { params: Promise<{ slug: string }> },
) {
  const { slug } = await params;
  const post = await fetchPost(slug);

  if (!post) notFound();

  const paragraphs = (post.content ?? "")
    .split(/\n{2,}/)
    .map((p) => p.trim())
    .filter(Boolean);

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.excerpt}</p>

      {post.image_url ? (
        <figure>
          {/* next/image needs the hostname in next.config remotePatterns */}
          <img
            src={post.image_url}
            alt={post.image_alt ?? post.title}
            width={post.image_width ?? undefined}
            height={post.image_height ?? undefined}
          />
          <figcaption>
            {post.image_credit} · {post.image_license}
          </figcaption>
        </figure>
      ) : null}

      {paragraphs.map((p, i) => <p key={i}>{p}</p>)}

      {post.sources.length > 0 ? (
        <section>
          <h2>Sources</h2>
          <ul>
            {post.sources.map((s) => (
              <li key={s}><a href={s} rel="nofollow noopener">{s}</a></li>
            ))}
          </ul>
        </section>
      ) : null}
    </article>
  );
}

5. Images

Featured images are served from images.pexels.com. To use next/image, allow that host:

// next.config.ts
const nextConfig = {
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "images.pexels.com", pathname: "/photos/**" },
    ],
  },
};

If you display an image you must display its image_credit and image_license. Both are supplied with every article that has one.

6. Your own sitemap

// app/sitemap.ts on the consuming website
import type { MetadataRoute } from "next";
import { fetchPostsPage, PAGE_SIZE } from "@/lib/content-api";

const SITE = "https://www.westerncarsbrighton.co.uk";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const first = await fetchPostsPage(1);
  const posts = [...first.data];

  for (let page = 2; page <= first.totalPages; page += 1) {
    posts.push(...(await fetchPostsPage(page)).data);
  }

  return [
    { url: `${SITE}/blog/`, lastModified: new Date() },
    // Listing pages 2+ are crawlable, so list them too.
    ...Array.from({ length: Math.max(0, first.totalPages - 1) }, (_, i) => ({
      url: `${SITE}/blog/page/${i + 2}/`,
      lastModified: new Date(),
    })),
    ...posts.map((post) => ({
      url: `${SITE}/blog/${post.slug}/`,
      lastModified: new Date(post.updated_at ?? post.published_at ?? Date.now()),
    })),
  ];
}

Testing requests

Browser

Open any endpoint directly — they are all plain GET requests with no authentication:

curl

curl -s "https://content.westerncars.com/api/posts?location=brighton&page=1&limit=5" | jq .

# Just the slugs
curl -s "https://content.westerncars.com/api/posts?location=brighton&limit=50" | jq -r '.data[].slug'

# One article with its body
curl -s "https://content.westerncars.com/api/posts/<slug>" | jq '.data.content'

PowerShell

$base = "https://content.westerncars.com"
$res = Invoke-RestMethod -Uri "$base/api/posts?location=brighton&page=1&limit=5"

"Total: $($res.total)  Pages: $($res.totalPages)"
$res.data | Select-Object title, slug, published_at | Format-Table -AutoSize

# One article
$post = Invoke-RestMethod -Uri "$base/api/posts/$($res.data[0].slug)"
$post.data.content

JavaScript

const base = "https://content.westerncars.com";

const params = new URLSearchParams({
  location: "brighton",
  page: "1",
  limit: "10",
});

const res = await fetch(`${base}/api/posts?${params}`);

if (!res.ok) {
  const { code, error } = await res.json();
  throw new Error(`${code}: ${error}`);
}

const { data, total, totalPages, hasNextPage } = await res.json();
console.log({ returned: data.length, total, totalPages, hasNextPage });