generated-web-client
v1.12.0
Published
SSR-safe client for the Generated public content API, with utilities for content processing, image URLs, sitemaps and SEO schema.
Downloads
500
Maintainers
Readme
Generated Web Client
TypeScript client for the Generated public content API, plus the helpers a site needs around it: markdown preprocessing, responsive imgproxy URLs, CTA placement, sitemaps and JSON-LD.
Generated hosts AI-written blog, news and glossary content for a project; your
site fetches it and renders it. This package is the fetching-and-rendering half.
It has no runtime dependencies, is safe to import in server components, edge
runtimes and browsers, and keeps its key-handling code in a separate /node
entrypoint so signing keys cannot leak into a client bundle.
Requires Node.js 24 or newer.
Installation
npm install generated-web-clientThe API it talks to
Everything is served from a single host, unauthenticated, keyed by your project ID:
https://api.generated.app/api/v1/{projectId}/…| Endpoint | Returns |
|---|---|
| GET /blog/list | Paginated blog list items, newest first |
| GET /blog/slug/{slug} | One blog post |
| GET /blog/id/{postId} | One blog post |
| GET /news/list, /news/slug/{slug}, /news/id/{id} | News |
| GET /glossary/list, /glossary/slug/{slug}, /glossary/id/{id} | Glossary terms |
| GET /sitemap/, /sitemap/{kind}/{page} | XML sitemaps |
Post endpoints take ?format=markdown or ?format=html (html is the
default), plus ?locale=. Responses are wrapped in { "data": … }; this client
unwraps them and throws GeneratedAPIError (carrying status, code and
requestId) on failure.
Quick start
import {
GeneratedBlogClient,
preprocessGeneratedMarkdown,
generateImgproxySrcset,
buildArticleSchema,
} from "generated-web-client";
const blog = new GeneratedBlogClient({
baseUrl: "https://api.generated.app",
projectId: "your-project-id",
defaultFormat: "markdown",
});
const post = await blog.getBySlug("my-post", { locale: "en" });
// Resolve genimg:/// and gen_video: syntax to real URLs
const markdown = preprocessGeneratedMarkdown(post.markdown, {
projectId: "your-project-id",
postType: "blog",
postId: post.id,
});
const { src, srcSet } = generateImgproxySrcset({ src: post.cover_image_url, quality: 75 });
const schema = buildArticleSchema({
url: `https://example.com/blog/${post.slug}`,
title: post.title,
description: post.meta_description,
publishedAt: post.published_at,
modifiedAt: post.updated_at,
hideDates: post.hide_dates,
images: [post.cover_image_url],
});Dates
Read this before rendering a date anywhere. Blog payloads carry three
date-related fields, on /blog/list and /blog/slug/{slug} alike, in both
response formats:
| Field | Meaning |
|---|---|
| published_at | When the post was scheduled to appear. On a corpus seeded in one campaign this is an artifact of scheduling, not a fact about the article. |
| updated_at | Real last-modification time of this post in this locale. The honest input for dateModified and for sitemap <lastmod>. |
| hide_dates | Project-level switch: this blog is a dateless corpus. Identical on every post of the project. |
updated_at and hide_dates are typed optional: an API older than
2026-07-25 does not send them, and a site pinned to one degrades to showing
dates rather than breaking.
The rules
- When
hide_datesis true, no date reaches a human — article header, listing cards, related-post widgets, home-page blocks, RSS<pubDate>,article:published_time. Nothing takes its place: no "updated recently", no reading time as a substitute, no relative phrasing. The absence is the point. - In JSON-LD,
datePublishedanddateModifiedare omitted — the keys are absent, not"", notnull, not0001-01-01T00:00:00Z. An empty value in a structured-data date field reads as malformed rather than as absent, and validators treat it as an error. - Sitemap
<lastmod>survives, and comes fromupdated_at. It is not a visible date, crawlers depend on it, andpublished_atis a scheduling artifact that does not belong in a freshness signal. - Order never changes. The API returns posts newest-first, server-side. Do not sort client-side by a date field: hiding dates must remove the label and nothing else. No helper in this package sorts.
- Blog only. News and glossary are time-anchored by definition, carry no
hide_dates, and keep their dates.
Ask one module rather than re-deriving the rule per component — the surfaces
that get missed in practice are the RSS feed and post blocks outside /blog.
import {
areDatesHidden,
visiblePublishedDate,
formatVisiblePublishedDate,
structuredDataDates,
sitemapLastmod,
resolveBlogDates,
} from "generated-web-client";
areDatesHidden(post); // boolean
visiblePublishedDate(post); // string | undefined
formatVisiblePublishedDate(post, { locale: "en-US" }); // "" when hidden
structuredDataDates(post); // {} when hidden
sitemapLastmod(post); // from updated_at, never hidden
resolveBlogDates(post); // all four at onceWorked example: hide_dates: false
const post = {
published_at: "2026-01-15T10:00:00Z",
updated_at: "2026-07-20T09:30:00Z",
hide_dates: false,
};
formatVisiblePublishedDate(post, { locale: "en-US" });
// => "Jan 15, 2026" → render it in the header and on cards
structuredDataDates(post);
// => { datePublished: "2026-01-15T10:00:00Z", dateModified: "2026-07-20T09:30:00Z" }
// dateModified is updated_at, not a repeat of published_at
sitemapLastmod(post);
// => "2026-07-20T09:30:00Z"Worked example: hide_dates: true
const post = {
published_at: "2026-01-15T10:00:00Z",
updated_at: "2026-07-20T09:30:00Z",
hide_dates: true,
};
formatVisiblePublishedDate(post, { locale: "en-US" });
// => "" → render nothing, substitute nothing
structuredDataDates(post);
// => {} → both keys absent from the JSON-LD
sitemapLastmod(post);
// => "2026-07-20T09:30:00Z" → unchanged; lastmod is not a visible dateThe JSON-LD builder takes the flag directly, so the article object is built the same way in both states:
const schema = buildArticleSchema({
url: postUrl,
title: post.title,
description: post.meta_description,
publishedAt: post.published_at,
modifiedAt: post.updated_at,
hideDates: post.hide_dates, // omits both date keys when true
images: [post.cover_image_url],
publisher: { name: "My Company", logo: "https://example.com/logo.svg" },
});Everything else in the schema — headline, author, publisher, image,
mainEntityOfPage, inLanguage — is unaffected, so the object still validates
as an Article.
For Open Graph metadata, map the same two values:
const { datePublished, dateModified } = structuredDataDates(post);
const openGraph = {
type: "article",
...(datePublished && { publishedTime: datePublished }),
...(dateModified && { modifiedTime: dateModified }),
};And for a sitemap you build yourself:
import { buildSitemapEntries } from "generated-web-client";
// app/sitemap.ts
const { items } = await blog.list({ limit: 100, locale: "en" });
return buildSitemapEntries(items, { siteUrl: "https://example.com" });
// => [{ url: "https://example.com/blog/my-post", lastModified: "2026-07-20T09:30:00Z" }, …]
// input order preserved, lastModified from updated_at, present either wayIf you serve sitemaps from Generated instead (GeneratedSitemapClient below),
there is nothing to do — the API already builds them this way.
API clients
GeneratedBlogClient
const client = new GeneratedBlogClient({
baseUrl: "https://api.generated.app",
projectId: "your-project-id",
defaultLocale: "en",
defaultFormat: "markdown", // or "html"
cta: "auto", // or "none"
});
// Newest first, server-side. Render in this order.
const { items, total, page, limit } = await client.list({ page: 1, limit: 20, locale: "en" });
const post = await client.getBySlug("my-post", { format: "markdown" });
const same = await client.getById("post-uuid");GeneratedNewsClient / GeneratedGlossaryClient
Same interface, minus the CTA options:
import { GeneratedNewsClient, GeneratedGlossaryClient } from "generated-web-client";
const news = new GeneratedNewsClient({ baseUrl: "https://api.generated.app", projectId: "your-project-id" });
const glossary = new GeneratedGlossaryClient({ baseUrl: "https://api.generated.app", projectId: "your-project-id" });GeneratedSitemapClient
Fetches XML sitemaps for proxying through your own domain:
import { GeneratedSitemapClient } from "generated-web-client";
const sitemaps = new GeneratedSitemapClient({
baseUrl: "https://api.generated.app",
projectId: "your-project-id",
});
const indexXml = await sitemaps.getIndex();
const blogXml = await sitemaps.getBlogPage(1);URL builders
Build URLs for images and videos embedded in generated content:
import { buildGeneratedImageUrl, buildGeneratedVideoUrl } from "generated-web-client";
buildGeneratedImageUrl({
projectId: "your-project-id",
postType: "blog",
postId: "post-uuid",
filename: "hero.webp",
host: "https://img.example.com",
});
// => "https://img.example.com/your-project-id/blog/post-uuid/hero.webp"Omit host and the URL is built against the media host your Generated
deployment serves images from.
Markdown preprocessor
Converts Generated-specific markdown syntax to standard markdown:
import { preprocessGeneratedMarkdown } from "generated-web-client";
const processed = preprocessGeneratedMarkdown(post.markdown, {
projectId: "your-project-id",
postType: "blog",
postId: post.id,
});Supported syntax:
— generated images— generated videos[gen_img:filename]or{{gen_img:filename}}— inline image tags[gen_video:youtube:ID]or[gen_video:URL]— video embeds
Images (imgproxy)
Client-side (unsigned)
import { imgproxyLoader, generateImgproxySrcset } from "generated-web-client";
const url = imgproxyLoader({ src: post.cover_image_url, width: 800, quality: 75 });
const { src, srcSet } = generateImgproxySrcset({ src: post.cover_image_url, quality: 75 });
// <img src={src} srcSet={srcSet} sizes="(max-width: 768px) 100vw, 800px" />Server-side (signed)
Signing keys must never reach a browser bundle, so the signer lives in a separate entrypoint. Import it only from server-side modules.
import {
createImgproxySignerSync,
imgproxySigningConfigFromEnv,
generateSignedImgproxySrcset,
} from "generated-web-client/node";
const signer = createImgproxySignerSync(
imgproxySigningConfigFromEnv(process.env as Record<string, string | undefined>),
);
const { src, srcSet } = generateSignedImgproxySrcset({ src: post.cover_image_url }, signer);Or, with a custom image domain, the one-call factory:
import "server-only";
import { createImageHelpers } from "generated-web-client/node";
export const images = createImageHelpers({
key: process.env.IMGPROXY_KEY!,
salt: process.env.IMGPROXY_SALT!,
host: "img.example.com",
});
const { src, srcSet } = images.getSrcset(post.cover_image_url);Environment variables:
IMGPROXY_KEY— hex-encoded signing key (server-only)IMGPROXY_SALT— hex-encoded signing salt (server-only)IMGPROXY_SIGNATURE_SIZE— signature size in bytes (default: 8)
Content utilities
import {
estimateReadingMinutes,
formatPublishedDate,
extractHeadings,
slugify,
stripMarkdown,
truncateText,
} from "generated-web-client";
estimateReadingMinutes(post.markdown); // => 5
formatPublishedDate(post.published_at, { locale: "en-US" }); // => "Jan 15, 2026"
extractHeadings(post.markdown); // => [{ level: 2, text, slug }, …]
extractHeadings(post.markdown, { levels: [2, 3] });
slugify("Hello World!"); // => "hello-world"
stripMarkdown("This is **bold**"); // => "This is bold"
truncateText("A long sentence that needs cutting", 20);For blog posts prefer formatVisiblePublishedDate over formatPublishedDate:
it respects hide_dates.
CTAs
Blog payloads can carry cta (a full CTA chosen by the API) and
adaptive_ctas (post-specific variants without a link). These helpers validate
them and place them through the article.
import { normalizeCTA, isValidCTA, toFullCTA, pickRandomFullCTAs } from "generated-web-client";
const cta = normalizeCTA(post.cta); // null when invalid
const full = toFullCTA(adaptive, { variant: "dark", button_href: "/signup" });
const chosen = pickRandomFullCTAs(post.adaptive_ctas ?? [], 3, { button_href: "/signup" });Placement, in one call:
import { prepareContentWithCTAs } from "generated-web-client";
const { sections, ctas } = prepareContentWithCTAs(post.markdown, post.adaptive_ctas ?? [], {
defaults: { button_href: "/signup", variant: "light" },
headingsPerCTA: 3,
maxCTAs: 3,
});
sections.map((section, i) =>
section.type === "content" ? (
<Markdown key={i}>{section.markdown}</Markdown>
) : (
<InlineCTA key={i} cta={ctas[section.slotIndex]} />
),
);The individual steps — extractHeadings, calculateCTAPlacements,
splitContentWithCTASlots, pickRandomFullCTAs — are exported too if you want
control over any of them.
JSON-LD
import {
buildArticleSchema,
buildFAQSchema,
buildBreadcrumbSchema,
buildOrganizationSchema,
buildWebSiteSchema,
cleanJsonLd,
jsonLdToScript,
} from "generated-web-client";
const article = buildArticleSchema({ /* see the Dates section above */ });
const faq = buildFAQSchema(post.faq);
const crumbs = buildBreadcrumbSchema([
{ name: "Home", url: "https://example.com" },
{ name: "Blog", url: "https://example.com/blog" },
{ name: post.title, url: postUrl },
]);
const scriptTag = jsonLdToScript(article);
// => '<script type="application/ld+json">{"@context":"https://schema.org",…}</script>'cleanJsonLd strips undefined values recursively, which some frameworks
require before serializing.
TypeScript
Every type is exported:
import type {
BlogListItem,
BlogPostMarkdown,
BlogPostHTML,
NewsListItem,
GlossaryTermListItem,
Paginated,
BlogDateFields,
StructuredDataDates,
BlogDatePolicy,
SitemapEntry,
ArticleSchemaInput,
PreprocessMarkdownOptions,
ImgproxySrcsetOptions,
ExtractedHeading,
JsonLd,
} from "generated-web-client";Development
npm ci
npm run type-check
npm test
npm run buildLicense
MIT — see LICENSE.
