crawlsphere
v0.1.0
Published
Official React SDK for CrawlSphere: typed client, hooks, Next.js helpers, TipTap content renderer, and SEO meta helpers
Maintainers
Readme
@crawlsphere/react
Official React SDK for the CrawlSphere Blog API.
- Typed client — isomorphic, works in React, Next.js, Node, Cloudflare Workers, Vercel Edge.
- React hooks —
usePosts,usePost,useCreatePost,useUpdatePost,useDeletePost,useUploadImage,useGenerateBlogPost,useCompetitors. - Beautiful content renderer —
<BlogContent>turns TipTap JSON into real React elements (nodangerouslySetInnerHTML). Ships a polished CSS file, three built-in themes (light/dark/sepia), CSS-variable theming, per-slotclassNames, and fullcomponentsslot takeover. - Next.js helpers —
createServerClient()with built-in ISR,postMetadata()forgenerateMetadata,postStructuredData()for JSON-LD. - Full TypeScript — every endpoint has input + response types.
Install
npm install @crawlsphere/react
# or
bun add @crawlsphere/reactSet your API key:
CRAWLSPHERE_API_KEY=cs_live_...
CRAWLSPHERE_API_URL=https://crawlsphere.com # optional
CRAWLSPHERE_DOMAIN=yourdomain.com # optional defaultQuick start — React
import { CrawlSphereProvider, usePosts, BlogContent } from '@crawlsphere/react';
export function App() {
return (
<CrawlSphereProvider options={{ apiKey: 'cs_live_...', domain: 'mysite.com' }}>
<PostList />
</CrawlSphereProvider>
);
}
function PostList() {
const { data, isLoading, error } = usePosts({ limit: 10 });
if (isLoading) return <p>Loading…</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<ul>
{data!.posts.map((p) => (
<li key={p.id}>
<a href={`/blog/${p.slug}`}>{p.title}</a>
</li>
))}
</ul>
);
}Quick start — Next.js (App Router)
// app/blog/[slug]/page.tsx
import { createServerClient, BlogContent, postMetadata, postStructuredData } from '@crawlsphere/react';
const client = createServerClient({
apiKey: process.env.CRAWLSPHERE_API_KEY!,
domain: 'mysite.com',
revalidate: 60,
});
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await client.getPost(slug);
return postMetadata(post, { baseUrl: 'https://mysite.com' });
}
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await client.getPost(slug);
const jsonLd = postStructuredData(post, { baseUrl: 'https://mysite.com' });
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<h1>{post.title}</h1>
{post.subtitle && <p className="subtitle">{post.subtitle}</p>}
<BlogContent content={post.content} />
</article>
);
}Styling <BlogContent>
Import the stylesheet once (app entry or app/layout.tsx):
import '@crawlsphere/react/styles.css';Three levels of control
// 1. Pick a theme + override CSS variables — zero-to-styled in one prop.
<BlogContent
content={post.content}
theme="dark"
style={{
'--cs-link': '#f472b6',
'--cs-font-heading': 'ui-serif, Georgia, serif',
'--cs-max-width': '72ch',
} as React.CSSProperties}
/>
// 2. Per-slot className overrides — tweak specific nodes.
<BlogContent
content={post.content}
classNames={{
heading: 'font-black tracking-tighter',
link: 'text-red-600 no-underline',
blockquote: 'border-pink-500',
}}
/>
// 3. Component takeover — swap whole renderers.
import Image from 'next/image';
<BlogContent
content={post.content}
components={{
image: (props) => <Image width={1200} height={630} {...props} />,
pre: MySyntaxHighlighter,
}}
/>Variants
<BlogContent variant="default" /> // cs-* classes + shipped styles.css
<BlogContent variant="prose" /> // adds Tailwind `prose` + cs-* classes
<BlogContent variant="unstyled" /> // zero classes — you own stylingAny Google Font, one prop
Pass a fonts prop and the SDK:
- Builds the correct Google Fonts CSS v2 URL.
- Renders a
<link rel="stylesheet">(React 19 hoists it to<head>). - Sets
--cs-font-body,--cs-font-heading,--cs-font-monofor you.
<BlogContent
content={post.content}
fonts={{
body: 'Lora',
heading: 'Playfair Display',
mono: 'JetBrains Mono',
weights: [400, 500, 700, 900], // optional; default [400,500,600,700]
display: 'swap', // optional; default 'swap'
}}
/>Any of the ~1,500 families on fonts.google.com works — just pass the exact family name.
Already loading a font via next/font/google or a global stylesheet? Skip the auto-injected <link>:
<BlogContent fonts={{ body: 'Inter', skipLink: true }} content={post.content} />Need the link tag rendered somewhere else (e.g. your root layout's <head>)?
import { GoogleFontsLink } from '@crawlsphere/react';
<head>
<GoogleFontsLink fonts={{ body: 'Inter', heading: 'Playfair Display' }} />
</head>Curated pairings live in the FONT_PAIRINGS export — drop one straight into the fonts prop:
import { BlogContent, FONT_PAIRINGS } from '@crawlsphere/react';
<BlogContent content={post.content} fonts={FONT_PAIRINGS.softSerif} />
// editorial | magazine | newsprint | docs | terminal |
// softSerif | brutalist | retro | elegant | friendlyBuilt-in themes
Eight ready-to-use reading aesthetics — pass any of them to theme:
| theme | vibe | when to use |
|--------------|--------------------------------------------|--------------------------------------------|
| light | Neutral sans, clean defaults | Default. General-purpose. |
| dark | Same, dark palette | Dark mode / night reading. |
| sepia | Warm serif, paper-tone background | Reading mode. Longform. |
| editorial | Charter serif body, sans headings, roomy | Personal essays, Medium-style posts. |
| docs | Inter sans, tight, purple accent | Technical docs, API references, Stripe/Linear vibe. |
| newsprint | All-serif, narrow column, indented paras | News / opinion. Classic broadsheet feel. |
| terminal | Monospace everything, phosphor-on-black | Engineering posts, changelogs, CLI tools. |
| magazine | Huge condensed headings, high contrast | Launch posts, manifestos, statement pieces.|
<BlogContent content={post.content} theme="editorial" />
<BlogContent content={post.content} theme="docs" />
<BlogContent content={post.content} theme="terminal" />Themes are just [data-theme="…"] selectors in the shipped CSS — so you can add your own (theme="brand") by writing a matching CSS block that overrides the variables.
Headings automatically get id="slugified-title" for deep links.
AI agent cheat sheet
Copy this entire block into your assistant (Claude, GPT, Cursor, the
@crawlsphere/mcp-server, whatever) when you want it to design the look of a
blog post for you. It contains every lever the SDK exposes.
You are styling a `<BlogContent>` component from `@crawlsphere/react`.
You have four props to work with (use the cheapest one that does the job):
### 1. `theme` — single-word aesthetic
One of: `light` (default), `dark`, `sepia`, `editorial`, `docs`,
`newsprint`, `terminal`, `magazine`.
- `editorial` — Medium-style serif body, sans headings, 18px, roomy.
- `docs` — Stripe/Linear: Inter, tight 15px, purple accent, code-heavy.
- `newsprint` — NYT broadsheet: all-serif, narrow, indented paragraphs.
- `terminal` — hacker: monospace, GitHub-dim palette, `# `/`## ` prefixes.
- `magazine` — display: 3.5rem Archivo Black headings, red accent.
- `sepia` — warm paper, reading-mode.
- `dark` — neutral dark palette.
### 2. `fonts` — ANY Google Font (~1,500 families)
```tsx
fonts={{
body?: string, // e.g. 'Lora', 'Inter', 'Source Serif 4'
heading?: string, // defaults to body when omitted
mono?: string, // e.g. 'JetBrains Mono', 'IBM Plex Mono'
weights?: number[], // default [400,500,600,700]
display?: 'swap' | 'block' | 'fallback' | 'auto' | 'optional',
skipLink?: boolean, // true if the font is already loaded elsewhere
}}
```
The SDK auto-renders the `<link rel="stylesheet">` and wires the CSS vars.
Curated pairings (import `FONT_PAIRINGS`):
- `editorial` { body: 'Source Serif 4', heading: 'Inter' }
- `magazine` { body: 'Inter', heading: 'Archivo Black' }
- `newsprint` { body: 'Lora', heading: 'Playfair Display' }
- `docs` { body: 'Inter', heading: 'Inter' }
- `softSerif` { body: 'Fraunces', heading: 'Fraunces' }
- `brutalist` { body: 'IBM Plex Sans', heading: 'Space Grotesk' }
- `retro` { body: 'Work Sans', heading: 'Space Mono' }
- `elegant` { body: 'Cormorant Garamond', heading: 'Cormorant Garamond' }
- `friendly` { body: 'Nunito', heading: 'Quicksand' }
### 3. `style` — CSS custom properties, single prop
Every visible property is a CSS variable. Set as many as needed:
```
--cs-font-body --cs-text --cs-quote-bg
--cs-font-heading --cs-text-muted --cs-quote-accent
--cs-font-mono --cs-heading --cs-quote-text
--cs-font-size --cs-link --cs-code-text
--cs-line-height --cs-link-hover --cs-code-bg
--cs-max-width --cs-strong --cs-code-border
--cs-letter-spacing --cs-rule --cs-pre-text
--cs-pre-bg
--cs-gap-block --cs-caption --cs-radius
--cs-gap-heading-top
--cs-gap-heading-bottom
```
### 4. `classNames` — per-slot Tailwind/utility overrides
```
root paragraph heading h1 h2 h3 h4 h5 h6
bulletList orderedList listItem
blockquote codeBlock code pre
horizontalRule hardBreak
image figure figcaption
link strong em underline strike
```
### 5. `components` — React component takeover for any slot
```tsx
components={{
image: (props) => <Image {...props} />, // e.g. next/image
pre: MySyntaxHighlighter,
link: MyLink, // useful for router links
}}
```
## Workflow
1. Pick the closest `theme` for the overall vibe.
2. Add `fonts` if the user asked for a specific typography feel.
3. Tweak with `style` CSS variables for colors / column width / spacing.
4. Only reach for `classNames` or `components` for changes the above can't
express.
Emit the smallest possible diff that achieves the user's intent —
ideally a single prop.Client API
import { createClient } from '@crawlsphere/react';
const client = createClient({ apiKey: 'cs_live_...', domain: 'mysite.com' });
await client.listPosts({ status: 'published', limit: 20 });
await client.getPost('my-slug');
await client.createPost({ title: 'Hello', content: { type: 'doc', content: [] } });
await client.updatePost('my-slug', { status: 'published' });
await client.deletePost('my-slug');
await client.uploadImage(file);
await client.generateBlogPost({ competitorId: 'abc', mode: 'competitive' });
await client.listCompetitors();
await client.createCompetitor({ name: 'Acme', website: 'https://acme.com' });All methods support AbortSignal:
const ctrl = new AbortController();
client.listPosts({}, { signal: ctrl.signal });
ctrl.abort();Hooks
| Hook | Type | Description |
|------|------|-------------|
| usePosts(params?) | query | Paginated list of posts |
| usePost(slug) | query | Single post by slug (includes pre-rendered SEO) |
| useCompetitors(params?) | query | List competitors |
| useCreatePost() | mutation | Create a post |
| useUpdatePost() | mutation | Update a post |
| useDeletePost() | mutation | Delete a post |
| useUploadImage() | mutation | Upload image to CrawlSphere storage |
| useGenerateBlogPost() | mutation | AI-generate a blog post from a competitor |
| useCreateCompetitor() | mutation | Create a competitor |
Query hooks return { data, error, isLoading, isFetching, refetch }.
Mutation hooks return { data, error, isLoading, <action>, reset }.
Error handling
import {
CrawlSphereError,
AuthenticationError,
NotFoundError,
InsufficientCreditsError,
} from '@crawlsphere/react';
try {
await client.generateBlogPost({ competitorId: 'abc' });
} catch (err) {
if (err instanceof InsufficientCreditsError) {
console.log(`Need ${err.required}, have ${err.credits}`);
} else if (err instanceof NotFoundError) {
// 404
} else if (err instanceof CrawlSphereError) {
console.log(err.status, err.code, err.data);
}
}Environment variables
The client automatically reads these if the matching option isn't passed:
CRAWLSPHERE_API_KEYCRAWLSPHERE_API_URL(default:https://crawlsphere.com)CRAWLSPHERE_DOMAIN
License
MIT © CrawlSphere
