This guide explains how to work with structured content in Lynkow. Structured content replaces the freeform rich-text body with a typed schema of custom fields -- prices, specifications, galleries, and anything else you define in the dashboard. You will learn how to detect structured content, fetch the schema, and build a universal renderer that handles every field type.

Standard vs Structured Categories

Every category in Lynkow has a contentMode property:

  • standard -- Contents have a body field containing rendered HTML from the rich-text editor. The customData field is null.

  • structured -- Contents have a customData object populated with typed fields that match the category's schema. The body field may be empty or absent.

You configure this in the Lynkow dashboard when creating or editing a category. Once a category is set to structured, you define a schema of fields (string, number, richtext, image, array, object, etc.) and every content entry in that category stores its data in customData instead of body.

Detecting Structured Content

When you fetch a content item, check customData:

TypeScript
const article = await lynkow.contents.getBySlug('wireless-headphones')

if (article.customData !== null) {
  // This is structured content -- render from customData
  console.log(article.customData.price)    // 79.99
  console.log(article.customData.specs)    // { weight: '250g', battery: '30h' }
} else {
  // This is standard content -- render body HTML
  console.log(article.body)               // '<h2>Wireless Headphones</h2>...'
}

Fetching the Schema

The schema defines what fields exist and their types. It lives on the category, not on each content item. Fetch it with categories.getBySlug():

TypeScript
const { category } = await lynkow.categories.getBySlug('products')

console.log(category.contentMode)  // 'structured'
console.log(category.schema)
// {
//   fields: [
//     { key: 'price', type: 'number', label: 'Price', required: true },
//     { key: 'description', type: 'richtext', label: 'Description' },
//     { key: 'gallery', type: 'array', label: 'Gallery', fields: [
//       { key: 'image', type: 'image', label: 'Image' },
//       { key: 'caption', type: 'string', label: 'Caption' }
//     ]},
//     { key: 'specs', type: 'object', label: 'Specifications', fields: [
//       { key: 'weight', type: 'string', label: 'Weight' },
//       { key: 'battery', type: 'string', label: 'Battery Life' }
//     ]}
//   ]
// }

Schema Types

TypeScript
type SchemaFieldType =
  | 'string'       // Short text
  | 'text'         // Multi-line text
  | 'richtext'     // HTML (rendered from TipTap editor)
  | 'number'       // Numeric value
  | 'boolean'      // True/false toggle
  | 'select'       // Single choice from options
  | 'multiselect'  // Multiple choices from options
  | 'image'        // Image URL (CDN)
  | 'file'         // File URL (CDN)
  | 'url'          // External URL
  | 'email'        // Email address
  | 'date'         // Date string (YYYY-MM-DD)
  | 'datetime'     // ISO datetime string
  | 'color'        // Hex color (#ff0000)
  | 'object'       // Nested group of sub-fields
  | 'array'        // Repeatable list of sub-fields
  | 'dataSource'   // Dynamic data injection (reviews, contents, etc.)

SchemaField Properties

Each field in the schema has these properties:

Property

Type

Description

key

string

The property name in customData

type

SchemaFieldType

Determines how to render the value

label

string

Human-readable label

required

boolean?

Whether the field is required

placeholder

string?

Placeholder hint

helpText

string?

Description for editors

defaultValue

any?

Default value

validation

object?

{ minLength, maxLength, min, max, pattern }

fields

SchemaField[]?

Sub-fields for object and array types

itemLabel

string?

Label for array items

minItems

number?

Minimum array length

maxItems

number?

Maximum array length

options

SelectOption[]?

Options for select and multiselect

Type-to-Rendering Mapping

Here is how each field type maps to a rendering strategy:

Field Type

customData Value

Rendering

string

"Hello"

Plain text in a <span> or <p>

text

"Multi-line\ntext"

<p> with whitespace-pre-wrap

richtext

"<h2>Title</h2><p>Body...</p>"

dangerouslySetInnerHTML with prose class

number

79.99

Formatted text (e.g., currency, units)

boolean

true

Conditional rendering or badge

select

"red"

Look up label from schema options

multiselect

["red", "blue"]

Look up labels, render as tags

image

"https://cdn.../photo.jpg"

<img> tag

file

"https://cdn.../doc.pdf"

<a> download link

url

"https://example.com"

<a> external link

email

"[email protected]"

<a href="mailto:...">

date

"2025-06-15"

Formatted date string

datetime

"2025-06-15T14:30:00Z"

Formatted date and time

color

"#3b82f6"

Color swatch

object

