Skip to content
SaaS4Builders
Content Management

Nuxt Content Setup

Content directory structure, collection definitions, multi-language support, Zod schemas, and MDC syntax for managing editorial content with Nuxt Content v3.

SaaS4Builders uses Nuxt Content v3 to manage all editorial content — documentation pages, blog posts, changelogs, pricing, and legal pages. Content is stored as Markdown and YAML files in the repository, organized by language, and validated at build time through Zod schemas defined in content.config.ts.

This approach keeps your marketing and editorial content version-controlled alongside your code, while allowing non-developers to edit content through Nuxt Studio.


Content Directory Structure

All content lives under frontend/content/, organized by locale:

frontend/content/
├── en/                          # English
│   ├── 0.index.yml              # Home page
│   ├── 1.docs/                  # Documentation articles
│   │   ├── 1.getting-started.md
│   │   └── ...
│   ├── 2.pricing.yml            # Pricing page
│   ├── 3.blog.yml               # Blog index
│   ├── 3.blog/                  # Blog posts
│   │   ├── my-first-post.md
│   │   └── ...
│   ├── 4.changelog.yml          # Changelog index
│   ├── 4.changelog/             # Version entries
│   │   ├── v1.0.0.md
│   │   └── ...
│   └── 5.terms.md               # Terms of service
├── fr/                          # French (same structure)
├── es/                          # Spanish
└── it/                          # Italian

The numeric prefixes (0., 1., 2., etc.) control the ordering in navigation — Nuxt Content uses them for sorting and strips them from the URL slug.

Four locales are supported out of the box: English (en), French (fr), Spanish (es), and Italian (it). Each locale has an identical directory structure, ensuring every page exists in all supported languages.


Collection Definitions

Collections are defined in frontend/content.config.ts. The configuration dynamically generates collections for each supported language using a factory function:

frontend/content.config.ts
import { defineCollection, z } from '@nuxt/content'
import { locales as availableLanguages } from './i18n/locales.config'

const createCollectionsForLanguage = (lang: string) => ({
  [`index_${lang}`]: defineCollection({
    source: `${lang}/0.index.yml`,
    type: 'page',
    schema: indexPageSchema,
  }),
  [`docs_${lang}`]: defineCollection({
    source: `${lang}/1.docs/**/*`,
    type: 'page',
    schema: z.object({
      links: z.array(createLinkSchema()).optional(),
    }),
  }),
  [`pricing_${lang}`]: defineCollection({
    source: `${lang}/2.pricing.yml`,
    type: 'page',
    schema: pricingPageSchema,
  }),
  [`blog_${lang}`]: defineCollection({
    source: `${lang}/3.blog.yml`,
    type: 'page',
  }),
  [`posts_${lang}`]: defineCollection({
    source: `${lang}/3.blog/**/*`,
    type: 'page',
    schema: postSchema,
  }),
  [`changelog_${lang}`]: defineCollection({
    source: `${lang}/4.changelog.yml`,
    type: 'page',
  }),
  [`versions_${lang}`]: defineCollection({
    source: `${lang}/4.changelog/**/*`,
    type: 'page',
    schema: versionSchema,
  }),
  [`terms_${lang}`]: defineCollection({
    source: `${lang}/5.terms.md`,
    type: 'page',
  }),
})

const supportedLanguages = availableLanguages.map((lang) => lang.code)

export const collections = supportedLanguages.reduce((acc, lang) => {
  return { ...acc, ...createCollectionsForLanguage(lang) }
}, {})

This generates 8 collections per language (32 total for 4 locales), each with appropriate source patterns and schemas.


Content Types

Home Page (index)

The home page is a YAML file (0.index.yml) with a rich schema covering:

SectionDescription
heroHero section with CTA links and optional image
workflowsFeature workflow cards with images
sectionsFeature sections with icon-based items
featuresFeature grid with title, description, icon
testimonialsCustomer quotes with avatars
ctaCall-to-action with links

Documentation (docs)

Documentation pages are Markdown files under 1.docs/. They support an optional links array in frontmatter for navigation buttons:

---
title: 'Getting Started'
description: 'Set up your development environment'
links:
  - label: 'View on GitHub'
    to: 'https://github.com/your-org/your-repo'
    icon: 'i-simple-icons-github'
    target: '_blank'
---

Your documentation content here...

Pricing (pricing)

The pricing page is a YAML file (2.pricing.yml) that contains editorial content — social proof and FAQ. The actual plans data (names, prices, features) comes from the backend API (the catalog), not from the content file. This separation keeps pricing in sync with Stripe and avoids duplication between content and billing logic.

frontend/content/en/2.pricing.yml
title: A plan for every need
description: Our plans are designed to meet the requirements of both beginners and players.
seo:
  title: Pricing
  description: Choose the plan that's right for you.

logos:
  title: Trusted by teams worldwide
  items:
    - name: TechFlow
      quote: Scaled from 3 to 40 team members with CraftDesk
    - name: Bright Agency
      quote: Our go-to tool for client project management

faq:
  title: Frequently Asked Questions
  description: Everything you need to know about pricing.
  items:
    - label: Is there a free plan?
      content: 'Yes, the Starter plan is free forever. No credit card required.'
      defaultOpen: true
    - label: Can I switch plans at any time?
      content: 'Yes. Upgrades take effect immediately with prorated billing.'
    - label: Do you offer annual billing?
      content: 'Yes. Annual plans save approximately 17% compared to monthly billing.'

The schema reflects this split — it only defines logos and faq, not plans:

