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 abodyfield containing rendered HTML from the rich-text editor. ThecustomDatafield isnull.structured-- Contents have acustomDataobject populated with typed fields that match the category's schema. Thebodyfield 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:
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():
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
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 |
|---|---|---|
|
| The property name in |
|
| Determines how to render the value |
|
| Human-readable label |
|
| Whether the field is required |
|
| Placeholder hint |
|
| Description for editors |
|
| Default value |
|
|
|
|
| Sub-fields for |
|
| Label for array items |
|
| Minimum array length |
|
| Maximum array length |
|
| Options for |
Type-to-Rendering Mapping
Here is how each field type maps to a rendering strategy:
Field Type |
| Rendering |
|---|---|---|
|
| Plain text in a |
|
|
|
|
|
|
|
| Formatted text (e.g., currency, units) |
|
| Conditional rendering or badge |
|
| Look up label from schema |
|
| Look up labels, render as tags |
|
|
|
|
|
|
|
|
|
|
| |
|
| Formatted date string |
|
| Formatted date and time |
|
| Color swatch |
|
| Recursively render sub-fields |
|
| 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:
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:
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:
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:
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:
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:
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)
{
"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:
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:
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
Standard vs structured is determined by
category.contentMode('standard'or'structured').Detection at the content level: if
content.customData !== null, it is structured content.Schema lives on the category and defines field keys, types, labels, and nesting.
Richtext fields are returned as pre-rendered HTML -- use
dangerouslySetInnerHTML.Image and file fields are CDN URLs -- render as
<img>or download links.Select/multiselect fields store raw values -- look up labels from
schema.options.Object fields contain nested sub-fields -- recurse with the same renderer.
Array fields contain repeatable items -- map and render each item's sub-fields.
The
StructuredContentRenderercomponent handles all 16+ field types in a single reusable component.