{ weight: "250g" }

Recursively render sub-fields

array

[{ image: "...", caption: "..." }]

Map and render each item's sub-fields

Rendering Richtext Fields

Richtext fields are returned as pre-rendered HTML (the API converts TipTap JSON to HTML before responding). Render them the same way you render a standard article body:

tsx
interface RichtextFieldProps {
  html: string
}

function RichtextField({ html }: RichtextFieldProps) {
  return (
    <div
      className="prose prose-lg max-w-none"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  )
}

Use the @tailwindcss/typography plugin to style headings, lists, code blocks, and other elements inside the prose container.

Rendering Image and File Fields

Image fields contain a CDN URL. File fields also contain a CDN URL. Render them as standard HTML elements:

tsx
function ImageField({ url, alt }: { url: string; alt?: string }) {
  if (!url) return null
  return (
    <img
      src={url}
      alt={alt || ''}
      className="rounded-lg max-w-full h-auto"
      loading="lazy"
    />
  )
}

function FileField({ url, label }: { url: string; label: string }) {
  if (!url) return null
  const fileName = url.split('/').pop() || 'Download'
  return (
    <a
      href={url}
      download
      className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 underline"
    >
      {label}: {fileName}
    </a>
  )
}

Rendering Select and Multiselect Fields

The customData stores the raw value(s). To display the human-readable label, look it up in the schema's options array:

tsx
import type { SchemaField } from 'lynkow'

function SelectField({
  value,
  field,
}: {
  value: string
  field: SchemaField
}) {
  const option = field.options?.find((o) => o.value === value)
  return <span>{option?.label || value}</span>
}

function MultiselectField({
  values,
  field,
}: {
  values: string[]
  field: SchemaField
}) {
  return (
    <div className="flex flex-wrap gap-2">
      {values.map((value) => {
        const option = field.options?.find((o) => o.value === value)
        return (
          <span
            key={value}
            className="px-2 py-1 text-sm bg-gray-100 text-gray-700 rounded-full"
          >
            {option?.label || value}
          </span>
        )
      })}
    </div>
  )
}

Rendering Object Fields

Object fields contain nested sub-fields. Recurse into them using the same renderer:

tsx
function ObjectField({
  data,
  fields,
}: {
  data: Record<string, any>
  fields: SchemaField[]
}) {
  if (!data) return null
  return (
    <div className="border border-gray-200 rounded-lg p-4 space-y-3">
      {fields.map((subField) => (
        <FieldRenderer
          key={subField.key}
          field={subField}
          value={data[subField.key]}
        />
      ))}
    </div>
  )
}

Rendering Array Fields

Array fields contain a list of items, each of which has the same sub-field structure:

tsx
function ArrayField({
  items,
  field,
}: {
  items: any[]
  field: SchemaField
}) {
  if (!items || items.length === 0) return null
  return (
    <div className="space-y-4">
      {items.map((item, index) => (
        <div
          key={index}
          className="border border-gray-200 rounded-lg p-4"
        >
          {field.itemLabel && (
            <p className="text-sm font-medium text-gray-500 mb-2">
              {field.itemLabel} {index + 1}
            </p>
          )}
          {field.fields?.map((subField) => (
            <FieldRenderer
              key={subField.key}
              field={subField}
              value={typeof item === 'object' ? item[subField.key] : item}
            />
          ))}
        </div>
      ))}
    </div>
  )
}

Complete StructuredContentRenderer

This component handles every field type. It takes the customData from a content item and the schema from its category, then renders each field appropriately.

Create components/structured-content-renderer.tsx:

tsx
import type { SchemaField } from 'lynkow'

// ---------------------------------------------------------------------------
// Main renderer: iterates over schema fields and renders each one
// ---------------------------------------------------------------------------

interface StructuredContentRendererProps {
  data: Record<string, any>
  fields: SchemaField[]
  className?: string
}

export function StructuredContentRenderer({
  data,
  fields,
  className = '',
}: StructuredContentRendererProps) {
  if (!data) return null

  return (
    <div className={`space-y-6 ${className}`}>
      {fields.map((field) => {
        const value = data[field.key]
        if (value === undefined || value === null || value === '') return null

        return (
          <FieldRenderer key={field.key} field={field} value={value} />
        )
      })}
    </div>
  )
}

// ---------------------------------------------------------------------------
// Field renderer: dispatches to the correct sub-component by type
// ---------------------------------------------------------------------------

interface FieldRendererProps {
  field: SchemaField
  value: any
  hideLabel?: boolean
}

