Step 2: Add a list of blocks
Most pages are built from a stack of blocks: a text section, an image, a call to action. In this step editors get a list of blocks on the landing page that they can add, reorder and remove: text blocks, image blocks and a weather block that loads data from an external API. The list field models this: each item in the list has one of the types you allow.


Just like page types, every block type gets a folder with a schema file and a view component.
app/
├ (alinea)/api/cms/route.ts
├ page.tsx
├ layout.tsx
╰ globals.css
entries/
╰ landing/
├ LandingPage.tsx
╰ LandingPage.schema.tsx
blocks/
├ text/
│ ├ TextBlock.tsx
│ ╰ TextBlock.schema.tsx
├ image/
│ ├ ImageBlock.tsx
│ ╰ ImageBlock.schema.tsx
╰ weather/
├ WeatherBlock.tsx
╰ WeatherBlock.schema.tsx
cms.tsxText block
Block types are defined with Config.type, not Config.document: they don't have a URL, title or metadata of their own. The text block has one rich text field. inline: true shows a minimal editor without the field label, which keeps a block with a single field compact.
import {Config, Field} from 'alinea'
export const TextBlock = Config.type('Text block', {
fields: {
body: Field.richText('Text', {inline: true})
}
})The RichText component from alinea/ui renders rich text as React elements, without dangerouslySetInnerHTML. Pass a component or element per tag to change how it renders: here every a becomes a Next.js Link, so internal links navigate client-side. Infer.ListItem<typeof TextBlock> gives the type of one list item of this block type, including _id and _type.
import type {Infer} from 'alinea'
import {RichText} from 'alinea/ui'
import NextLink from 'next/link'
import type {TextBlock} from './TextBlock.schema'
type TextBlockData = Infer.ListItem<typeof TextBlock>
function Link({href, ...props}: {href?: string; [key: string]: any}) {
if (!href) return <a {...props} />
return <NextLink href={href!} {...props} />
}
export function TextBlockView({block}: {block: TextBlockData}) {
return <RichText doc={block.body} a={Link} />
}
Image block
The image block has an image field and an alt text.
import {Config, Field} from 'alinea'
export const ImageBlock = Config.type('Image block', {
fields: {
image: Field.image('Image', {required: true, width: 0.5}),
alt: Field.text('Alt text', {width: 0.5})
}
})
An image field returns an object with src, width and height (among others), or undefined when no image is picked. withAlinea allows the image URLs in images.localPatterns, so they work with the Next.js Image component, which resizes and optimizes them.
import type {Infer} from 'alinea'
import Image from 'next/image'
import type {ImageBlock} from './ImageBlock.schema'
type ImageBlockData = Infer.ListItem<typeof ImageBlock>
export function ImageBlockView({block}: {block: ImageBlockData}) {
if (!block.image) return null
const {src, width, height} = block.image
return (
<Image
src={src}
width={width}
height={height}
alt={block.alt || ''}
style={{width: '300px', height: 'auto'}}
/>
)
}
Weather block
Blocks can load their own data too. Editors enter a region, and the block shows the current weather there. The help option adds a description below the field.
import {Config, Field} from 'alinea'
export const WeatherBlock = Config.type('Weather block', {
fields: {
title: Field.text('Title', {required: true, width: 0.5}),
region: Field.text('Region', {
required: true,
width: 0.5,
help: 'City or region name, for example: Brussels or New York'
})
}
})The view is an async server component that fetches the weather from Open-Meteo. The 'use cache' directive caches getCurrentWeather per region, and cacheLife keeps each result for 15 minutes, so a page with this block doesn't call the API on every request.
import type {Infer} from 'alinea'
import {cacheLife} from 'next/cache'
import type {WeatherBlock} from './WeatherBlock.schema'
type WeatherBlockData = Infer.ListItem<typeof WeatherBlock>
type GeocodingResponse = {
results?: Array<{
name: string
country?: string
latitude: number
longitude: number
}>
}
type ForecastResponse = {
current?: {
temperature_2m?: number
weather_code?: number
}
current_units?: {
temperature_2m?: string
}
}
const weatherCodeLabels: Record<number, string> = {
0: 'Clear sky',
1: 'Mainly clear',
2: 'Partly cloudy',
3: 'Overcast',
45: 'Fog',
48: 'Depositing rime fog',
51: 'Light drizzle',
53: 'Moderate drizzle',
55: 'Dense drizzle',
61: 'Slight rain',
63: 'Moderate rain',
65: 'Heavy rain',
71: 'Slight snowfall',
73: 'Moderate snowfall',
75: 'Heavy snowfall',
80: 'Rain showers',
81: 'Rain showers',
82: 'Violent rain showers',
95: 'Thunderstorm'
}
async function getCurrentWeather(region: string) {
'use cache'
cacheLife({stale: 900, revalidate: 900, expire: 900})
const geocoding = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(region)}&count=1`
)
if (!geocoding.ok) return null
const geocodingData = (await geocoding.json()) as GeocodingResponse
const result = geocodingData.results?.[0]
if (!result) return null
const forecast = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${result.latitude}&longitude=${result.longitude}¤t=temperature_2m,weather_code&timezone=auto`
)
if (!forecast.ok) return null
const forecastData = (await forecast.json()) as ForecastResponse
if (!forecastData.current) return null
return {
location: result.country
? `${result.name}, ${result.country}`
: result.name,
temperature: forecastData.current.temperature_2m,
unit: forecastData.current_units?.temperature_2m ?? '°C',
summary:
weatherCodeLabels[forecastData.current.weather_code ?? -1] ??
'Current weather'
}
}
export async function WeatherBlockView({block}: {block: WeatherBlockData}) {
const weather = await getCurrentWeather(block.region)
return (
<section>
<h2>{block.title}</h2>
{!weather ? (
<p>Could not load weather for {block.region}.</p>
) : (
<p>
{weather.location}: {weather.temperature}
{weather.unit} ({weather.summary})
</p>
)}
</section>
)
}'use cache' and cacheLife are part of Cache Components, which Next.js doesn't enable by default. Turn it on in next.config.ts. Next.js then prerenders the page at build time with the cached weather in it, and renders it again once the 15 minutes are up:
import {withAlinea} from 'alinea/next'
import type {NextConfig} from 'next'
const nextConfig: NextConfig = {
cacheComponents: true
}
export default withAlinea(nextConfig)Add the list to the landing page
Add a blocks list field to the landing page. The keys of schema become the _type of each item.
import {Config, Field} from 'alinea'
import {ImageBlock} from '@/blocks/image/ImageBlock.schema'
import {TextBlock} from '@/blocks/text/TextBlock.schema'
import {WeatherBlock} from '@/blocks/weather/WeatherBlock.schema'
export const LandingPage = Config.document('Landing page', {
fields: {
title: Field.text('Title', {required: true, width: 0.5}),
path: Field.path('Path', {readOnly: true, width: 0.5, initialValue: ''}),
blocks: Field.list('Blocks', {
schema: {
TextBlock,
ImageBlock,
WeatherBlock
}
})
}
})Render each block by checking its _type: TypeScript narrows block to the matching block type inside each branch. generateMetadata now falls back to the text of the first text block when the SEO tab has no description. plainText walks the rich text nodes to collect the text.
import type {TextDoc} from 'alinea'
import {Node} from 'alinea/core/TextDoc'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import {ImageBlockView} from '@/blocks/image/ImageBlock'
import {TextBlockView} from '@/blocks/text/TextBlock'
import {WeatherBlockView} from '@/blocks/weather/WeatherBlock'
import {cms} from '@/cms'
import {LandingPage} from './LandingPage.schema'
export async function LandingPageView() {
const page = await cms.first({url: '/', type: LandingPage})
if (!page) notFound()
return (
<main>
<h1>{page.title}</h1>
{page.blocks.map(block => {
if (block._type === 'TextBlock') return <TextBlockView key={block._id} block={block} />
if (block._type === 'ImageBlock') return <ImageBlockView key={block._id} block={block} />
if (block._type === 'WeatherBlock') return <WeatherBlockView key={block._id} block={block} />
return null
})}
</main>
)
}
export async function generateMetadata(): Promise<Metadata> {
const page = await cms.first({url: '/', type: LandingPage})
if (!page) return {}
let fallbackDescription = ''
for (const block of page.blocks) {
if (block._type === 'TextBlock') {
fallbackDescription = plainText(block.body)
break
}
}
return {
title: page.metadata.title || page.title,
description: page.metadata?.description || fallbackDescription,
openGraph: {
title: page.metadata.openGraph.title || page.metadata.title || page.title,
description: page.metadata.openGraph.description || page.metadata?.description,
images: page.metadata?.openGraph.image
? [page.metadata?.openGraph.image.src]
: undefined
}
}
}
export function plainText(value: TextDoc<any> | string | undefined): string {
if (!value) return ''
if (typeof value === 'string') return value
if (!Array.isArray(value)) return ''
const result = value
.reduce((acc, node) => {
return acc + textOf(node)
}, '')
.trim()
return result.replace(/ +(?= )/g, '')
}
function textOf(node: Node): string {
if (node._type === 'hardBreak') return '\n'
if (Node.isText(node)) {
return node.text ? ' ' + node.text : ''
} else if (Node.isElement(node) && node.content) {
return node.content.reduce((acc, node) => {
return acc + textOf(node)
}, '')
}
return ''
}