Using Cockpit with Nuxt.js
Learn how to integrate Cockpit CMS as a headless backend for your Nuxt.js applications.
- Overview
- Project Setup
- Prerequisites
- Creating the Nuxt.js Project
- Environment Configuration
- Cockpit API Client
- Composable API Client
- Server-Side API Client
- Content Models Setup
- Nuxt.js Implementation
- Blog Homepage
- Dynamic Blog Post Pages
- API Routes for Revalidation
- Advanced Patterns
- Cockpit Image Component
- Search Functionality
- Global State Management
- Performance Optimization
- Static Generation
- ISR with Nitro
- Data Fetching Optimization
- Deployment
- Environment Variables
- Best Practices
- 1. Type Safety
- 2. Error Handling
- 3. SEO Optimization
- Troubleshooting
- Common Issues
- Debug Mode
Overview
Cockpit CMS works seamlessly with Nuxt.js to create modern, performant web applications. This guide covers:
- Setting up Cockpit as a headless CMS backend
- Creating a Nuxt.js API client for Cockpit
- Implementing server-side rendering (SSR) and static generation
- Building dynamic pages with Nuxt Content
- Optimizing performance with caching and composables
- Real-time content updates with webhooks
Project Setup
Prerequisites
- Node.js 18+ and npm/yarn
- Cockpit CMS instance running (local or remote)
- Basic knowledge of Nuxt.js 3 and Vue.js
Creating the Nuxt.js Project
npx nuxi@latest init my-cockpit-site
cd my-cockpit-site
# Install additional dependencies
npm install @nuxt/image
# Optional: for better DX
npm install -D @nuxtjs/tailwindcss
Environment Configuration
Create .env
in your Nuxt.js project:
# Cockpit API Configuration
NUXT_PUBLIC_COCKPIT_API_URL=http://localhost:8080/api
NUXT_COCKPIT_API_KEY=your-api-key-here
# Optional: For ISR and revalidation
NUXT_NITRO_PRESET=node-server
NUXT_REVALIDATION_SECRET=your-secret-token
Update nuxt.config.ts
:
export default defineNuxtConfig({
devtools: { enabled: true },
runtimeConfig: {
cockpitApiKey: '', // NUXT_COCKPIT_API_KEY
revalidationSecret: '', // NUXT_REVALIDATION_SECRET
public: {
cockpitApiUrl: '' // NUXT_PUBLIC_COCKPIT_API_URL
}
},
nitro: {
prerender: {
crawlLinks: true,
routes: ['/']
}
},
modules: [
'@nuxt/image',
'@nuxtjs/tailwindcss'
],
image: {
domains: ['localhost', 'your-cockpit-domain.com']
}
})
Cockpit API Client
Composable API Client
Create composables/useCockpit.ts
:
interface CockpitOptions {
filter?: Record<string, any>
sort?: Record<string, number>
limit?: number
skip?: number
populate?: number
fields?: Record<string, number>
}
interface CockpitAsset {
_id: string
title?: string
description?: string
tags?: string[]
size: number
mime: string
image?: boolean
video?: boolean
audio?: boolean
archive?: boolean
document?: boolean
code?: boolean
colors?: string[]
width?: number
height?: number
}
export const useCockpit = () => {
const config = useRuntimeConfig()
const baseURL = config.public.cockpitApiUrl
const apiKey = config.cockpitApiKey
const request = async <T = any>(endpoint: string, options: RequestInit = {}): Promise<T> => {
const url = `${baseURL}${endpoint}`
const fetchOptions: RequestInit = {
headers: {
'Content-Type': 'application/json',
'api-key': apiKey,
...options.headers,
},
...options,
}
try {
const response = await $fetch<T>(url, fetchOptions)
return response
} catch (error) {
console.error('Cockpit API Error:', error)
throw error
}
}
const getItems = async <T = any>(collection: string, options: CockpitOptions = {}): Promise<T[]> => {
const params = new URLSearchParams()
Object.entries(options).forEach(([key, value]) => {
if (value !== undefined) {
params.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value))
}
})
const query = params.toString()
const endpoint = `/content/items/${collection}${query ? `?${query}` : ''}`
return request<T[]>(endpoint)
}
const getItem = async <T = any>(collection: string, id: string): Promise<T> => {
return request<T>(`/content/item/${collection}/${id}`)
}
const getSingleton = async <T = any>(singleton: string): Promise<T> => {
return request<T>(`/content/item/${singleton}`)
}
const getAsset = (asset: CockpitAsset, options: { w?: number, h?: number, q?: number } = {}) => {
if (!asset?._id) return null
const params = new URLSearchParams()
if (options.w) params.append('w', options.w.toString())
if (options.h) params.append('h', options.h.toString())
if (options.q) params.append('q', options.q.toString())
const query = params.toString()
return `${baseURL}/assets/image/${asset._id}${query ? `?${query}` : ''}`
}
return {
request,
getItems,
getItem,
getSingleton,
getAsset
}
}
Server-Side API Client
For server-only operations, create server/utils/cockpit.ts
:
import type { H3Event } from 'h3'
export const useCockpitServer = (event: H3Event) => {
const config = useRuntimeConfig(event)
const baseURL = config.public.cockpitApiUrl
const apiKey = config.cockpitApiKey
// Cache storage
const cache = useStorage('cockpit')
const request = async <T = any>(
endpoint: string,
options: RequestInit = {},
cacheKey?: string,
cacheTTL: number = 60 // seconds
): Promise<T> => {
// Check cache first
if (cacheKey) {
const cached = await cache.getItem<T>(cacheKey)
if (cached) return cached
}
const url = `${baseURL}${endpoint}`
const response = await $fetch<T>(url, {
headers: {
'Content-Type': 'application/json',
'api-key': apiKey,
...options.headers,
},
...options,
})
// Store in cache
if (cacheKey) {
await cache.setItem(cacheKey, response, { ttl: cacheTTL })
}
return response
}
return {
request,
// Include other methods from composable
}
}
Content Models Setup
Create the same content models as in the Next.js guide:
Posts Collection:
{
"name": "posts",
"type": "collection",
"fields": [
{"name": "title", "type": "text", "required": true},
{"name": "slug", "type": "text", "required": true},
{"name": "content", "type": "wysiwyg"},
{"name": "excerpt", "type": "text"},
{"name": "featured_image", "type": "asset"},
{"name": "author", "type": "text"},
{"name": "published", "type": "boolean", "default": false},
{"name": "tags", "type": "tags"}
]
}
Site Settings Singleton:
{
"name": "site_settings",
"type": "singleton",
"fields": [
{"name": "site_title", "type": "text"},
{"name": "site_description", "type": "text"},
{"name": "logo", "type": "asset"},
{"name": "social_links", "type": "object"}
]
}
Nuxt.js Implementation
Blog Homepage
Create pages/index.vue
:
<template>
<div>
<header class="mb-12">
<h1 class="text-4xl font-bold mb-4">{{ siteSettings?.site_title }}</h1>
<p class="text-lg text-gray-600">{{ siteSettings?.site_description }}</p>
</header>
<main>
<section>
<h2 class="text-2xl font-bold mb-6">Latest Posts</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<article
v-for="post in posts"
:key="post._id"
class="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
>
<NuxtImg
v-if="post.featured_image"
:src="cockpit.getAsset(post.featured_image, { w: 400, h: 200, q: 80 })"
:alt="post.title"
width="400"
height="200"
class="w-full h-48 object-cover"
/>
<div class="p-6">
<h3 class="text-xl font-semibold mb-2">
<NuxtLink :to="`/blog/${post.slug}`" class="hover:text-blue-600">
{{ post.title }}
</NuxtLink>
</h3>
<p class="text-gray-600 mb-4">{{ post.excerpt }}</p>
<div class="text-sm text-gray-500">
<span>By {{ post.author }}</span>
<time class="ml-4">{{ formatDate(post._created) }}</time>
</div>
</div>
</article>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
interface Post {
_id: string
title: string
slug: string
excerpt: string
featured_image?: any
author: string
_created: number
}
interface SiteSettings {
site_title: string
site_description: string
logo?: any
social_links?: Record<string, string>
}
const cockpit = useCockpit()
// Fetch data
const { data } = await useAsyncData('homepage', async () => {
const [posts, siteSettings] = await Promise.all([
cockpit.getItems<Post>('posts', {
filter: { published: true },
sort: { _created: -1 },
limit: 12,
populate: 1
}),
cockpit.getSingleton<SiteSettings>('site_settings')
])
return { posts, siteSettings }
})
const posts = computed(() => data.value?.posts || [])
const siteSettings = computed(() => data.value?.siteSettings)
// Helper function
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString()
}
// SEO
useHead({
title: () => siteSettings.value?.site_title || 'Blog',
meta: [
{ name: 'description', content: () => siteSettings.value?.site_description || '' }
]
})
</script>
Dynamic Blog Post Pages
Create pages/blog/[slug].vue
:
<template>
<article v-if="post" class="max-w-4xl mx-auto">
<header class="mb-8">
<h1 class="text-4xl font-bold mb-4">{{ post.title }}</h1>
<NuxtImg
v-if="post.featured_image"
:src="cockpit.getAsset(post.featured_image, { w: 800, h: 400, q: 80 })"
:alt="post.title"
width="800"
height="400"
class="w-full rounded-lg mb-6"
/>
<div class="text-gray-600 flex items-center gap-4">
<span>By {{ post.author }}</span>
<time>{{ formatDate(post._created) }}</time>
</div>
</header>
<div
class="prose prose-lg max-w-none"
v-html="post.content"
/>
<footer v-if="post.tags?.length" class="mt-8 pt-8 border-t">
<div class="flex flex-wrap gap-2">
<span
v-for="(tag, index) in post.tags"
:key="index"
class="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
>
{{ tag }}
</span>
</div>
</footer>
</article>
<div v-else-if="pending" class="text-center py-12">
<p>Loading...</p>
</div>
<div v-else class="text-center py-12">
<h1 class="text-2xl font-bold mb-4">Post not found</h1>
<NuxtLink to="/" class="text-blue-600 hover:underline">
Back to homepage
</NuxtLink>
</div>
</template>
<script setup lang="ts">
interface Post {
_id: string
title: string
slug: string
content: string
excerpt?: string
featured_image?: any
author: string
published: boolean
tags?: string[]
_created: number
}
const route = useRoute()
const cockpit = useCockpit()
// Fetch post data
const { data: post, pending, error } = await useAsyncData(
`post-${route.params.slug}`,
async () => {
// First get post by slug to find its ID
const posts = await cockpit.getItems<Post>('posts', {
filter: {
slug: route.params.slug,
published: true
},
fields: { _id: 1, slug: 1 },
limit: 1
})
if (!posts || posts.length === 0) {
throw createError({ statusCode: 404, statusMessage: 'Post not found' })
}
// Use getItem to fetch the full post
const fullPost = await cockpit.getItem<Post>('posts', posts[0]._id)
if (!fullPost || !fullPost.published) {
throw createError({ statusCode: 404, statusMessage: 'Post not found' })
}
return fullPost
}
)
// Handle error
if (error.value) {
throw createError({ statusCode: 404, statusMessage: 'Post not found' })
}
// Helper function
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString()
}
// SEO
useHead({
title: () => post.value?.title || 'Post',
meta: [
{ name: 'description', content: () => post.value?.excerpt || '' },
{ property: 'og:title', content: () => post.value?.title || '' },
{ property: 'og:description', content: () => post.value?.excerpt || '' },
{ property: 'og:image', content: () => post.value?.featured_image
? cockpit.getAsset(post.value.featured_image, { w: 1200, h: 630 })
: ''
}
]
})
</script>
API Routes for Revalidation
Create server/api/revalidate.post.ts
:
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const body = await readBody(event)
const query = getQuery(event)
// Check for secret to confirm this is a valid request
if (query.secret !== config.revalidationSecret) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid token'
})
}
try {
const { collection, item } = body
const nitroApp = useNitroApp()
// Clear cached data based on the updated content
switch (collection) {
case 'posts':
// Clear homepage cache
await nitroApp.storage.removeItem('cockpit:homepage')
// Clear specific post cache
if (item?.slug) {
await nitroApp.storage.removeItem(`cockpit:post-${item.slug}`)
}
break
case 'site_settings':
// Clear all cached pages that use site settings
await nitroApp.storage.clear('cockpit:')
break
default:
// Clear homepage cache for any other content
await nitroApp.storage.removeItem('cockpit:homepage')
}
return { revalidated: true }
} catch (err) {
throw createError({
statusCode: 500,
statusMessage: 'Error revalidating'
})
}
})
Advanced Patterns
Cockpit Image Component
Create components/CockpitImage.vue
:
<template>
<NuxtImg
v-if="asset?._id"
:src="imageUrl"
:alt="alt || asset.title || ''"
:width="width"
:height="height"
:class="className"
v-bind="$attrs"
/>
</template>
<script setup lang="ts">
interface Props {
asset?: {
_id: string
title?: string
}
width: number
height: number
quality?: number
alt?: string
className?: string
}
const props = withDefaults(defineProps<Props>(), {
quality: 80
})
const cockpit = useCockpit()
const imageUrl = computed(() => {
if (!props.asset?._id) return null
return cockpit.getAsset(props.asset, {
w: props.width,
h: props.height,
q: props.quality
})
})
</script>
Search Functionality
Create pages/search.vue
:
<template>
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold mb-8">Search</h1>
<form @submit.prevent="handleSearch" class="mb-8">
<div class="flex gap-4">
<input
v-model="searchQuery"
type="search"
placeholder="Search posts..."
class="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
:disabled="loading"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{{ loading ? 'Searching...' : 'Search' }}
</button>
</div>
</form>
<div v-if="results.length > 0" class="space-y-6">
<h2 class="text-xl font-semibold">
Results for "{{ route.query.q }}"
</h2>
<article
v-for="post in results"
:key="post._id"
class="border-b pb-6"
>
<h3 class="text-xl font-semibold mb-2">
<NuxtLink
:to="`/blog/${post.slug}`"
class="hover:text-blue-600"
>
{{ post.title }}
</NuxtLink>
</h3>
<p class="text-gray-600">{{ post.excerpt }}</p>
</article>
</div>
<div v-else-if="searched && !loading" class="text-gray-600">
No results found for "{{ route.query.q }}"
</div>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const cockpit = useCockpit()
const searchQuery = ref(route.query.q?.toString() || '')
const results = ref([])
const loading = ref(false)
const searched = ref(false)
const handleSearch = async () => {
if (!searchQuery.value.trim()) return
loading.value = true
searched.value = true
try {
const searchResults = await cockpit.getItems('posts', {
filter: {
$or: [
{ title: { $regex: searchQuery.value, $options: 'i' } },
{ content: { $regex: searchQuery.value, $options: 'i' } },
{ excerpt: { $regex: searchQuery.value, $options: 'i' } }
],
published: true
},
limit: 20
})
results.value = searchResults
// Update URL without reload
router.push({
query: { q: searchQuery.value }
})
} catch (error) {
console.error('Search error:', error)
} finally {
loading.value = false
}
}
// Search on mount if query exists
onMounted(() => {
if (searchQuery.value) {
handleSearch()
}
})
</script>
Global State Management
Create stores/cockpit.ts
:
export const useCockpitStore = defineStore('cockpit', () => {
const siteSettings = ref(null)
const navigation = ref([])
const cockpit = useCockpit()
const loadSiteSettings = async () => {
if (siteSettings.value) return siteSettings.value
try {
const settings = await cockpit.getSingleton('site_settings')
siteSettings.value = settings
return settings
} catch (error) {
console.error('Failed to load site settings:', error)
return null
}
}
const loadNavigation = async () => {
if (navigation.value.length > 0) return navigation.value
try {
const nav = await cockpit.getItems('navigation', {
sort: { order: 1 }
})
navigation.value = nav
return nav
} catch (error) {
console.error('Failed to load navigation:', error)
return []
}
}
return {
siteSettings: readonly(siteSettings),
navigation: readonly(navigation),
loadSiteSettings,
loadNavigation
}
})
Performance Optimization
Static Generation
For static site generation, update nuxt.config.ts
:
export default defineNuxtConfig({
nitro: {
prerender: {
crawlLinks: true,
routes: async () => {
// Generate routes for all published posts
const cockpit = useCockpit()
const posts = await cockpit.getItems('posts', {
filter: { published: true },
fields: { slug: 1 }
})
return posts.map(post => `/blog/${post.slug}`)
}
}
}
})
ISR with Nitro
Configure ISR in nuxt.config.ts
:
export default defineNuxtConfig({
routeRules: {
'/': { isr: 60 }, // Revalidate homepage every minute
'/blog/**': { isr: 300 }, // Revalidate blog posts every 5 minutes
}
})
Data Fetching Optimization
Create composables/useCockpitData.ts
:
export const useCockpitData = <T = any>(
key: string,
fetcher: () => Promise<T>,
options: {
ttl?: number
immediate?: boolean
} = {}
) => {
const nuxtApp = useNuxtApp()
// Use payload for SSR/SSG
const cached = useState<T>(`cockpit-${key}`, () => null)
const { data, pending, error, refresh } = useAsyncData(
key,
async () => {
// Return cached data if available and fresh
if (cached.value && options.ttl) {
const cacheTime = nuxtApp.payload._cockpitCache?.[key]
if (cacheTime && Date.now() - cacheTime < options.ttl * 1000) {
return cached.value
}
}
// Fetch fresh data
const result = await fetcher()
// Cache the result
cached.value = result
if (!nuxtApp.payload._cockpitCache) {
nuxtApp.payload._cockpitCache = {}
}
nuxtApp.payload._cockpitCache[key] = Date.now()
return result
},
{
immediate: options.immediate !== false
}
)
return {
data: computed(() => data.value || cached.value),
pending,
error,
refresh
}
}
Deployment
Environment Variables
Set these environment variables in your deployment platform:
NUXT_PUBLIC_COCKPIT_API_URL=https://your-cockpit-instance.com/api
NUXT_COCKPIT_API_KEY=your-production-api-key
NUXT_REVALIDATION_SECRET=your-webhook-secret
Best Practices
1. Type Safety
Define types for your content models:
// types/cockpit.ts
export interface Post {
_id: string
title: string
slug: string
content: string
excerpt?: string
featured_image?: CockpitAsset
author: string
published: boolean
tags?: string[]
_created: number
_modified: number
}
export interface CockpitAsset {
_id: string
title?: string
mime: string
size: number
width?: number
height?: number
}
2. Error Handling
Always handle errors gracefully with Nuxt's error system:
<script setup>
const { data, error } = await useAsyncData('posts', async () => {
try {
return await cockpit.getItems('posts')
} catch (err) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch posts'
})
}
})
if (error.value) {
throw createError(error.value)
}
</script>
3. SEO Optimization
Use Nuxt's built-in SEO features:
<script setup>
useSeoMeta({
title: post.value?.title,
description: post.value?.excerpt,
ogTitle: post.value?.title,
ogDescription: post.value?.excerpt,
ogImage: post.value?.featured_image
? cockpit.getAsset(post.value.featured_image, { w: 1200, h: 630 })
: null,
twitterCard: 'summary_large_image'
})
</script>
Troubleshooting
Common Issues
- CORS Errors: Configure CORS in Cockpit for your Nuxt.js domain
- API Key Issues: Ensure the API key is in server-side runtime config
- Hydration Mismatches: Use
ClientOnly
wrapper for client-only content - Image Loading: Configure image domains in
nuxt.config.ts
Debug Mode
Add debug logging:
// composables/useCockpit.ts
const request = async (endpoint: string, options = {}) => {
if (process.dev) {
console.log('Cockpit API Request:', endpoint, options)
}
// ... rest of request logic
}
This integration provides a complete foundation for building modern, performant applications with Nuxt.js and Cockpit CMS, leveraging Vue.js reactivity and Nuxt's powerful features.