export function FieldRenderer({ field, value, hideLabel }: FieldRendererProps) {
  if (value === undefined || value === null || value === '') return null

  return (
    <div>
      {!hideLabel && (
        <dt className="text-sm font-medium text-gray-500 mb-1">
          {field.label}
        </dt>
      )}
      <dd>
        <FieldValue field={field} value={value} />
      </dd>
    </div>
  )
}

// ---------------------------------------------------------------------------
// Value renderer: handles each field type
// ---------------------------------------------------------------------------

function FieldValue({ field, value }: { field: SchemaField; value: any }) {
  switch (field.type) {
    case 'string':
      return <span className="text-gray-900">{value}</span>

    case 'text':
      return (
        <p className="text-gray-900 whitespace-pre-wrap">{value}</p>
      )

    case 'richtext':
      return (
        <div
          className="prose prose-lg max-w-none"
          dangerouslySetInnerHTML={{ __html: value }}
        />
      )

    case 'number':
      return <span className="text-gray-900">{value}</span>

    case 'boolean':
      return (
        <span
          className={`inline-flex items-center px-2 py-1 text-xs font-medium rounded-full ${
            value
              ? 'bg-green-100 text-green-700'
              : 'bg-gray-100 text-gray-500'
          }`}
        >
          {value ? 'Yes' : 'No'}
        </span>
      )

    case 'select': {
      const option = field.options?.find((o) => o.value === value)
      return <span className="text-gray-900">{option?.label || value}</span>
    }

    case 'multiselect': {
      const values = Array.isArray(value) ? value : [value]
      return (
        <div className="flex flex-wrap gap-2">
          {values.map((v) => {
            const option = field.options?.find((o) => o.value === v)
            return (
              <span
                key={v}
                className="px-2 py-1 text-sm bg-gray-100 text-gray-700 rounded-full"
              >
                {option?.label || v}
              </span>
            )
          })}
        </div>
      )
    }

    case 'image':
      return (
        <img
          src={value}
          alt={field.label}
          className="rounded-lg max-w-full h-auto"
          loading="lazy"
        />
      )

    case 'file': {
      const fileName = typeof value === 'string'
        ? value.split('/').pop() || 'Download'
        : value
      return (
        <a
          href={value}
          download
          className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 underline"
        >
          {fileName}
        </a>
      )
    }

    case 'url':
      return (
        <a
          href={value}
          target="_blank"
          rel="noopener noreferrer"
          className="text-blue-600 hover:text-blue-800 underline"
        >
          {value}
        </a>
      )

    case 'email':
      return (
        <a
          href={`mailto:${value}`}
          className="text-blue-600 hover:text-blue-800 underline"
        >
          {value}
        </a>
      )

    case 'date':
      return (
        <time dateTime={value} className="text-gray-900">
          {new Date(value).toLocaleDateString('en-US', {
            year: 'numeric',
            month: 'long',
            day: 'numeric',
          })}
        </time>
      )

    case 'datetime':
      return (
        <time dateTime={value} className="text-gray-900">
          {new Date(value).toLocaleString('en-US', {
            year: 'numeric',
            month: 'long',
            day: 'numeric',
            hour: '2-digit',
            minute: '2-digit',
          })}
        </time>
      )

    case 'color':
      return (
        <div className="flex items-center gap-2">
          <span
            className="inline-block w-6 h-6 rounded border border-gray-300"
            style={{ backgroundColor: value }}
          />
          <span className="text-sm text-gray-600 font-mono">{value}</span>
        </div>
      )

    case 'object':
      if (!field.fields) return null
      return (
        <div className="border border-gray-200 rounded-lg p-4 space-y-3">
          {field.fields.map((subField) => (
            <FieldRenderer
              key={subField.key}
              field={subField}
              value={value[subField.key]}
            />
          ))}
        </div>
      )

    case 'array': {
      if (!Array.isArray(value) || value.length === 0) return null
      return (
        <div className="space-y-4">
          {value.map((item, index) => (
            <div
              key={index}
              className="border border-gray-200 rounded-lg p-4"
            >
              {field.itemLabel && (
                <p className="text-sm font-medium text-gray-500 mb-2">
                  {field.itemLabel} {index + 1}
                </p>
              )}
              {field.fields?.map((subField) => (
                <FieldRenderer
                  key={subField.key}
                  field={subField}
                  value={
                    typeof item === 'object' ? item[subField.key] : item
                  }
                />
              ))}
            </div>
          ))}
        </div>
      )
    }

    case 'dataSource':
      // DataSource fields are resolved server-side; render the injected data
      if (Array.isArray(value)) {
        return (
          <ul className="list-disc list-inside space-y-1">
            {value.map((item, i) => (
              <li key={i} className="text-gray-900">
                {typeof item === 'object' ? item.title || item.name || JSON.stringify(item) : item}
              </li>
            ))}
          </ul>
        )
      }
      return <span className="text-gray-900">{JSON.stringify(value)}</span>

    default:
      return <span className="text-gray-900">{String(value)}</span>
  }
}

