> For the complete Lynkow documentation index in agent-friendly format, see [llms.txt](/llms.txt).

# Structured Content

**Publié le** : 2026-08-14
**Catégorie** : Features

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.

> **Reference** - This guide is a tutorial. For exhaustive, always-current signatures and field lists, see the auto-generated reference:
> 
> - SDK: [`ContentsService`](/docs/sdk/services/sdk-contents-service)
> - API: [Storefront: Content](/docs/storefront-api/storefront-content)

## 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

Each field has a `type` drawn from the `SchemaFieldType` union: `string`, `text`, `richtext`, `number`, `boolean`, `select`, `multiselect`, `image`, `file`, `url`, `email`, `date`, `datetime`, `color`, `object`, `array`, and `dataSource`. The "Type-to-Rendering Mapping" table below shows what each one carries in `customData` and how to render it.

See the full `SchemaFieldType` union in the [SDK Types reference](/docs/sdk/types).

### SchemaField Properties

These are the properties the renderers in this guide read:

| Property | Type | Description |
| --- | --- | --- |
| `key` | `string` | The property name in `customData` |
| `type` | `SchemaFieldType` | Determines how to render the value |
| `label` | `string` | Human-readable label |
| `options` | `SelectOption[]?` | Options for `select` and `multiselect` |
| `fields` | `SchemaField[]?` | Sub-fields for `object` and `array` types |
| `itemLabel` | `string?` | Label for array items |


See the full `SchemaField` shape (including `required`, `validation`, `placeholder`, `helpText`, `defaultValue`, `minItems`, and `maxItems`) in the [SDK Types reference](/docs/sdk/types).

## 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` | `"hello@example.com"` | `<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.