@credlyst/blog
v0.1.2
Published
Drop-in React blog components that render content published from Credlyst. Works on Lovable's TanStack Start and legacy Vite stacks.
Maintainers
Readme
@credlyst/blog
React components that render a full blog from content published in Credlyst. Built for Lovable sites; works in any React app.
Content is authored, published, scheduled and retired in Credlyst. This package only reads. There is no write path, no API key, and nothing to configure beyond a site key.
How it works, in one paragraph
Publishing in Credlyst writes JSON snapshots to a public CDN — an index of teasers, one file per post, and a taxonomy. These components fetch those files and render them, caching in memory and localStorage so the CDN is touched about once per content change rather than once per page view. There is no server, no database and no runtime dependency beyond React.
Credlyst publish ──> JSON snapshots on a CDN ──> @credlyst/blog ──> your pagesInstall
bun add @credlyst/blognpm install @credlyst/blog and pnpm add @credlyst/blog work too. React 18 or newer is a peer dependency.
Configure
Copy the config block from the Credlyst channel setup page. It looks like this:
// credlyst.config.ts
import type { CredlystConfig } from '@credlyst/blog'
export const credlystConfig: CredlystConfig = {
siteKey: '00000000-0000-0000-0000-000000000000',
baseUrl: 'https://xyz.supabase.co/storage/v1/object/public/lovable-snapshots/sites/00000000-0000-0000-0000-000000000000',
pageSize: 10,
categoriesEnabled: true,
}Add basePath if the blog does not live at /blog:
basePath: '/insights',None of this is secret. The snapshots are published blog posts on a public CDN. Do not put these values in environment variables — it makes the config harder to find without making anything safer.
Which stack am I on?
Check whether the project has a src/routes/ directory with createFileRoute.
| | TanStack Start (projects created on or after 13 May 2026) | Legacy Vite SPA |
|---|---|---|
| Fetch in | route loader | the component |
| Crawler sees | fully rendered HTML | Lovable's prerender |
| Use | loader + initialData | components alone |
Use the route loader when the project has one. It is the difference between a crawler receiving the article text and receiving an empty shell.
TanStack Start
Fetch in the loader and pass the result as initialData. The component then renders synchronously with no client fetch and no loading flash.
Index route — src/routes/blog/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { BlogIndex, fetchCredlystIndex } from '@credlyst/blog'
import { credlystConfig } from '../../credlyst.config'
export const Route = createFileRoute('/blog/')({
loader: () => fetchCredlystIndex(credlystConfig, 1),
component: BlogIndexPage,
})
function BlogIndexPage() {
const data = Route.useLoaderData()
return <BlogIndex config={credlystConfig} initialData={data} />
}Post route — src/routes/blog/$slug.tsx
import { createFileRoute } from '@tanstack/react-router'
import { BlogPost, fetchCredlystPost } from '@credlyst/blog'
import { credlystConfig } from '../../credlyst.config'
export const Route = createFileRoute('/blog/$slug')({
loader: ({ params }) => fetchCredlystPost(credlystConfig, params.slug),
component: BlogPostPage,
})
function BlogPostPage() {
const { slug } = Route.useParams()
const data = Route.useLoaderData()
return <BlogPost config={credlystConfig} slug={slug} initialData={data} />
}Paginated route — src/routes/blog/page/$page.tsx
import { createFileRoute } from '@tanstack/react-router'
import { BlogIndex, fetchCredlystIndex } from '@credlyst/blog'
import { credlystConfig } from '../../../credlyst.config'
export const Route = createFileRoute('/blog/page/$page')({
loader: ({ params }) => fetchCredlystIndex(credlystConfig, Number(params.page)),
component: () => {
const { page } = Route.useParams()
return (
<BlogIndex config={credlystConfig} page={Number(page)} initialData={Route.useLoaderData()} />
)
},
})Legacy Vite SPA
No loaders. Render the components and let them fetch.
import { Routes, Route } from 'react-router-dom'
import { BlogIndex, BlogPost } from '@credlyst/blog'
import { credlystConfig } from './credlyst.config'
import { useParams } from 'react-router-dom'
function PostPage() {
const { slug } = useParams()
return <BlogPost config={credlystConfig} slug={slug} />
}
export function AppRoutes() {
return (
<Routes>
<Route path="/blog" element={<BlogIndex config={credlystConfig} />} />
<Route path="/blog/:slug" element={<PostPage />} />
</Routes>
)
}Lovable prerenders pages for verified crawlers, so indexing still works. Humans get the SPA shell first and the content a moment later.
Home-page strip
import { BlogTeaserGrid } from '@credlyst/blog'
import { credlystConfig } from './credlyst.config'
<BlogTeaserGrid
config={credlystConfig}
limit={3}
heading={<h2 className="mb-6 text-2xl font-semibold">Latest writing</h2>}
/>It renders nothing when there are no posts and when the fetch fails. That is deliberate — an empty band with apologetic copy on a home page reads as broken.
Client-side navigation
By default links are plain <a> tags, which reload the page. Pass your router's link component to get client-side navigation — which is also what makes the in-memory cache worth having:
import { Link } from '@tanstack/react-router'
<BlogIndex
config={credlystConfig}
linkComponent={({ href, className, children }) => (
<Link to={href} className={className}>{children}</Link>
)}
/>Restyling
Every element takes its classes from one named slot. Override any subset; an override replaces the default for that slot rather than merging with it.
<BlogIndex
config={credlystConfig}
theme={{
cardTitle: 'text-2xl font-bold tracking-tight text-brand-900',
card: 'rounded-2xl border border-brand-100 p-6 shadow-sm',
postBody: 'prose prose-lg prose-brand max-w-none',
}}
/>To restyle the whole blog at once, wrap it:
import { CredlystThemeProvider } from '@credlyst/blog'
<CredlystThemeProvider value={{ cardTitle: 'text-2xl font-bold' }}>
{children}
</CredlystThemeProvider>Slot names: root, list, card, cardMedia, cardBody, cardTitle, cardExcerpt, cardMeta, tagList, tag, grid, pagination, paginationLink, paginationCurrent, filterBar, filterButton, filterButtonActive, searchInput, post, postHeader, postTitle, postMeta, postHero, postBody, emptyState, errorState, goneState, skeleton.
The defaults are neutral greys on purpose, so they do not fight whatever the site already looks like. The article body uses prose classes — install @tailwindcss/typography for it to have any effect, or override postBody with your own.
Wording
<BlogPost
config={credlystConfig}
slug={slug}
copy={{
gone: 'This case study has been withdrawn',
goneDetail: 'Get in touch if you were looking for it.',
error: 'We could not load this right now.',
}}
/>Behaviour worth knowing before you debug something
A retracted post is not an error. When a post is unpublished in Credlyst, its file is replaced with a marker and BlogPost renders a "no longer available" state. Old links keep working and say something useful. Use isGone() if you handle the data yourself.
A disconnected channel empties the index, not the posts. BlogIndex renders the empty state; every post URL still resolves. Reconnecting in Credlyst restores the index on the next publish.
Search and filtering work over the loaded page. The snapshots are static files with no query endpoint, so BlogIndex filters what it has. Pass searchScope="all" to fetch every page once and search the lot — good for a few dozen posts, wasteful for hundreds.
Pagination hides while a filter is active, because the page numbers describe the unfiltered set.
A failed refresh keeps the old content on screen. A blog that has worked for a week does not go blank because one request timed out; the stale copy stays and the error is not shown.
The headline is not rendered twice. Credlyst article bodies usually open with an <h1> that repeats the title, so it is dropped. Pass dedupeLeadingHeading={false} if your bodies genuinely start with a different heading.
Article HTML is inserted directly. It was sanitised at the origin, at publish time, by Credlyst's single renderer using an allowlist, and written to a bucket only Credlyst can write to. This package deliberately ships no sanitiser of its own — a second, different policy would mean the published article could differ from the one the author previewed.
SEO
Title, meta description, Open Graph, Twitter card, article JSON-LD and a canonical link. Build the descriptor once; feed it to whichever mechanism your stack has.
The canonical comes from the snapshot's canonical_url, which Credlyst resolved from the workspace's setting — the Credlyst-hosted page, or this site. Do not compute your own. The whole point of that setting is that the API, the snapshots and these tags agree.
TanStack Start — head()
import { createFileRoute } from '@tanstack/react-router'
import { BlogPost, buildPostHead, fetchCredlystPost, isGone, toTanStackHead } from '@credlyst/blog'
import { credlystConfig } from '../../credlyst.config'
const seo = { siteUrl: 'https://acme.com', siteName: 'Acme', twitterHandle: '@acme' }
export const Route = createFileRoute('/blog/$slug')({
loader: ({ params }) => fetchCredlystPost(credlystConfig, params.slug),
head: ({ loaderData }) =>
isGone(loaderData)
? { meta: [{ title: 'No longer available' }] }
: toTanStackHead(buildPostHead(loaderData.post, credlystConfig, seo)),
component: () => (
<BlogPost
config={credlystConfig}
slug={Route.useParams().slug}
initialData={Route.useLoaderData()}
/>
),
})The tags are rendered server-side, so they are in the first response — which is what makes them worth having.
Legacy Vite SPA — useCredlystHead
import { BlogPost, buildPostHead, isGone, useCredlystHead, useCredlystPost } from '@credlyst/blog'
import { credlystConfig } from './credlyst.config'
import { useParams } from 'react-router-dom'
const seo = { siteUrl: 'https://acme.com', siteName: 'Acme' }
export function PostPage() {
const { slug } = useParams()
const { data } = useCredlystPost(credlystConfig, slug)
useCredlystHead(
data && !isGone(data) ? buildPostHead(data.post, credlystConfig, seo) : null,
)
return <BlogPost config={credlystConfig} slug={slug} initialData={data} />
}It writes into document.head during layout — before paint, and before Lovable's crawler prerender snapshots the DOM. It removes the previous page's tags on navigation, so an SPA does not accumulate two canonicals. It touches nothing it did not write, and while loading it leaves whatever is there in place: wrong tags beat no tags, because one is a mis-titled search result and the other is an untitled one.
Index pages
head: ({ loaderData }) => toTanStackHead(buildIndexHead(loaderData, credlystConfig, {
...seo,
title: 'Insights',
description: 'Writing on publishing infrastructure.',
})),Page 2 onwards gets — page 2 in the title, so a paginated blog does not read as duplicate content, plus rel="prev"/rel="next".
Sitemap
Credlyst writes sitemap-blog.xml alongside the snapshots. Get its URL with sitemapUrl(config) and either link it from your robots.txt or list it in a sitemap index:
Sitemap: https://xyz.supabase.co/storage/v1/object/public/lovable-snapshots/sites/YOUR_SITE_KEY/sitemap-blog.xmlIt lists post URLs on your site, which requires the channel's site_url to be set in Credlyst (or the workspace canonical source set to external). Without either, Credlyst has no way to know where your posts live and the sitemap comes back empty.
Caching
Two layers, plus the browser's.
| Layer | Survives | Purpose |
|---|---|---|
| Memory | client-side navigation | instant repeat renders |
| localStorage | reload, new tab | no loading state on a page you have seen |
| Browser HTTP cache | per its own rules | revalidation, If-None-Match, 304s |
Cached content renders immediately even when stale, and a refresh runs in the background. Default freshness is 5 minutes; change it with ttlMs.
Revalidation is left to the browser deliberately. Supabase Storage does not send Access-Control-Expose-Headers, so a cross-origin response's ETag is unreadable from JavaScript — response.headers.get('etag') returns null on a customer's domain. The browser can read it and does, sending If-None-Match and getting real 304s. If you are changing src/cache.ts: never pass cache: 'no-store', and never branch on a read ETag.
clearCredlystCache() drops everything, if you need it.
API
Components
| | |
|---|---|
| BlogIndex | paginated list with filter and search |
| BlogPost | one article |
| BlogTeaserGrid | latest-N strip |
| BlogCard | one teaser, for your own layouts |
Server-safe functions
Call from a route loader. No React, no browser APIs.
fetchCredlystIndex(config, page?) · fetchCredlystPost(config, slug) · fetchCredlystTaxonomy(config)
Hooks
useCredlystIndex(config, { page, initialData }) · useCredlystPost(config, slug, { initialData }) · useCredlystTaxonomy(config, { initialData })
Each returns { data, status, error, isValidating, refresh }.
Helpers
isGone(result) · postHref(config, teaser) · pageHref(config, page) · sitemapUrl(config) · clearCredlystCache() · formatDate(iso, locale)
Troubleshooting
Nothing renders and the network tab shows a 400. Storage answers 400, not 404, for a file that is not there. Check baseUrl against the Credlyst setup page, and confirm something has been published to the channel.
The index is empty but posts exist. The channel was disconnected in Credlyst, or nothing has been published to this channel — publishing to LinkedIn does not populate a Lovable blog.
Pagination skips posts. pageSize here must match the channel's page size in Credlyst. It is in the config block; do not change one without the other.
Content is stale. Default freshness is 5 minutes and the CDN adds up to a minute. Drop ttlMs to see changes sooner, at the cost of more requests.
Styles look unstyled. Tailwind only emits classes it can find. If the components live outside your content globs, add the package:
content: ['./src/**/*.{ts,tsx}', './node_modules/@credlyst/blog/dist/**/*.js']The article body has no typography. Install @tailwindcss/typography, or override the postBody slot.
Licence
MIT
