Schemas overview
TwoTaps lets you model rich, nested content structures (pages, blocks, forms, reusable components) using a hierarchical schema system. This guide explains:
- Core schema concepts (TTSchema, fields, recursion, revisions, templates)
- How published page data is exposed through the public GraphQL endpoint
- How to integrate with Next.js (pages router or app router) using the
@twotaps/site-utils-*packages - Dynamic page listing & filtering (publicPages endpoint)
- Rendering schemas & nested fields safely
- Preview & draft mode integration
- Forms & submissions
- Performance, caching & revalidation
This doc is for external site developers integrating TwoTaps content, not for internal contributors to the monorepo.
New to TwoTaps schemas? Learn how to generate TypeScript types for your schemas in the Generating schema types guide for type-safe development.
Quick glossary
| Term | Meaning |
|---|---|
| TTSchema | A content structure definition (e.g. HeroBlock, LocationDetails). |
| TTSchemaField | A field definition inside a schema (text, image, schema (nested), etc.). |
| Page Revision | A captured snapshot of a page’s schema blocks + values (draft or published). |
| Revision Schema | One instance of a schema inside a revision (with properties filled). |
| Template | A reusable layout grouping schemas; pages reference a template revision. |
| Properties | Field values bound to a schema instance. Flattened via propertyMapping. |
| publicListPages | GraphQL query to list pages by type, filters & location. |
| Visual Editor | In-browser editor embedding your site via a special preview path. |
Core architecture
Every published page in TwoTaps resolves to one or more ordered revision schema blocks. Each block references a concrete TTSchema definition plus its captured properties.
Key points:
- Schemas can nest other schemas up to depth
MAX_SCHEMA_RECURSION_DEPTH = 10. - A field of type
Schemacan either be single or multi-valued (hasMultipleValues). - Page content is materialised on publish into the page’s published revision; the public API exposes that revision.
- Templates themselves have revisions; when a revision schema includes a
template, its published revision schema blocks are inlined after a synthetic anchor block.
Data shape on the public API
When you fetch a page (GET_PAGE, GET_URL_RESOLUTION, or revision APIs) you ultimately receive an array of revision schema objects. The helper parseResponse normalises & flattens this into an array of TTSchemaCustomDto items:
{
id: number; // unique revision schema id
name: string; // schema name (slug)
sortOrder: number; // ordering
anchorId?: string; // optional anchor
anchorOnly: boolean; // template anchor placeholder
label?: string; // optional label applied by editors
campaign?: boolean; // campaign specific block
inHeader?: boolean; // designated header block
inFooter?: boolean; // designated footer block
properties: TTSchemaProperties[]; // raw field values (id, name, value[])
fields [...resolved via propertyMapping]
}Field values & flattening
Raw properties use numeric field IDs and arrays of stringified values (or nested arrays for nested schemas). The helper:
propertyMapping(properties, fields, subSchemas)→ returns a name-keyed JS object.- Handles recursion & multi-value arrays for nested schemas.
- Converts single-value non-schema fields to scalars for easier component props.
Example flattening:
// Schema fields:
// title: Text
// images: Image (multi)
// location: Schema (single) -> fields: city(Text), coords(Schema multi) -> fields: lat(Text), lng(Text)
// Raw properties (simplified):
[
{ fieldId: 1, name: 'title', value: ['Welcome'] },
{ fieldId: 2, name: 'images', value: ['https://cdn/a.jpg', 'https://cdn/b.jpg'] },
{ fieldId: 3, name: 'location', value: [
[
{ fieldId: 4, name: 'city', value: ['Paris'] },
{ fieldId: 5, name: 'coords', value: [
[ { fieldId: 6, name: 'lat', value: ['48.85'] }, { fieldId: 7, name: 'lng', value: ['2.35'] } ],
[ { fieldId: 6, name: 'lat', value: ['48.86'] }, { fieldId: 7, name: 'lng', value: ['2.34'] } ]
] }
]
]}
]
// propertyMapping result:
{
title: 'Welcome',
images: ['https://cdn/a.jpg', 'https://cdn/b.jpg'],
location: {
city: 'Paris',
coords: [
{ lat: '48.85', lng: '2.35' },
{ lat: '48.86', lng: '2.34' }
]
}
}Packages overview
| Package | Use case |
|---|---|
@twotaps/site-utils-base | Core GraphQL queries, DTO classes, parsing utilities, PageService. |
@twotaps/site-utils-react-base | React hooks & contexts (useDynamicPageFiltering, PageContext, forms). |
@twotaps/site-utils-nextjs | Next.js (pages router) helpers: resolveUrl, error templates, API route handler. |
@twotaps/site-utils-nextjs14 | Next.js App Router helpers (server actions, caching & revalidation helpers). |
Install (from npm registry):
# Yarn recommended
yarn add @twotaps/site-utils-base @twotaps/site-utils-react-base @twotaps/site-utils-nextjs
# For app router projects (Next.js 13/14)
yarn add @twotaps/site-utils-nextjs14Required environment variables
Set at build time (and exposed for client when prefixed with NEXT_PUBLIC_):
NEXT_PUBLIC_GRAPHQL_URL→ GraphQL endpoint (defaults to https://api-duo.twotaps.io/graphql if omitted).NEXT_PUBLIC_HOST→ Canonical public domain (e.g.https://www.example.com). Used in sitemap, robots and resolve calls.NEXT_PUBLIC_BASE_PATH→ If your Next.js app is served from a sub-path (e.g./site). Optional.
Fetching and rendering a page (Pages Router)
Create a catch-all route pages/[...rest].tsx:
import { PageService, parseParams, PageDto, parseResponse } from '@twotaps/site-utils-react-base';
import { PageContext } from '@twotaps/site-utils-react-base';
export async function getStaticProps(ctx) {
const pageService = new PageService(process.env.NEXT_PUBLIC_GRAPHQL_URL);
const { isVisualEditor, slug } = parseParams(
Array.isArray(ctx.params?.rest) ? ctx.params.rest : [],
process.env.NEXT_PUBLIC_BASE_PATH,
);
if (isVisualEditor) {
return pageService.getRevision(parseInt(slug, 10), ctx.previewData || {});
}
return pageService.resolveUrl(slug, process.env.NEXT_PUBLIC_HOST);
}
export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' };
}
export default function Page({ page }: { page: PageDto }) {
return (
<PageContext.Provider value={{ page }}>
{page.ttschema.map((block) => (
<BlockRenderer key={block.id} block={block} />
))}
</PageContext.Provider>
);
}A simple block renderer:
function BlockRenderer({ block }) {
if (block.anchorOnly) return <div id={block.anchorId || `anchor-${block.id}`} />;
const props = block.properties; // Already flattened
switch (block.name) {
case 'hero_block':
return <Hero title={props.title} images={props.images} />;
case 'location_details':
return <Location city={props.location.city} coords={props.location.coords} />;
default:
return null;
}
}Fetching and rendering (App Router / Next.js 13/14)
Create app/[[...rest]]/page.tsx:
import { resolveUrlCached, PageService } from '@twotaps/site-utils-nextjs14/server';
import { PageContext } from '@twotaps/site-utils-react-base';
export const revalidate = 0; // blocking fallback
export default async function Page({ params }) {
const result = await resolveUrlCached({
params: { rest: params.rest || [] },
previewData: { draftMode: false },
});
if (result.notFound) return <NotFound />;
const { page } = result.props;
return (
<PageContext.Provider value={{ page }}>
{page.ttschema.map((block) => (
<BlockRenderer key={block.id} block={block} />
))}
</PageContext.Provider>
);
}Caching & revalidation (App Router)
resolveUrlCachedwrapsresolveUrlwithunstable_cache.- Each page path maps to a tag via
getNextPageCacheKey. - Hit
/api/revalidate?path=/some/page(using your custom route) to triggerrevalidateTag.
Preview & draft mode
TwoTaps Visual Editor requests a special preview path built via the API endpoint.
Pages Router: Add an API route (e.g. pages/api/twotaps/[endpoint].ts) and map handler:
import { handler } from '@twotaps/site-utils-nextjs';
export default handler;App Router: Add app/api/twotaps/[endpoint]/route.ts:
import { handleApiRequest } from '@twotaps/site-utils-nextjs14/server';
export async function GET(request, { params }) {
return handleApiRequest(request, { params });
}Preview query parameters:
revisionId(required for block-level preview)pageId(optional)campaignId(optional)block(optional)draftMode=trueto enable Next.js draft mode (app router)
Dynamic page listing (publicPages endpoint)
Use the useDynamicPageFiltering hook to list pages of a given type with filters & location context.
import { useDynamicPageFiltering } from '@twotaps/site-utils-react-base';
export function LocationsList() {
const pages = useDynamicPageFiltering({
pageType: 'location_page',
limit: 20,
offset: 0,
query: 'coffee',
filters: { city: 'Paris' },
fields: ['customProperties.city', 'customProperties.address'],
orderBy: { field: 'customProperties.city', direction: 'ASC' },
});
if (pages.fetching) return <p>Loading...</p>;
if (pages.error) return <pre>{pages.error}</pre>;
return (
<ul>
{pages.items.map((page) => (
<li key={page.id}>
{page.name} – {page.customProperties?.city}
</li>
))}
</ul>
);
}Hook props of note:
pageType(string) → required, matches a TwoTaps Page Type slug.filters→ key/value pairs matching custom field names (flattened by TwoTaps).fields→ explicit whitelist of fields to return (use dot notationcustomProperties.fieldName). If provided, you must include all needed fields.limitPageId|limitPageParentId|limitPageAncestorId→ scope results.latitude|longitude|radius→ geo queries (when the page type supports location fields).
Rendering forms
Forms are separate entities but can be embedded via a schema block referencing a form ID.
Fetch & render:
import { useForm } from '@twotaps/site-utils-react-base';
import { Form } from '@twotaps/site-utils-react-base';
export function ContactForm({ formId }) {
const [form] = useForm(formId);
if (!form) return null;
return <Form form={form} handleRedirect={(url) => (window.location.href = url)} />;
}Validation constraints supported (examples): REQUIRED, STRING_LENGTH, STRING_MINIMUM_LENGTH, STRING_MAXIMUM_LENGTH, STRING_EMAIL, STRING_MATCH.
reCAPTCHA integration:
- Provide site key in TwoTaps form settings; exposed as
recaptchaSiteToken. - Supported bot protections:
reCAPTCHA_v2,reCAPTCHA_v2_Invisible,reCAPTCHA_v3.
Images & asset URLs
Use generateImageUrl(src, { width, height }) to request resized variants if the asset server supports dynamic scaling.
const url = generateImageUrl(block.properties.heroImage, { width: 800 });Error templates & custom 404/500
Load an error template schema for site-wide 404 or 500 handling:
import { loadErrorTemplate, ErrorTemplateType } from '@twotaps/site-utils-nextjs';
export async function getStaticProps() {
return loadErrorTemplate(ErrorTemplateType.Error404);
}App Router variant: use loadErrorTemplate from @twotaps/site-utils-nextjs14/server within a route handler.
Handling anchors (in-page navigation)
Template anchors appear as anchorOnly: true blocks (with name: 'anchor'). Render an empty div with ID so you can build a table of contents:
if (block.anchorOnly) {
return <div id={block.anchorId || `anchor-${block.id}`} />;
}Campaign blocks
When campaign: true the block belongs to a running or draft campaign variant. You can:
const variantLabel = block.campaign ? block.label : undefined;Use this to style A/B variants or display badges.
Performance & best practices
- Prefer server-side fetching via
resolveUrlto avoid client waterfalls. - Cache page data (
resolveUrlCached) and tag revalidation for selective updates. - Avoid unnecessary deep copies; use
block.propertiesdirectly (already flattened & stable). - Debounce dynamic filtering inputs to reduce network load.
- Include
fieldsin public listing queries for smaller payloads. - Use
console.errorfor diagnostics (TwoTaps lint forbidsconsole.log).
Common pitfalls
| Issue | Cause | Fix |
|---|---|---|
Empty properties | Field definitions removed after publish | Guard against missing fields & refresh revision. |
| Preview shows 404 | Missing/invalid revisionId or not using TwoTaps preview endpoint | Ensure preview API route installed. |
| Nested schema not flattened | Using raw GraphQL response instead of parseResponse | Always pass through parseResponse. |
| Filters ignored in listing | Field name mismatch (case-sensitive) | Use exact field names as defined in TwoTaps (lowercase if shown so). |
| Revalidation has no effect | Wrong path or base path mismatch | Strip leading slash & base path before computing cache key. |
End-to-end example (Pages Router)
// pages/[...rest].tsx
import { PageService, parseParams, PageContext } from '@twotaps/site-utils-react-base';
import { Hero, Location, ContactForm } from '../components';
export async function getStaticProps(ctx) {
const pageService = new PageService(process.env.NEXT_PUBLIC_GRAPHQL_URL);
const { isVisualEditor, slug, ...preview } = parseParams(
Array.isArray(ctx.params?.rest) ? ctx.params.rest : [],
process.env.NEXT_PUBLIC_BASE_PATH,
);
return isVisualEditor
? pageService.getRevision(parseInt(slug, 10), preview)
: pageService.resolveUrl(slug, process.env.NEXT_PUBLIC_HOST);
}
export function getStaticPaths() {
return { paths: [], fallback: 'blocking' };
}
function BlockRenderer({ block }) {
if (block.anchorOnly) return <div id={block.anchorId || `anchor-${block.id}`} />;
const p = block.properties;
switch (block.name) {
case 'hero_block':
return <Hero title={p.title} images={p.images} />;
case 'location_details':
return <Location city={p.location.city} coords={p.location.coords} />;
case 'contact_form_block':
return <ContactForm formId={p.formId} />;
default:
return null;
}
}
export default function Page({ page }) {
return (
<PageContext.Provider value={{ page }}>
{page.ttschema.map((b) => (
<BlockRenderer key={b.id} block={b} />
))}
</PageContext.Provider>
);
}Troubleshooting
- Verify ENV setup:
NEXT_PUBLIC_GRAPHQL_URL,NEXT_PUBLIC_HOST, optionalNEXT_PUBLIC_BASE_PATH. - Inspect network responses – ensure
APOLLO-REQUIRE-PREFLIGHTheader not stripped by proxies. - For preview: check constructed redirect path includes
__twotaps_visual_editor__segment. - For dynamic listings: log the hashed dependencies (filters, ids) if updates not triggering.
- Use
console.error(notconsole.log) for errors to align with linting.
Next steps
- Add analytics or tracking around rendered blocks.
- Implement incremental static regeneration with selective tag revalidation.
- Build a schema component registry for type-safe rendering.
Have questions or feature requests? Reach out to your TwoTaps support contact.