Skip to content

Tutorial: build a blog ​

What you'll learn

Build a small blog from an empty project to production: content model, admin, pages, drafts, live preview, SEO and deployment. It takes about 30 minutes. Pick Nuxt or Next.js in the code tabs; everything else is the same.

Before this page: nothing. You need Node.js 22.12 or newer.

1. Create the project ​

bash
npx nuxi@latest init my-blog
cd my-blog
npx create-easy-cms --db sqlite --yes
bash
npx create-next-app@latest my-blog --ts --app --no-src-dir
cd my-blog
npx create-easy-cms --db sqlite --yes

create-easy-cms installs the packages, writes easy-cms.config.ts, puts a random EASY_CMS_SECRET in .env and connects the admin and the API to your app. SQLite keeps everything in one file (cms.db), which is all you need until you deploy.

2. Describe the content ​

Replace easy-cms.config.ts with a blog: categories, and posts with drafts and history.

ts
import { defineConfig } from '@easy-cms/core'
import { sqlite } from '@easy-cms/db-sqlite'

export default defineConfig({
  secret: process.env.EASY_CMS_SECRET ?? '',
  db: sqlite({ url: process.env.DATABASE_URL ?? 'file:./cms.db' }),
  admin: { menu: ['posts', 'categories', 'media'] },
  collections: [
    {
      slug: 'categories',
      editIn: 'drawer', // small: edit in a panel over the list
      useAsTitle: 'name',
      access: { read: () => true },
      fields: [
        { name: 'name', type: 'text', required: true },
        { name: 'slug', type: 'slug', from: 'name' },
      ],
    },
    {
      slug: 'posts',
      drafts: true, // draft / published
      versions: true, // history and restore
      useAsTitle: 'title',
      access: {
        // Visitors see published posts; logged-in editors see drafts too.
        read: ({ user }) => (user ? true : { status: { equals: 'published' } }),
      },
      fields: [
        { name: 'title', type: 'text', required: true, maxLength: 120 },
        { name: 'slug', type: 'slug', from: 'title' },
        { name: 'excerpt', type: 'textarea', maxLength: 300 },
        { name: 'cover', type: 'upload' },
        { name: 'body', type: 'richText' },
        { name: 'category', type: 'relationship', to: 'categories', position: 'sidebar' },
        { name: 'publishedAt', type: 'date', position: 'sidebar' },
      ],
    },
  ],
})

In development the database follows the config: save the file and the tables change.

3. Open the admin ​

bash
npm run dev
bash
pnpm dev
bash
yarn dev
bash
bun run dev

Go to http://localhost:3000/admin. There are no users yet, so the admin asks for the first admin account. Then you land on the dashboard.

The dashboard after logging in

Create a category or two (they open in a panel), then a post: write a title (the slug fills in by itself), an excerpt, some body text, pick a cover and a category, and press Publish.

Writing a post

4. List the posts ​

ts
export default defineEventHandler(async () => {
  const cms = await useEasyCMS()
  const { docs } = await cms.find('posts', { sort: '-publishedAt', limit: 20 })
  return docs
})
tsx
import { getEasyCMS } from '@easy-cms/next'
import Link from 'next/link'
import config from '@/easy-cms.config'

export const dynamic = 'force-dynamic'