frontend/content.config.ts
const pricingPageSchema = z.object({
  logos: z.object({
    title: z.string().nonempty(),
    icons: z.array(z.string()).optional(),
    items: z.array(
      z.object({
        name: z.string().nonempty(),
        quote: z.string().nonempty(),
      })
    ).optional(),
  }),
  faq: createBaseSchema().extend({
    items: z.array(
      z.object({
        label: z.string().nonempty(),
        content: z.string().nonempty(),
        defaultOpen: z.boolean().optional(),
      })
    ),
  }),
})

Blog Posts (posts)

Blog posts are Markdown files with image, author, date, and badge frontmatter:

---
title: 'Announcing SaaS4Builders v1.0'
description: 'Our first stable release is here'
image:
  src: '/images/blog/v1-launch.png'
authors:
  - name: 'John Doe'
    to: 'https://twitter.com/johndoe'
    avatar:
      src: '/images/avatars/john.jpg'
date: 2026-03-15
badge:
  label: 'Release'
---

Blog post content in Markdown...

Changelog Versions (versions)

Version entries document release notes:

---
title: 'v1.2.0'
description: 'Multi-currency support and performance improvements'
date: 2026-03-20
image: '/images/changelog/v1.2.png'
---

## What's New

- Multi-currency support for subscriptions
- 40% faster dashboard loading
- Bug fixes for team invitations

Terms of Service (terms)

A simple Markdown page with standard title/description frontmatter. No additional schema required.


Reusable Schemas

The content configuration defines several reusable Zod schemas used across collections:

Image Schema

frontend/content.config.ts
const createImageSchema = () =>
  z.object({
    src: z.string().nonempty().editor({ input: 'media' }),
    alt: z.string().optional(),
    loading: z.enum(['lazy', 'eager']).optional(),
    srcset: z.string().optional(),
  })

The .editor({ input: 'media' }) annotation tells Nuxt Studio to render a media picker for this field instead of a plain text input.

frontend/content.config.ts
const createLinkSchema = () =>
  z.object({
    label: z.string().nonempty(),
    to: z.string().nonempty(),
    icon: z.string().optional().editor({ input: 'icon' }),
    size: z.enum(['xs', 'sm', 'md', 'lg', 'xl']).optional(),
    trailing: z.boolean().optional(),
    target: z.string().optional(),
    color: z.enum(['primary', 'secondary', 'neutral', 'error', 'warning', 'success', 'info']).optional(),
    variant: z.enum(['solid', 'outline', 'subtle', 'soft', 'ghost', 'link']).optional(),
  })

Links support all Nuxt UI button variants and colors, making CTA buttons fully configurable from content files.

Feature Item Schema

frontend/content.config.ts
const createFeatureItemSchema = () =>
  z.object({
    title: z.string().nonempty(),
    description: z.string().nonempty(),
    icon: z.string().nonempty().editor({ input: 'icon' }),
  })

Storage and Build

Nuxt Content v3 uses SQLite as its storage backend. During development, content is stored at frontend/.data/contents.sqlite. During the build process, the content is parsed, validated against schemas, and dumped into the SQLite database for fast querying at runtime.

Content files are not served directly — they are parsed at build time and queried via Nuxt Content's composables (queryCollection, useContent, etc.). This means changes to content files require a rebuild (or hot-reload in development mode).

Adding Content

Adding a Blog Post

  1. Create a Markdown file under the appropriate locale directory:
frontend/content/en/3.blog/my-new-post.md
  1. Add the required frontmatter:
---
title: 'My New Post'
description: 'A brief description for SEO'
image:
  src: '/images/blog/my-post.png'
authors:
  - name: 'Your Name'
    to: 'https://your-website.com'
    avatar:
      src: '/images/avatars/you.jpg'
date: 2026-04-01
badge:
  label: 'Tutorial'
---
  1. Write your content using Markdown with MDC syntax (see below).
  2. Repeat for other locales (fr/, es/, it/) to maintain full i18n coverage.

Adding a Documentation Page

  1. Create the file in frontend/content/{locale}/1.docs/:
frontend/content/en/1.docs/3.my-new-section/1.my-page.md
  1. Add frontmatter with optional navigation links:
---
title: 'My Page Title'
description: 'What this page covers'
links:
  - label: 'Source Code'
    to: 'https://github.com/...'
    icon: 'i-simple-icons-github'
    target: '_blank'
---

Adding a Changelog Entry

  1. Create a Markdown file in frontend/content/{locale}/4.changelog/:
frontend/content/en/4.changelog/v1.3.0.md
  1. Add version frontmatter:
---
title: 'v1.3.0'
description: 'Summary of changes'
date: 2026-04-15
---

MDC Syntax

Nuxt Content supports MDC (Markdown Components) syntax for richer content. Common patterns used in this project:

Callouts

::callout{type="warning"}
This is an important warning.
::

::callout{type="info"}
Helpful information here.
::

Code Groups

::code-group
\`\`\`bash [npm]
npm install @nuxtjs/content
\`\`\`
\`\`\`bash [pnpm]
pnpm add @nuxtjs/content
\`\`\`
::

Prose Components

Standard Markdown elements (headings, lists, tables, code blocks) are rendered through Nuxt Content's prose components, which are styled consistently with your UI theme.


Adding a New Locale

To add a fifth locale (e.g., German de):

  1. Add the locale to frontend/i18n/locales.config.ts.
  2. Create the content directory: frontend/content/de/.
  3. Copy the structure from an existing locale and translate the content.
  4. The collection factory in content.config.ts automatically generates collections for the new locale — no code changes needed.

What's Next