Skip to content

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.

The landing page with a list of text, image and weather blocks, previewed beside the formThe landing page with a list of text, image and weather blocks, previewed beside the form

Just like page types, every block type gets a folder with a schema file and a view component.

project structure
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.tsx

Text 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.

blocks/text/TextBlock.schema.tsx
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.

blocks/text/TextBlock.tsx
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.

blocks/image/ImageBlock.schema.tsx
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.

blocks/image/ImageBlock.tsx
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.

blocks/weather/WeatherBlock.schema.tsx
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.

blocks/weather/WeatherBlock.tsx
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}&current=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:

next.config.ts
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.

entries/landing/LandingPage.schema.tsx
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.

entries/landing/LandingPage.tsx
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 ''
}