@qoh/core-react
v1.0.0
Published
Queen of hearts Core React API
Keywords
Readme
@qoh/core-react
React client library for headless.li — the Semantic Layer for AI Agents.
Build CMS-driven websites with zero hallucinations at a fraction of the (token) cost.
Stop fighting GraphQL. Start shipping.
headless.li is a middleware platform that connects GraphQL-based headless CMS systems to your React components — without manually writing any GraphQL queries. Works with:
Drupal · WordPress · AEM · Sitecore · Contentstack · Hygraph · Strapi · Payload · Sanity · Directus (and any other GraphQL-based CMS)
Register your components once against CMS type names, call service.query() with a query name
and slug, and pass the result to <QueenofheartsRenderComponent />. The library resolves the
right component for each content block automatically based on __typename.
Installation
npm install @qoh/core-reactRequires React 17, 18, or 19 as a peer dependency.
Quick start
1. Initialize the service
Call QueenofheartsService.init() once at application startup (e.g. in your root layout or _app.tsx) with your headless.li API token.
import { QueenofheartsService } from '@qoh/core-react';
QueenofheartsService.init('your-headlessli-api-token');You can get a free token at headless.li
2. Register your components
Map each CMS content type (__typename) to the React component that should render it.
import { registerComponent } from '@qoh/core-react';
import HeroSection from './components/HeroSection';
import TextBlock from './components/TextBlock';
registerComponent(HeroSection, 'HeroRecord');
registerComponent(TextBlock, 'TextBlockRecord');3. Fetch page data
Use QueenofheartsService.getInstance().query() to retrieve CMS content. The service automatically sends the registered component list to the API so only the fields your components actually use are returned.
const service = QueenofheartsService.getInstance();
const page = await service.query('page', {
variables: {
filter: [{ name: 'slug', operator: Filter.eq, value: 'home' }],
},
});4. Render the content
Pass the block array from the CMS response to <QueenofheartsRenderComponent />. It iterates the array and renders the correct registered component for each block based on __typename.
import { QueenofheartsRenderComponent } from '@qoh/core-react';
export default function Page({ page }) {
return <QueenofheartsRenderComponent data={page.sections} />;
}Component registration
Eager components
registerComponent(MyComponent, 'MyRecord');registerComponent accepts an optional Zod schema as the third argument (see Zod schemas).
By default, a component is registered only once. Pass true as the fourth argument to overwrite an existing registration:
registerComponent(MyComponent, 'MyRecord', schema, true);Lazy components
For code-splitting, register a dynamic import loader instead of the component directly:
import { registerLazyComponent } from '@qoh/core-react';
registerLazyComponent(() => import('./components/HeavyChart'), 'ChartRecord');The loader is called the first time that component type is encountered and the resolved module cached in the registry. QueenofheartsRenderComponent wraps rendering in <Suspense> automatically.
Pre-loading lazy components
If you know which component types a page will need before rendering, you can pre-load them:
import { fetchDynamicComponents } from '@qoh/core-react';
await fetchDynamicComponents(pageData);This walks pageData recursively, collects all __typename values, and triggers the loaders for any registered lazy components that have not been loaded yet.
Zod schemas — selective field fetching
When you register a component with a Zod schema, the library derives a ComponentFieldMap from the schema and sends it to the headless.li API. The API then restricts each component's query to only the fields that schema describes, reducing payload size.
import { z } from 'zod';
import { registerComponent } from '@qoh/core-react';
const HeroSchema = z.object({
__typename: z.string(),
headline: z.string(),
subline: z.string().optional(),
image: z.object({
url: z.string(),
alt: z.string().nullable(),
}),
});
registerComponent(HeroSection, 'HeroRecord', HeroSchema);For union/polymorphic fields (an array of blocks of different types), use .loose() on the object — this signals to the library that child component resolution should be delegated:
const PageSchema = z.object({
__typename: z.string(),
sections: z.array(z.object({ __typename: z.string() }).loose()),
});Without a schema the library requests all fields ({ __all: true }).
Querying content
service.query(queryName, options?)
Fetches CMS records by query name. The query name corresponds to a root-level query in your CMS GraphQL schema (e.g. page, blogPost, product).
const result = await service.query('page', {
variables: {
filter: [{ name: 'slug', operator: Filter.eq, value: 'about' }],
locale: 'en',
},
depth: 3,
});Options:
| Option | Type | Description |
|---|---|---|
| variables.filter | { name, operator, value }[] | Filter conditions |
| variables.locale | string | Locale code |
| variables | Record<string, unknown> | Any additional CMS-specific variables |
| ignoreProperties | string[] | CMS fields to exclude from the response |
| depth | number | Relation nesting depth |
| fieldArgs | Record<string, unknown> | Extra per-field arguments |
Filter operators (via the Filter enum):
import { Filter } from '@qoh/core-react';
Filter.eq // field equals value
Filter.neq // field does not equal value
Filter.in // field is one of (value is comma-separated)
Filter.notIn // field is not one ofservice.queryGraphql(graphqlQuery)
Sends a raw GraphQL query string to the headless.li proxy. Use this for one-off queries that don't map to registered components.
const result = await service.queryGraphql(`
query {
allProducts {
id
title
}
}
`);Passing shared props to all components
Use contextProps to inject shared data (router, i18n, feature flags, etc.) into every component rendered by <QueenofheartsRenderComponent />. Each component receives the contextProps object merged with its CMS data.
<QueenofheartsRenderComponent
data={page.sections}
contextProps={{ locale: 'en', router }}
/>Error handling
API errors throw an ApiError instance:
import { ApiError } from '@qoh/core-react';
try {
const page = await service.query('page', options);
} catch (e) {
if (e instanceof ApiError) {
console.error(e.status, e.body.summary, e.body.details);
}
}Browser devtools
When the <body> element has the class qoh-inject-ids, the service enters debug mode. It injects a __qohId attribute into every CMS block object and listens for custom window events emitted by the headless.li browser devtools extension to support component highlighting and data inspection.
API reference
| Export | Kind | Description |
|---|---|---|
| QueenofheartsService | class | Singleton service. Call .init() once, then .getInstance() everywhere. |
| QueenofheartsRenderComponent | React component | Renders a CMS data array or object by dispatching to registered components. |
| registerComponent | function | Registers an eager React component against a CMS type name. |
| registerLazyComponent | function | Registers a lazy-loaded component with a dynamic import loader. |
| fetchDynamicComponents | function | Pre-loads lazy components found in a data tree. |
| getAllregisteredComponents | function | Returns the current component registry. |
| unregisterComponent | function | Removes a component from the registry by type name. |
| zodToComponentFields | function | Converts a Zod schema to a ComponentFieldMap. |
| Filter | enum | Filter operators: eq, neq, in, notIn. |
| ApiError | class | Error class thrown on non-2xx API responses. |
| componentField | Zod schema | Zod schema for a generic polymorphic block array field. |
