Skip to Content
DocumentationSchemasOverview

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

TermMeaning
TTSchemaA content structure definition (e.g. HeroBlock, LocationDetails).
TTSchemaFieldA field definition inside a schema (text, image, schema (nested), etc.).
Page RevisionA captured snapshot of a page’s schema blocks + values (draft or published).
Revision SchemaOne instance of a schema inside a revision (with properties filled).
TemplateA reusable layout grouping schemas; pages reference a template revision.
PropertiesField values bound to a schema instance. Flattened via propertyMapping.
publicListPagesGraphQL query to list pages by type, filters & location.
Visual EditorIn-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 Schema can 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

PackageUse case
@twotaps/site-utils-baseCore GraphQL queries, DTO classes, parsing utilities, PageService.
@twotaps/site-utils-react-baseReact hooks & contexts (useDynamicPageFiltering, PageContext, forms).
@twotaps/site-utils-nextjsNext.js (pages router) helpers: resolveUrl, error templates, API route handler.
@twotaps/site-utils-nextjs14Next.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-nextjs14

Required 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)

  • resolveUrlCached wraps resolveUrl with unstable_cache.
  • Each page path maps to a tag via getNextPageCacheKey.
  • Hit /api/revalidate?path=/some/page (using your custom route) to trigger revalidateTag.

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=true to 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 notation customProperties.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 resolveUrl to avoid client waterfalls.
  • Cache page data (resolveUrlCached) and tag revalidation for selective updates.
  • Avoid unnecessary deep copies; use block.properties directly (already flattened & stable).
  • Debounce dynamic filtering inputs to reduce network load.
  • Include fields in public listing queries for smaller payloads.
  • Use console.error for diagnostics (TwoTaps lint forbids console.log).

Common pitfalls

IssueCauseFix
Empty propertiesField definitions removed after publishGuard against missing fields & refresh revision.
Preview shows 404Missing/invalid revisionId or not using TwoTaps preview endpointEnsure preview API route installed.
Nested schema not flattenedUsing raw GraphQL response instead of parseResponseAlways pass through parseResponse.
Filters ignored in listingField name mismatch (case-sensitive)Use exact field names as defined in TwoTaps (lowercase if shown so).
Revalidation has no effectWrong path or base path mismatchStrip 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

  1. Verify ENV setup: NEXT_PUBLIC_GRAPHQL_URL, NEXT_PUBLIC_HOST, optional NEXT_PUBLIC_BASE_PATH.
  2. Inspect network responses – ensure APOLLO-REQUIRE-PREFLIGHT header not stripped by proxies.
  3. For preview: check constructed redirect path includes __twotaps_visual_editor__ segment.
  4. For dynamic listings: log the hashed dependencies (filters, ids) if updates not triggering.
  5. Use console.error (not console.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.

Last updated on