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!