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.
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.
| Method | Path | Returns |
|---|---|---|
GET | /api/posts | A 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/locations | Every enabled location slug, with how many published articles each has. |
GET | /feed.xml | RSS 2.0 feed of published articles, optionally filtered by location. |
GET | /sitemap.xml | Sitemap 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
| Parameter | Required | Default | Notes |
|---|---|---|---|
location | No | all locations | A location slug. Omit it to list articles for every location. |
page | No | 1 | Whole number, 1 or more. A page beyond the last returns an empty array, not an error. |
limit | No | 10 | Whole number from 1 to 50. Anything outside that range is a 400. |
content | No | summary | Either summary or full. The default omits article bodies to keep listings small; use full only when you genuinely need every body in one call. |
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.
| Slug | Name | Published articles |
|---|---|---|
brighton | Brighton | 3 |
london | London | 1 |
east-grinstead | East Grinstead | 1 |
horley | Horley | 1 |
crawley | Crawley | 2 |
horsham | Horsham | 2 |
lewes | Lewes | 2 |
haywards-heath | Haywards Heath | 2 |
gatwick | Gatwick | 2 |
worthing | Worthing | 1 |
reigate | Reigate | 1 |
lingfield | Lingfield | 1 |
falmer | Falmer | 1 |
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:
| Field | Type | Meaning |
|---|---|---|
page | number | The page that was returned. |
limit | number | Page size that was applied. |
total | number | Total published articles matching the filter, across all pages. |
totalPages | number | Number of pages at this page size. 0 when there are no articles. |
hasPreviousPage | boolean | Whether a previous page exists. |
hasNextPage | boolean | Whether a further page exists. Use this to decide whether to render a Next link. |
location | object or null | The resolved location, or null when no filter was applied. |
contentIncluded | boolean | Whether article bodies are present in this response. |
Article fields
| Field | Type | Meaning |
|---|---|---|
id | string (uuid) | Stable identifier. |
title | string | Article headline. |
slug | string | URL-safe identifier. Use it for your own /blog/{slug}/ route. |
excerpt | string | One- or two-sentence summary, for blog cards. |
content | string | Full body as plain text. Paragraphs are separated by a blank line. Present only when requested. |
content_url | string | Absolute URL of the single-article endpoint for this article. |
meta_description | string | Ready-to-use meta description, 100 to 160 characters. |
canonical_url | string | The article's canonical address on this central hub. See the note below. |
url | string | Alias of canonical_url. |
published_at | string (ISO 8601) or null | When the article was published. |
updated_at | string (ISO 8601) or null | When the article last changed. Use it for your own lastmod. |
location | object or null | { slug, name } of the location the article was written for. |
location_id | string (uuid) | Internal location id. |
author | string or null | Byline. |
category | string or null | A single short category phrase. |
tags | string[] | Up to six short topic tags. Always an array. |
reading_minutes | number or null | Estimated reading time at 220 words per minute. |
sources | string[] | Approved source URLs the article's factual claims were traced to. Worth displaying. |
image_url | string or null | Featured image. Always check for null: not every article has one. |
image_alt | string or null | Alt text. Fall back to the title if absent. |
image_credit | string or null | Attribution text. You must display this if you display the image. |
image_source_url | string or null | Where the image came from. |
image_license | string or null | Licence name. Display it alongside the credit. |
image_license_url | string or null | Link to the licence terms. |
image_provider | string or null | Which library the image came from. |
image_width | number or null | Intrinsic width in pixels. Pass it to next/image to avoid layout shift. |
image_height | number or null | Intrinsic height in pixels. |
image_mime | string or null | Image media type. |
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 }
}| Status | Code | Cause and fix |
|---|---|---|
| 400 | invalid_location | The location parameter is not a lowercase hyphenated slug. Check for spaces, capitals or underscores. |
| 400 | invalid_page | page was not a whole number of 1 or more. Decimals and strings are rejected rather than coerced. |
| 400 | invalid_limit | limit was outside 1 to 50. Page through with several requests instead of raising the limit. |
| 400 | invalid_content_mode | content was neither 'summary' nor 'full'. |
| 404 | unknown_location | The slug is well formed but is not an enabled location. Check /api/locations. |
| 404 | post_not_found | No published article has that slug. Drafts and articles held for review also return this, deliberately. |
| 500 | server_error | Something 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.
| Response | Cache-Control | In practice |
|---|---|---|
| Published content endpoints | public, s-maxage=60, stale-while-revalidate=300 | Edge-cached for 60 seconds, then served stale while refreshing for up to 5 minutes. |
| RSS feed | public, s-maxage=300, stale-while-revalidate=900 | Refreshed at most every 5 minutes. |
| Errors | no-store | Never 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
| URL | Purpose |
|---|---|
https://content.westerncars.com/feed.xml | RSS 2.0, up to 50 most recent published articles across all locations. |
https://content.westerncars.com/feed.xml?location=brighton | The same feed filtered to one location. |
https://content.westerncars.com/sitemap.xml | Sitemap of this hub's own pages only. |
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=brightonNeither 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.contentJavaScript
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 });