Skip to content

Writing loaders

@ghostwritr/react-router ships no loader wrappers. You write a standard React Router loader() and call fetchArticles or fetchArticle inside it. Loaders run on the server, so the feed call — and your siteId — stay server-side, and you keep full control of your routing, caching, and error responses.

siteId is the keyless read capability — there is no API key. Keep it in the environment and read it inside the loader (which runs on the server):

.env
GHOSTWRITR_SITE_ID=your-site-id

Every fetcher call takes it as an option. A non-empty string is required; a blank one throws a GhostwritrError with code CONFIG.

fetchArticles({ siteId }) returns every published Article, newest first:

app/routes/blog._index.tsx
import { fetchArticles } from "@ghostwritr/react-router";
import { Link, useLoaderData } from "react-router";
export async function loader() {
const articles = await fetchArticles({ siteId: process.env.GHOSTWRITR_SITE_ID! });
return { articles };
}
export default function Blog() {
const { articles } = useLoaderData<typeof loader>();
return (
<ul>
{articles.map((a) => (
<li key={a.id}>
<Link to={`/blog/${a.slug}`}>{a.title}</Link>
</li>
))}
</ul>
);
}

fetchArticles walks every page of the feed (20 articles per page) and de-duplicates by id. A site that hasn’t published yet has no feed and throws GhostwritrError code NOT_FOUND; see Error handling.

fetchArticle({ siteId, slug }) resolves a single article by slug, or returns null when the slug isn’t in the current feed. Translate that null into a real 404 Response so React Router renders your error boundary and sends the right status:

app/routes/blog.$slug.tsx
import { fetchArticle, articleMeta } from "@ghostwritr/react-router";
import { ArticleContent } from "@ghostwritr/react";
import { useLoaderData } from "react-router";
import type { Route } from "./+types/blog.$slug";
export async function loader({ params }: Route.LoaderArgs) {
const article = await fetchArticle({
siteId: process.env.GHOSTWRITR_SITE_ID!,
slug: params.slug!,
});
if (!article) throw new Response("Not found", { status: 404 });
return { article };
}
export const meta: Route.MetaFunction = ({ data }) =>
data ? articleMeta(data.article, { siteName: "Your Brand" }) : [];
export default function ArticlePage() {
const { article } = useLoaderData<typeof loader>();
return (
<article>
<h1>{article.title}</h1>
<ArticleContent markdown={article.markdown} className="prose" />
</article>
);
}

React Router SSR runs your loader per request, so each render reads the current feed (the manifest is short-cached upstream). If you put a CDN in front of your loaders, point your feed-freshness webhook at a resource route that purges the cache on feed.updated — then new articles appear in seconds instead of waiting on a TTL. See Instant updates.

Both fetchers accept the same FeedFetchOptions: staticBaseUrl to point at a different feed origin, and fetch to supply your own fetch (for caching, retries, or a test stub).

const articles = await fetchArticles({
siteId: process.env.GHOSTWRITR_SITE_ID!,
fetch: (url, init) => fetch(url, { ...init, cache: "force-cache" }),
});