Example: Product Page

This example shows a complete product page that uses structured content. The category "Products" is configured with contentMode: 'structured' and a schema containing price, description (richtext), gallery (array of images), and specs (object).

Category schema (configured in Lynkow dashboard)

JSON
{
  "fields": [
    { "key": "price", "type": "number", "label": "Price", "required": true },
    { "key": "currency", "type": "select", "label": "Currency", "options": [
      { "value": "USD", "label": "USD ($)" },
      { "value": "EUR", "label": "EUR" },
      { "value": "GBP", "label": "GBP" }
    ]},
    { "key": "inStock", "type": "boolean", "label": "In Stock" },
    { "key": "description", "type": "richtext", "label": "Description" },
    { "key": "gallery", "type": "array", "label": "Gallery", "itemLabel": "Photo", "fields": [
      { "key": "image", "type": "image", "label": "Image" },
      { "key": "caption", "type": "string", "label": "Caption" }
    ]},
    { "key": "specs", "type": "object", "label": "Specifications", "fields": [
      { "key": "weight", "type": "string", "label": "Weight" },
      { "key": "dimensions", "type": "string", "label": "Dimensions" },
      { "key": "battery", "type": "string", "label": "Battery Life" },
      { "key": "color", "type": "color", "label": "Color" }
    ]},
    { "key": "manual", "type": "file", "label": "User Manual" }
  ]
}

Product page component

Create app/products/[slug]/page.tsx:

tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { lynkow } from '@/lib/lynkow'
import { StructuredContentRenderer } from '@/components/structured-content-renderer'

type Params = Promise<{ slug: string }>

// ---------------------------------------------------------------------------
// Static generation
// ---------------------------------------------------------------------------
export async function generateStaticParams() {
  const { data: contents } = await lynkow.contents.list({
    category: 'products',
    limit: 500,
  })

  return contents.map((product) => ({
    slug: product.slug,
  }))
}

// ---------------------------------------------------------------------------
// SEO metadata
// ---------------------------------------------------------------------------
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
  const { slug } = await params

  try {
    const product = await lynkow.contents.getBySlug(slug)

    return {
      title: product.metaTitle || product.title,
      description: product.metaDescription || product.excerpt || undefined,
      openGraph: {
        title: product.metaTitle || product.title,
        description: product.metaDescription || product.excerpt || undefined,
        images: product.ogImage
          ? [{ url: product.ogImage }]
          : product.featuredImage
            ? [{ url: product.featuredImage }]
            : undefined,
      },
    }
  } catch {
    return { title: 'Product Not Found' }
  }
}

// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
export default async function ProductPage({ params }: { params: Params }) {
  const { slug } = await params

  let product
  try {
    product = await lynkow.contents.getBySlug(slug)
  } catch {
    notFound()
  }

  // Fetch the category to get the schema
  const categorySlug = product.categories[0]?.slug
  let schema = null
  if (categorySlug) {
    const { category } = await lynkow.categories.getBySlug(categorySlug)
    if (category.contentMode === 'structured') {
      schema = category.schema
    }
  }

  // If this is structured content, render from customData
  if (product.customData && schema) {
    return <ProductStructuredView product={product} schema={schema} />
  }

  // Fallback: standard content with body HTML
  return (
    <main className="max-w-3xl mx-auto px-4 py-12">
      <h1 className="text-4xl font-bold">{product.title}</h1>
      <div
        className="mt-8 prose prose-lg max-w-none"
        dangerouslySetInnerHTML={{ __html: product.body }}
      />
    </main>
  )
}

// ---------------------------------------------------------------------------
// Structured product view: custom layout using individual fields
// ---------------------------------------------------------------------------

interface ProductStructuredViewProps {
  product: {
    title: string
    featuredImage: string | null
    featuredImageVariants: Record<string, string> | null
    customData: Record<string, any>
    publishedAt: string
  }
  schema: { fields: SchemaField[] }
}

import type { SchemaField } from 'lynkow'
import { FieldValue } from '@/components/structured-content-renderer'

