Build an automatically deployed Blog with Nuxt Content. 🚀
Nuxt Content provides an amazing Developer Experience when it comes to quickly generating a performant site for your blog, docs and other content publishing needs. The Framework is simple to learn and here's how you can get started.
Create a new Nuxt Content App with Yarn
yarn create nuxt-app <your-app-name>
Add your firebase project
Note you'll need to install firebase:
npm i -g firebase-tools@latest
You'll need to be authenticated to your firebase project.
firebase login
If you don't have a project yet, run the following and follow the steps:
firebase projects:create
To initialize your blog run:
firebase init hosting
The options below will allow your code to deploy automatically to your firebase hosting everytime you push to your main branch.
- ? What do you want to use as your public directory?
dist
- ? Configure as a single-page app (rewrite all urls to /index.html)? (y/N)
N
- ? Set up automatic builds and deploys with GitHub?
y
- ? For which GitHub repository would you like to set up a GitHub workflow? (format: user/repository) (your-account/nuxt-content-tutorial)
- ? Set up the workflow to run a build script before every deploy? (y/N)
y
- ? What script should be run before every deploy? (npm ci && npm run build)
npm run test && npm run generate
- ? Set up automatic deployment to your site's live channel when a PR is merged? (Y/n)
Y
- ? What is the name of the GitHub branch associated with your site's live channel?
main
Note
Check the .github/workflows
directory after this command. Make sure only merge option exists and correct build script is listed.
The working workflow file
.github/workflows/firebase-hosting-merge.yml
# This file was auto-generated by the Firebase CLI
# https://github.com/firebase/firebase-tools
name: Deploy to Firebase Hosting on merge
'on':
push:
branches:
- main
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: yarn
- name: Generate
run: yarn generate
- name: Deploy
uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: '${{ secrets.GITHUB_TOKEN }}'
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_KAIZEN_CODES_BLOG }}'
channelId: live
projectId: kaizen-codes-blog
The Layout File
<template>
<v-app>
<TheNavigation />
<v-main>
<v-container>
<v-row>
<v-col cols="12" md="10">
<Nuxt />
</v-col>
<v-col cols="12" md="2">
<!-- Ads / permanent side content -->
</v-col>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
<script>
export default {
name: 'DefaultLayout',
}
</script>
You'll notice that the layout file has 2 columns. The second column will be a placeholder for any possible future content. Like a ads container perhaps. 🤑
Create the home page
<template>
<v-container fluid>
<div class="intro mt-5 mb-8">
<h1 class="text-h1">Kaizen Codes Blog</h1>
<h2 class="mt-2">
100x Your Nuxt.js skills <span class="emoji">🚀</span>
</h2>
</div>
<v-row v-if="!posts.length">
<v-col cols="12">
<p>No posts found, yet. <span class="emoji">😁</span></p>
</v-col>
</v-row>
<v-row v-else class="posts-container mt-5">
<v-col cols="12">
<div class="filter">
<v-select
v-if="categories.length"
v-model="category"
style="width: 100px"
outlined
dense
hide-details="auto"
:items="categories"
/>
</div>
</v-col>
<v-col v-for="post in posts" :key="post.slug" cols="12" md="6">
<v-card elevation="0">
<v-card-title> {{ post.title }} </v-card-title>
<v-card-subtitle>
{{
new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
}).format(new Date(post.createdAt))
}}
</v-card-subtitle>
<v-card-text>
<nuxt-content :document="{ body: post.excerpt }" />
</v-card-text>
<v-card-actions>
<v-btn text :to="post.path">Read More</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-row v-if="posts.length" class="post-pagination">
<v-col class="text-right" cols="12">
<v-btn :disabled="page === 1" @click="fetchPrevious">
<v-icon small> mdi-arrow-left </v-icon>
Previous
</v-btn>
<v-btn :disabled="!nextPage" @click="fetchNext">
Next
<v-icon small> mdi-arrow-right </v-icon>
</v-btn>
</v-col>
</v-row>
</v-container>
</template>
<script>
export default {
name: 'HomePage',
layout: 'DefaultLayout',
async asyncData({ $content }) {
const limit = 5
const page = 1
const fetchedPosts = await $content()
.limit(limit)
.sortBy('createdAt', 'desc')
.skip((limit - 1) * (page - 1))
.fetch()
const nextPage = fetchedPosts.length === limit
const posts = nextPage ? fetchedPosts.slice(0, -1) : fetchedPosts
return {
page,
limit,
posts,
nextPage,
}
},
data: () => ({
category: 'all',
categories: [],
}),
fetch() {
this.$content()
.only(['category'])
.fetch()
.then((categories) => {
const payload = Array.from(new Set(categories.map((c) => c.category)))
this.categories = ['all', ...payload]
})
},
computed: {
searchQuery() {
return this.$store.state.query
},
},
watch: {
async searchQuery(newValue) {
this.page = 1
await this.fetchPosts(newValue)
},
async category() {
this.page = 1
await this.fetchPosts(this.searchQuery)
},
},
methods: {
async fetchNext() {
this.page += 1
await this.fetchPosts()
},
async fetchPrevious() {
this.page -= 1
await this.fetchPosts()
},
async fetchPosts(query = '') {
let baseFetch = this.$content().limit(this.limit)
if (this.category !== 'all') {
baseFetch = baseFetch.where({ category: this.category })
}
const fetchedPosts = await baseFetch
.sortBy('createdAt', 'desc')
.search(query)
.skip((this.limit - 1) * (this.page - 1))
.fetch()
this.nextPage = fetchedPosts.length === this.limit
const posts = this.nextPage ? fetchedPosts.slice(0, -1) : fetchedPosts
this.posts = posts
},
},
}
</script>
The home route in pages/index.vue
displays a list of the posts the you create in ./content/*
, Visit the Nuxt Content homepage to see how you can write .md
files here, or checkout: My Youtube Video.
Create the navigation component (with search)
<template>
<v-app-bar max-height="64">
<v-app-bar-nav-icon>
<img height="36" src="/kaizen.png" alt="Kaizen Codes Blog" />
</v-app-bar-nav-icon>
<div
v-if="$route.name === 'index'"
class="search d-flex align-center justify-end ml-auto"
>
<v-text-field
v-model="search"
hide-details="auto"
dense
placeholder="Search for a post"
prepend-icon="mdi-magnify"
outlined
:append-icon="search.length ? 'mdi-close' : ''"
@click:append="clearSearch"
/>
</div>
</v-app-bar>
</template>
<script>
export default {
name: 'TheNavigation',
computed: {
search: {
get() {
return this.$store.state.query
},
set(value) {
this.$store.commit('SET_QUERY', value)
},
},
},
methods: {
clearSearch() {
this.$store.commit('SET_QUERY', '')
},
},
}
</script>
<style></style>
components/TheNavigation.vue
Talks to the Vuex store to update the text of the search menu. Let's Add this store in store/index.js
.
export const state = () => ({
query: '',
})
export const mutations = {
SET_QUERY(state, payload) {
state.query = payload
},
}
Create the Single Post page
<template>
<v-container fluid>
<section class="my-3">
<v-btn text to="/">
<v-icon small class="mr-2">mdi-arrow-left</v-icon>
Go back
</v-btn>
</section>
<section class="post-content">
<h2 class="text-h2 mb-10">{{ post.title }}</h2>
<nuxt-content :document="post" />
</section>
<v-row>
<v-col class="d-flex justify-space-between align-center mt-5" cols="12">
<v-btn :disabled="!prev" :to="prev && prev.path">Previous Post</v-btn>
<v-btn :disabled="!next" :to="next && next.path">Next Post</v-btn>
</v-col>
</v-row>
</v-container>
</template>
<script>
export default {
name: 'PostPage',
layout: 'DefaultLayout',
async asyncData({ $content, error, params }) {
// TODO Paginate
const [prev, next] = await $content()
.only(['path'])
.sortBy('createdAt', 'desc')
.surround(params.slug)
.fetch()
const post = await $content(params.slug)
.fetch()
.catch(() =>
error({
statusCode: 404,
message: 'Oops, looks like that does not exist...',
})
)
return {
post,
prev,
next,
}
},
head() {
return {
title: this.post.title,
meta: [
{
hid: 'description',
name: 'description',
content: this.post.description,
},
// Open Graph
{ hid: 'og:title', property: 'og:title', content: this.post.title },
{
hid: 'og:description',
property: 'og:description',
content: this.post.description,
},
// Twitter Card
{
hid: 'twitter:title',
name: 'twitter:title',
content: this.post.title,
},
{
hid: 'twitter:description',
name: 'twitter:description',
content: this.post.description,
},
],
}
},
}
</script>
<style></style>
This page resides in pages/_slug.vue
and displays the content of each post, with navigation to previous and next posts.
This post isn't very detailed, to follow along step-by-step consider Watching This. Future posts in this blog will definitely 😉 be more detailed and useful. Good luck and happy coding!