Skip to content

Step 1: Simple landing page

Start with a homepage served at / that shows a title editors can change in the dashboard, with SEO metadata and a live preview.

The landing page entry in the dashboard, with its title previewed beside the formThe landing page entry in the dashboard, with its title previewed beside the form

The landing page gets its own folder with two files: LandingPage.schema.tsx defines the fields editors fill in, LandingPage.tsx renders them.

project structure
app/
├ (alinea)/api/cms/route.ts
├ page.tsx
├ layout.tsx
╰ globals.css

entries/
╰ landing/
  ├ LandingPage.tsx
  ╰ LandingPage.schema.tsx

cms.tsx

Define the schema

Config.document creates a type for entries that have a page of their own. Every document gets a title and a path field, an SEO tab with the metadata fields and a Details tab that shows who created and last changed the entry. Here we redefine path to make it read-only with an empty initial value, so a landing page created in the dashboard always resolves to /.

entries/landing/LandingPage.schema.tsx
import {Config, Field} from 'alinea'

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: ''})
  }
})

Render the page

The view is an async server component that queries its own entry. cms.first returns the first entry that matches, or null when there is none, so a missing page renders the Next.js 404 page. (cms.get throws instead, which suits entries that must exist.) Passing type: LandingPage both filters on the type and types the result.

The same file exports generateMetadata, which reads the fields of the SEO tab and falls back to the title.

entries/landing/LandingPage.tsx
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
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>
    </main>
  )
}

export async function generateMetadata(): Promise<Metadata> {
  const page = await cms.first({url: '/', type: LandingPage})
  if (!page) return {}

  return {
    title: page.metadata.title || page.title,
    description: page.metadata?.description,
    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
    }
  }
}

Register the type

Replace the generated config with this cms.tsx. The type goes in schema, and the Pages root lists it in contains so editors can create landing pages there.

Config.page seeds an entry: Alinea creates it in your content folder when it doesn't exist yet, and editors can't delete it from the dashboard. The key becomes the entry's path, and index is a special path that resolves to the parent's URL, / in this case. Seeding is optional, editors can also create the page themselves.

cms.tsx
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'
import {LandingPage} from '@/entries/landing/LandingPage.schema'

export const cms = createCMS({
  schema: {LandingPage},
  workspaces: {
    main: Config.workspace('Main', {
      source: 'content',
      mediaDir: 'public/media',
      roots: {
        pages: Config.root('Pages', {
          contains: ['LandingPage'],
          children: {
            // Optionally seed this page, alternatively you can simply create the page from the CMS directly
            index: Config.page({
              type: LandingPage,
              fields: {
                title: 'Welcome'
              }
            })
          }
        }),
        media: Config.media()
      }
    })
  },
  baseUrl: {
    development: 'http://localhost:3000',
    production:
      process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL ??
      'http://localhost:3000'
  },
  handlerUrl: '/api/cms',
  adminPath: '/admin',
  preview: true
})

baseUrl.production is read from NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL, which Vercel sets during the build. Replace it with the URL of your site when you deploy elsewhere. See Configuration for all options.

Wire up Next.js

Wrap your Next.js config with withAlinea so the dashboard is served on /admin. The API route for /api/cms (app/(alinea)/api/cms/route.ts) was created by alinea init, see the Quickstart.

next.config.ts
import {withAlinea} from 'alinea/next'
import type {NextConfig} from 'next'

const nextConfig: NextConfig = {}

export default withAlinea(nextConfig)

The route file only renders the view and re-exports its metadata:

app/page.tsx
import {LandingPageView} from '@/entries/landing/LandingPage'

export {generateMetadata} from '@/entries/landing/LandingPage'

export default function Page() {
  return <LandingPageView />
}

Render cms.previews in the root layout. It enables live previews in the dashboard, and the widget prop adds a small toolbar to your pages to switch to the dashboard.

app/layout.tsx
import {cms} from '@/cms'
import './globals.css'

export default function RootLayout({children}: {children: React.ReactNode}) {
  return (
    <html lang="en">
      <body>
        {children}
        <cms.previews widget />
      </body>
    </html>
  )
}

The layout also imports globals.css: a few base styles so the pages, and the preview next to the editor, are easy to read. Replace them with your own styles, nothing in the tutorial depends on them.

app/globals.css
/* A few base styles so the pages are easy to read, replace them with your own */

body {
  max-width: 720px;
  margin: 0 auto;
  padding: 32px 24px;
  font-family:
    system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
    sans-serif;
  font-size: 16px;
  line-height: 1.6;
  color: #1f2328;
  background: #fff;
}

h1,
h2,
h3 {
  margin: 1.5em 0 0.5em;
  line-height: 1.25;
}

h1 {
  margin-top: 0.5em;
  font-size: 2.25rem;
}

p,
ul,
ol {
  margin: 0 0 1em;
}

a {
  color: #2458d3;
}

img {
  max-width: 100%;
  height: auto;
}

header,
footer {
  padding: 16px 0;
  color: #59636e;
}

header {
  border-bottom: 1px solid #d1d9e0;
}

footer {
  margin-top: 48px;
  border-top: 1px solid #d1d9e0;
}