export default async function Home() {
  const cms = await getEasyCMS(config)
  const { docs } = await cms.find('posts', { sort: '-publishedAt', limit: 20 })
  return (
    <ul>
      {docs.map((post) => (
        <li key={post.id}>
          <Link href={`/posts/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  )
}

For Nuxt, show them in app/pages/index.vue:

vue
<script setup lang="ts">
const { data: posts } = await useFetch('/api/posts')
</script>

<template>
  <ul>
    <li v-for="post in posts" :key="post.id">
      <NuxtLink :to="`/posts/${post.slug}`">{{ post.title }}</NuxtLink>
    </li>
  </ul>
</template>

find returns published posts only, and post.title is typed as string straight from the config: try misspelling it.

5. Show one post ​

Install the rich text renderer:

bash
npm install @easy-cms/richtext
bash
pnpm add @easy-cms/richtext
bash
yarn add @easy-cms/richtext
bash
bun add @easy-cms/richtext
ts
export default defineEventHandler(async (event) => {
  const cms = await useEasyCMS()
  const user = await useEasyCMSUser(event)
  const { docs } = await cms.find('posts', {
    where: { slug: { equals: getRouterParam(event, 'slug') } },
    limit: 1,
    user, // editors see drafts, visitors only published posts
    overrideAccess: false,
    draft: user !== null,
  })
  if (!docs[0]) throw createError({ statusCode: 404 })
  return docs[0]
})
tsx
import { getEasyCMS, getEasyCMSUser } from '@easy-cms/next'
import { renderRichText } from '@easy-cms/richtext'
import { notFound } from 'next/navigation'
import config from '@/easy-cms.config'

export const dynamic = 'force-dynamic'

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const cms = await getEasyCMS(config)
  const user = await getEasyCMSUser(config)
  const { docs } = await cms.find('posts', {
    where: { slug: { equals: decodeURIComponent(slug) } },
    limit: 1,
    user, // editors see drafts, visitors only published posts
    overrideAccess: false,
    draft: user !== null,
  })
  const post = docs[0]
  if (!post) notFound()
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: renderRichText(post.body) }} />
    </article>
  )
}

For Nuxt, the page app/pages/posts/[slug].vue:

vue
<script setup lang="ts">
import { renderRichText } from '@easy-cms/richtext'

const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`, {
  headers: useRequestHeaders(['cookie']), // so editors see their drafts
})
const html = computed(() => renderRichText(post.value?.body))
</script>

<template>
  <article v-if="post">
    <h1>{{ post.title }}</h1>
    <div v-html="html" />
  </article>
</template>

renderRichText escapes everything, so its HTML is safe to insert.

6. Drafts and history ​

Open your post, change the title and press Save draft. The site still shows the published version; the admin marks the post as having unpublished changes. Press Publish when ready. Every save is kept in History on the right: open an older version and restore it.

Want it live at 9:00 tomorrow? Add schedule: true to the posts collection, and use Schedule next to Publish. See Drafts, versions & scheduling.

7. Live preview ​

Tell Easy CMS where a post is shown, and let the page follow the form:

ts
// in the posts collection
preview: ({ doc }) => (doc.slug ? `/posts/${doc.slug}` : null),
vue
<script setup lang="ts">
// …after useFetch
useLivePreview(post) // auto-imported
</script>
tsx
'use client'
import { useLivePreview } from '@easy-cms/next/live-preview'

// Render the post here, and use <PostView post={post} /> in the page.
export function PostView({ post: initial }: { post: { title: string } }) {
  const post = useLivePreview(initial)
  return <h1>{post.title}</h1>
}

Press Preview in the editor: the real page appears next to the form and changes as you type.

Live preview next to the form

8. SEO ​

bash
npm install @easy-cms/plugin-seo
bash
pnpm add @easy-cms/plugin-seo
bash
yarn add @easy-cms/plugin-seo
bash
bun add @easy-cms/plugin-seo
ts
import { seoPlugin } from '@easy-cms/plugin-seo'

export default defineConfig({
  // …
  plugins: [
    seoPlugin({
      collections: ['posts'],
      generateTitle: ({ doc }) => `${doc.title} | My Blog`,
      generateDescription: ({ doc }) => doc.excerpt as string,
    }),
  ],
})

Posts now have an SEO group with length meters, a search preview and Generate buttons. Use seoMeta(post) in the post page for the <title>, description and share tags; see SEO.

9. Deploy ​

  1. Create the first migration. Production never changes the database on its own:

    bash
    npx easy-cms migrate:create init
    git add easy-cms/migrations && git commit -m "CMS schema"
    bash
    pnpm exec easy-cms migrate:create init
    git add easy-cms/migrations && git commit -m "CMS schema"
    bash
    yarn easy-cms migrate:create init
    git add easy-cms/migrations && git commit -m "CMS schema"
    bash
    bunx easy-cms migrate:create init
    git add easy-cms/migrations && git commit -m "CMS schema"
  2. Pick a database. SQLite works on a server with a persistent disk. On serverless platforms, switch to Postgres: npm install @easy-cms/db-postgres and db: postgres({ url: process.env.DATABASE_URL }). See Databases.

  3. Set the environment on the server: EASY_CMS_SECRET (a new random value, not the one from your laptop), DATABASE_URL, and NODE_ENV=production.

  4. Migrate, then start, on every deploy:

    bash
    npx easy-cms migrate
    npm run build && npm start
    bash
    pnpm exec easy-cms migrate
    pnpm build && pnpm start
    bash
    yarn easy-cms migrate
    yarn build && yarn start
    bash
    bunx easy-cms migrate
    bun run build && bun run start
  5. Store uploads safely. Files go to uploads/ by default; on serverless or several servers, use S3 or Cloudflare R2 (see Uploads).

Go through the checklist before going live, and set up backups.

Next steps ​

Released under the MIT License.