function ProductStructuredView({ product, schema }: ProductStructuredViewProps) {
  const { customData } = product

  // Look up field definitions from the schema for select/multiselect rendering
  const fieldMap = new Map(schema.fields.map((f) => [f.key, f]))

  return (
    <main className="max-w-6xl mx-auto px-4 py-12">
      <div className="grid lg:grid-cols-2 gap-12">
        {/* Left column: images */}
        <div>
          {/* Featured image */}
          {product.featuredImage && (
            <img
              src={product.featuredImageVariants?.hero || product.featuredImage}
              alt={product.title}
              className="w-full rounded-lg"
            />
          )}

          {/* Gallery */}
          {customData.gallery && customData.gallery.length > 0 && (
            <div className="grid grid-cols-4 gap-2 mt-4">
              {customData.gallery.map(
                (item: { image: string; caption?: string }, i: number) => (
                  <img
                    key={i}
                    src={item.image}
                    alt={item.caption || `Photo ${i + 1}`}
                    className="w-full aspect-square object-cover rounded-md cursor-pointer hover:opacity-80 transition-opacity"
                  />
                )
              )}
            </div>
          )}
        </div>

        {/* Right column: product info */}
        <div>
          <h1 className="text-3xl font-bold">{product.title}</h1>

          {/* Price and stock */}
          <div className="flex items-center gap-4 mt-4">
            {customData.price != null && (
              <span className="text-3xl font-bold text-gray-900">
                {formatPrice(customData.price, customData.currency)}
              </span>
            )}
            {customData.inStock !== undefined && (
              <span
                className={`px-3 py-1 text-sm font-medium rounded-full ${
                  customData.inStock
                    ? 'bg-green-100 text-green-700'
                    : 'bg-red-100 text-red-700'
                }`}
              >
                {customData.inStock ? 'In Stock' : 'Out of Stock'}
              </span>
            )}
          </div>

          {/* Description (richtext) */}
          {customData.description && (
            <div
              className="mt-6 prose max-w-none"
              dangerouslySetInnerHTML={{ __html: customData.description }}
            />
          )}

          {/* Specifications (object) */}
          {customData.specs && (
            <div className="mt-8">
              <h2 className="text-lg font-semibold mb-4">Specifications</h2>
              <dl className="divide-y divide-gray-200">
                {fieldMap.get('specs')?.fields?.map((specField) => {
                  const specValue = customData.specs[specField.key]
                  if (!specValue) return null
                  return (
                    <div
                      key={specField.key}
                      className="py-3 flex justify-between"
                    >
                      <dt className="text-gray-500">{specField.label}</dt>
                      <dd className="text-gray-900 font-medium">
                        <FieldValue field={specField} value={specValue} />
                      </dd>
                    </div>
                  )
                })}
              </dl>
            </div>
          )}

          {/* Manual (file download) */}
          {customData.manual && (
            <div className="mt-6">
              <a
                href={customData.manual}
                download
                className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 underline"
              >
                Download User Manual
              </a>
            </div>
          )}
        </div>
      </div>
    </main>
  )
}

// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------

function formatPrice(price: number, currency?: string): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: currency || 'USD',
  }).format(price)
}

Using the generic renderer instead

If you do not need a custom layout, use StructuredContentRenderer directly. It renders every field in schema order with appropriate labels and styling:

tsx
import { StructuredContentRenderer } from '@/components/structured-content-renderer'

// Inside your page component:
if (product.customData && schema) {
  return (
    <main className="max-w-3xl mx-auto px-4 py-12">
      <h1 className="text-4xl font-bold mb-8">{product.title}</h1>
      <StructuredContentRenderer
        data={product.customData}
        fields={schema.fields}
      />
    </main>
  )
}

This is useful for rapid prototyping or when you have many structured categories with different schemas and want a single rendering path.

Summary

  1. Standard vs structured is determined by category.contentMode ('standard' or 'structured').

  2. Detection at the content level: if content.customData !== null, it is structured content.

  3. Schema lives on the category and defines field keys, types, labels, and nesting.

  4. Richtext fields are returned as pre-rendered HTML -- use dangerouslySetInnerHTML.

  5. Image and file fields are CDN URLs -- render as <img> or download links.

  6. Select/multiselect fields store raw values -- look up labels from schema.options.

  7. Object fields contain nested sub-fields -- recurse with the same renderer.

  8. Array fields contain repeatable items -- map and render each item's sub-fields.

  9. The StructuredContentRenderer component handles all 16+ field types in a single reusable component.