✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

App27 min read

How to Build a Learning Platform with Strapi CMS and Gridsome

April 27, 2022Updated on July 10, 2026

Building a learning platform with Strapi CMS and Gridsome is a really great way to explore the possibilities of Strapi. Gridsome is a Vue.js-powered Jamstack framework for building static sites—a static site generator. A static site generator essentially takes dynamic content and data and generates static HTML files deployed over a content delivery network or static web host.

What is Strapi?

Strapi is a headless content management system (CMS). A Headless CMS is a content management system that makes content accessible via REST APIs, GraphQL APIs, or Git workflow. As the name implies, it gives the developer the freedom to choose their most suitable frontend setup.

Strapi is even better because it's 100% JavaScript, and my favorite feature is how you can fully customize everything. It's almost like you built your own CMS.

Prerequisites

To follow this article, you’d need the following:

What we’ll be building

We will build a simple learning platform using Strapi and Gridsome. Strapi will handle the backend. It should be able to do the following:

  • Create and store courses,
  • Provide REST endpoints for the courses, and
  • Provide endpoints to handle authentication.

Then, we'll use Gridsome on the front, and we'll be able to perform the following:

  • Display all courses and create the view for a single course and
  • Handle authentication states with Vuex

Here's a demo of what we'll be building in action:

A Demo

Setting up the Strapi API

First, let's scaffold a new Strapi project. Run this command on your terminal:

     yarn create strapi-app strapi-learning-backend --quickstart

or

     npx create-strapi-app@latest strapi-learning-backend --quickstart

With the --quickstart flag, the command will automatically create the project using the Quickstart mode, which helps you get started with Strapi faster. It will install all the necessary dependencies, configurations, and plugins to start a new project. This installation also includes SQLite as the database for local development.

What your screen should look like

Strapi will start the dev server after the project has been successfully installed however, if you run into any errors (that are not Strapi-related), run this command to start the Strapi development server.

     yarn develop

or

     npm run develop

Strapi-succesful-install-run-develop

Visiting http://localhost:1337/admin will take you to the Strapi admin dashboard, but first will be presented with this screen prompting you to create an admin account:

Strapi-admin-register-page

Now that you've successfully launched the admin dashboard let's create the courses.

Content-Type for Courses

Strapi content type lets you define how your content will be structured by adding types and populating them with fields. Let's go ahead and create a collection type for Courses.

Strapi-welcome-screen

Click on the button that says CREATE YOUR FIRST CONTENT-TYPE. This will take you to the Content-Type Builder page.

Strapi-content-type-builder-page

Click on Create new collection type under COLLECTION TYPE in the sidebar. You will now be prompted to give the collection type a name:

Strap-create-strapi-courses-collection-type-modal

Note: Strapi automatically pluralizes the collection type names. If the name is course, your API endpoint will be /courses. Check this reference on the docs.

Click on continue, and you will be prompted with this screen to select a field for your collection type:

Strapi-create-collection-type-fields-options

These are the fields we will be adding to our courses type:

  • course_title: the title of the course (Text - Short Text)
  • short_description: (Text - Long Text)
  • long_description: (Rich text)
  • course_image: (Media - Single Media)
  • course_video: (Media - Single Media)

I have demonstrated how to create a field in the GIF below:

Ensure you click on save when you're done with creating fields. Now we've created fields for our content type, it should look something like this:

Strapi-course-collection-type

Now, let's go ahead to add courses through the Strapi WYSIWYG editor. Click on Strapi course (or whatever name you gave your collection type) on the left sidebar of the dashboard on the Content Manager page, then click on Add new entry and it will present you with this screen, which you can populate the fields. Let's try adding one course first:

     course_title: Strapi CMS with Gatsby,
     short_description: Learn how to use Strapi CMS with Gatsby to build powerful web applications,
     long_description: Gatsby is a React-based open-source framework for creating websites and apps. It's great whether you're building a portfolio site or blog, or a high-traffic e-commerce store or company homepage,
     course_image: https://res.cloudinary.com/samtech/image/upload/v1608285507/with_gatsby_e4dca7b0c3_9e4d8db26e.png,
     course_video: https://res.cloudinary.com/samtech/video/upload/v1608287197/Strapi_and_Gatsby_Blog_Starter_b98bcc1544.mp4

To add media with URL, instead of selecting the file locally, click on the select media field, click on Upload assets button

Strapi-course-strapi-course-create-2021-12-17-22_45_11

When you have done this, ensure you click the SAVE button. This means that the content is in the draft stage. Clicking on the Publish button will move the content from draft to published. The course summary page should look like this now:

Strapi-save-course-entry

Awesome! Let’s fetch the course we just created. On the left sidebar, click on Settings and under USERS & PERMISSIONS PLUGIN > Roles > Public > Permissions. Tick the following boxes to allow requests to the Strapi-course collection.

Strapi-public-role-permissions-Strapi-course

You will get the JSON response below when you send a request to http://localhost:1337/api/strapi-courses.

Strapi-get-request-to-strapi-courses

If you notice, we don’t get the course_image and course_video fields from this request. To get these fields, we need to add the ?populate=* query parameter to our request. Now try sending a request to http://localhost:1337/api/strapi-courses?populate=*

Strapi-get-request-to-strapi-courses-populate

Voila!

Congratulations on getting this far! Let's now deal with uploads before we add more courses.

Working with media uploads

Thanks to the strapi-plugin-upload package, your media files uploads are in public/uploads by default. If you've gone through the results of our query, you will notice that the course_image.provider is local. This will work fine on the Strapi admin server, but when connecting with your frontend, you'll need a provider to serve your media files. Strapi allows you to integrate external providers such as Cloudinary, AWS-S3, Google cloud storage, and more. We will use [Cloudinary] for this tutorial (https://cloudinary.com/). Go to your Cloudinary dashboard and copy your CLOUDINARY_NAME, CLOUDINARY_KEY, and CLOUDINARY_SECRET. We will need them soon. In this demo, we will add these values to our code instead of accessing them from a .env file.

Strapi-cloudinary-account-details

We have to set up the Strapi provider Cloudinary plugin. Make sure Strapi is not currently running and Install the package in your Strapi backend by running this command on your terminal.

     yarn add @strapi/provider-upload-cloudinary

To register the plugin, go to the config directory and create a plugins.js file:

     // config/plugins.js
     
     module.exports = ({ env }) => ({
       // ...
       upload: {
         config: {
           provider: "cloudinary",
           providerOptions: {
             cloud_name: env("CLOUDINARY_NAME"),
             api_key: env("CLOUDINARY_KEY"),
             api_secret: env("CLOUDINARY_SECRET"),
           },
           actionOptions: {
             upload: {},
             delete: {},
           },
         },
       },
       // ...
     });

Make sure to include your keys in your .env file and update the optional fields accordingly. NOTE: In order fix the preview issue on your Strapi dashboard where after uploading a photo, it uploads to Cloudinary, but you wont be able to preview the photo on your Strapi admin dashboard. You can fix this by replacing strapi::security string with the object below in ./config/middlewares.js.

     // config/middlewares.js
     
     module.exports = [
       'strapi::errors',
       {
         name: 'strapi::security',
         config: {
           contentSecurityPolicy: {
             useDefaults: true,
             directives: {
               'connect-src': ["'self'", 'https:'],
               'img-src': ["'self'", 'data:', 'blob:', 'res.cloudinary.com'],
               'media-src': ["'self'", 'data:', 'blob:', 'res.cloudinary.com'],
               upgradeInsecureRequests: null,
             },
           },
         },
       },
       'strapi::cors',
       'strapi::poweredBy',
       'strapi::logger',
       'strapi::query',
       'strapi::body',
       'strapi::favicon',
       'strapi::public',
     ];

When you save this, restart your server. Now, by adding another course, let's confirm that it's working fine.

    course_title: Strapi CMS with Vue,
    short_description: Learn how to build web apps with Strapi CMS and Vuejs,
    long_description: Vue (pronounced /vjuː/, like view) is a progressive framework for building user interfaces. Unlike other monolithic frameworks, Vue is designed from the ground up to be incrementally adaptable. The core library is focused on the view layer only and is easy to pick up and integrate with other libraries or existing projects.,
    download this image or just upload via url: https://res.cloudinary.com/samtech/image/upload/v1608287448/with_vue_9386f31664_ccce641ecf.png,
    download this video or just upload via url: https://res.cloudinary.com/samtech/video/upload/v1608288859/Strapi_and_Gridsome_Portfolio_Starter_5bae22f749.mp4

P.S: Uploading a video might take some time.

When you have uploaded media and published the new course successfully, query http://localhost:1337/api/strapi-courses and you will get something like this:

Strap-api-get-strapi-courses

Awesome! The provider is now cloudinary; we even have a thumbnail format. You can go on to add more courses here. However, we still need to deal with one more thing. Remember, our app will have authentication, so we have to set permissions for authenticated users. Go to Settings > Users & Permissions Plugin > Roles > Authenticated Tick the boxes and save.

Strapi-Authenticated-role-permission

Awesome, we just succeeded in Setting up our API! That’s really cool.

Deployment

Deploying a Strapi application is easy. However, we'll not be covering that in this article. The Strapi docs handle this topic very well. We've gone this far. You could trust me, right? Learn how to Deploy your Strapi app here. I recommend you use Heroku.

Setting up the Gridsome project

Before we begin, Gridsome is popularly known for building "statically generated websites," but for this article, we will go extra lengths into working with its Client API. The Client API lets you install Vue plugins such as Vuex, which we will be using to handle authentication later in this section.

Installing Gridsome

Install the Gridsome CLI globally by executing this command on your terminal:

     npm install --global @gridsome/cli

This will enable you to use the Gridsome CLI in your machine. Then run this command to create a new Gridsome project:

     gridsome create strapi-learning 

This might take some time but when it's done, go to the project directory by running this command:

     cd strapi-learning 

Open the Gridsome starter project on your preferred code editor. Then, run

     gridsome develop

to start your local development server.

img

Navigate to http://localhost:8080 to view the Gridsome app.

img

Setting up layout and pages

Let's begin by creating the layout component for our app. In Gridsome, a layout component is where you insert other components you want to use across the site/app. Components such as the navbar, footer, and so on. The gridsome create has already set up the Layout to be used globally across pages. Go to src/main.js, and you will see your layouts being configured. Also, we will be adding a font-family to the head of our site:

     // src/main.js
     
     import DefaultLayout from '~/layouts/Default.vue'
     
     export default function (Vue, {head}) {
       head.link.push({
           rel: 'stylesheet',
           href: 'https://fonts.googleapis.com/css2?family=Karla&display=swap',
         })
       // Set default layout as a global component
       Vue.component('Layout', DefaultLayout)
     }

Now, head over to src/layouts/Default.vue and replace the existing project’s Default.vue code:

     // src/layouts/Default.vue
     
     <template>
         <div class="layout">
             <header class="header">
                 <Navbar />
             </header>
             <slot />
         </div>
     </template>
     
     <static-query>
     query {
       metadata {
         siteName
       }
     }
     </static-query>
     
     <script>
     import Navbar from '~/components/Navbar';
     export default {
         components: {
             Navbar,
         },
     };
     </script>
     <style>
     body {
         font-family: 'Karla', sans-serif;
         margin: 0;
         padding: 0;
         background: #753ff6;
         line-height: 1.5;
     }
     
     span{
         text-transform: uppercase;
     }
     
     .layout {
         max-width: 950px;
         margin: 0 auto;
         padding-left: 20px;
         padding-right: 20px;
     }
     
     .header {
         display: flex;
         justify-content: space-between;
         align-items: center;
         margin-bottom: 20px;
     
     }
    
     .nav__link {
         margin-left: 20px;
     }
     
     @media (max-width: 700px){
         nav{
             flex-direction: column;
             margin-bottom: 1rem;
         }
     }
     </style>

Let's now create the Navbar component. Go to src/Components and create a Navbar.vue file with the following content:

     // src/Components/Navbar.vue
    
     <template>
         <div class="navbar">
             <nav>
                 <div>
                     <h1>
                         <g-link to="/"> Strapi Learning Platform </g-link>
                     </h1>
                 </div>
                 <ul>
                     <div v-if="disableNav" class="ul">
                         <li>Welcome, {{ user.username }}</li>
                         <li>
                             <button class="btn btn-outline" @click="logout">
                                 Logout
                             </button>
                         </li>
                     </div>
                     <div v-else class="ul">
                         <li>
                             <g-link to="/signup" class="btn"> Register </g-link>
                         </li>
                         <li>
                             <g-link to="/login" class="btn btn-outline">
                                 Login
                             </g-link>
                         </li>
                     </div>
                 </ul>
             </nav>
         </div>
     </template>
     
     <script>
     export default {
         data() {
             return {
                 user: {},
             };
         },
         computed: {
             isLoggedIn() {
                 return this.$store.getters.isLoggedIn;
             },
     
             disableNav() {
                 if (this.$store.getters.authStatus === 'success') {
                     return true;
                 } else if (this.$store.getters.authStatus === 'error') {
                     return false;
                 }
             },
         },
     
         mounted() {
             this.user = JSON.parse(localStorage.getItem('user'));
         },
         methods: {
             logout() {
                 this.$store
                     .dispatch('logout')
                     .then(() => {
                         console.log('I am serious');
                         this.$router.push('/login');
                     })
                     .catch(err => {
                         console.log(err);
                     });
             },
         },
     };
     </script>
     
     <style scoped>
     .navbar {
         width: 100%;
     }
     h1 a {
         color: #fff;
         text-decoration: none;
     }
     nav {
         display: flex;
         justify-content: space-between;
         align-items: center;
     }
     
     ul {
         list-style-type: none;
         margin: 0;
         padding: 0;
     }
     
     .ul {
         display: flex;
         align-items: baseline;
     }
     
     .btn {
         border: none;
         box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
         cursor: pointer;
         height: 4.375em;
         border-radius: 1.5em;
         text-decoration: none;
         padding: 1.5em 1.5em;
         background-color: rgb(87, 41, 178);
         color: #fff;
         font-size: 14px;
         font-weight: 600;
         font-family: 'Karla';
         outline: none;
     }
     
     .btn-outline {
         background: #fff;
         border: 1px solid rgb(87, 41, 178);
         color: #000;
     }
     
     li {
         margin-left: 0.5rem;
         margin-right: 0.5rem;
         color: #fff;
     }
     
     .nav-link {
         color: #fff;
         text-decoration: none;
     }
     
     @media (max-width: 700px) {
         ul {
             justify-content: space-between;
         }
     }
     </style>

Your page should look like this:

A sample screenshot

Let’s add some content to the homepage. Replace the code in pages/Index.vue with this code snippet:

     // src/pages/Index.vue
     
     <template>
         <Layout>
             <section class="hero-section">
                 <h1>Welcome!</h1>
     
                 <p>
                     Strapi is the leading open-source headless CMS. It’s 100%
                     Javascript, fully customizable and developer-first.
                 </p>
     
                 <p>
                     <g-link to="/login"> Login </g-link> to enjoy all our courses
                     here
                 </p>
             </section>
         </Layout>
     </template>
     
     <style>
     .home-links a {
         margin-right: 1rem;
     }
     
     .hero-section {
         background: #fff;
         color: #2f2e8b;
         padding: 1rem;
     }
     </style>

Awesome! So far, we have implemented the Navbar and Homepage. The page should now look like this:

A sample screenshot

We have succeeded in setting up the page layout. Next, we will handle the register and login functionality.

Handling authentication

When building the Strapi API earlier on, we ensured that only authenticated users could access our courses. This means that a user will have to provide a JWT token as an Authorization header when making the GET request. Firstly, we need to install Axios and Vuex. Run this command on your terminal:

     npm install axios vuex

Setting up Axios

We will need Axios for our HTTP requests. Open the src/main.js file and replace the existing code with this:

     // main.js
     
     import DefaultLayout from '~/layouts/Default.vue'
     import Vuex from 'vuex'
     import axios from 'axios'
     
     export default function (Vue, { head }) {
       Vue.use(Vuex)
     
       Vue.prototype.$http = axios;
     
       const token = process.isClient ? localStorage.getItem(`Bearer ${token}`) : false
     
       if (token) {
         Vue.prototype.$http.defaults.headers.common['Authorization'] = `Bearer ${token}`
         console.log('token is active',token)
       }
     
       head.link.push({
         rel: 'stylesheet',
         href: 'https://fonts.googleapis.com/css2?family=Karla&display=swap',
       })
     
       Vue.component('Layout', DefaultLayout)
     }

Here, you set the Authorization header on the Axios, so when you want to make HTTP requests, you don't need to set the Authorization header every time. Also, you will notice the process.client condition every time there is localStorage. This is because localStorage is only available in the browser. So, you check for process.isClient to avoid code running during server-side rendering.

Handling authentication with Vuex

Now you have Vuex installed, how do you use it? Gridsome Client API defines a client context, a reference to the Vue app's options. In essence, the Vuex store is one of those appOptions you will have to pass to the main Vue instance. In your src/main.js, modify this line of code:

     export default function (Vue, { head }) 

with this:

     export default function (Vue, { head, appOptions }) 

Still in src/main.js, add these lines of code before head.link.push({…})


     // src/main.js
     
     ...
     appOptions.store = new Vuex.Store({
       state: {},
       mutations:{},
       actions:{},
       getters: {}
     }) 
     head.link.push({...})

The Vuex store is available in our components with $store. Let's utilize the store to handle authentication. First, let's define the attributes of the state. A state is a store object that holds the application-level data that needs to be shared between components. Add these lines of code to the state:{} object in src/main.js

     // src/main.js
     
     ...
     state: {
           status: '',
           token: process.isClient ? localStorage.getItem('token') || '' : false,
           user: {}
       },
     ...

Here, the state will hold the authentication status, JWT token, and user data. Next, let's write mutations. Mutations are methods that modify the store state. They usually consist of a string type and a handler that accepts the state and payload as parameters. In the mutations object, add this code snippet:

     // src/main.js
     
     ...
     mutations: {
           AUTH_REQUEST(state){
             state.status = 'loading'
           },
           AUTH_SUCCESS(state, token, user) {
             state.status = 'success',
             state.token = token,
             state.user = user
           },
           AUTH_ERROR(state){
             state.status = 'error'
           },
           LOGOUT(state){
             state.status = 'logged out',
             state.token = ''
           }
       },
     ...

Next, let’s write actions. Actions are methods that trigger the mutations. When handling asynchronous tasks, actions are used before calling the corresponding mutations. In our project, we will handle actions for Login, register, and Logout. At this point, you'll start dealing with making HTTP requests. So, you must add essential environment variables before you write actions. Create the .env file in the root directory and add the Strapi API URL. If you did not deploy the API to Heroku, you have to spin up the local strapi dev server and use it as STRAPI_URL in your env file.

     //.env
     
     STRAPI_URL=<your strapi api url here> or http://localhost:1337

Now, let’s go back to writing actions. Let’s start with the action handling login:

     // src/main.js
     
     ...
     actions: {
        async login({ commit }, user) {
             commit('AUTH_REQUEST')
             await axios.post(`${process.env.STRAPI_URL}/auth/local/`, user)
               .then(response => {
                 const token = response.data.jwt
                 const user = response.data.user
     
                 if (process.isClient) {
                   localStorage.setItem('token', token)
                   localStorage.setItem('user', JSON.stringify(user))
                 }
                 axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
                 const something =  axios.defaults.headers.common['Authorization']
                 console.log({something})
                 commit('AUTH_SUCCESS', token, user)
                 console.log({user, token})
               })
     
               .catch(err => {
                 commit('AUTH_ERROR')
                 process.isClient ? localStorage.removeItem('token') : false
                 console.error(err)
               })
           },
     
       },
     ...

In this code snippet, you first pass a commit that will trigger the corresponding mutation, which will, in turn, make changes to the Vuex store. Then, you make an HTTP call to the Strapi server login route. You store the JWT token and user information on localStorage, then commit to the AUTH_SUCCESS mutation, updating the state attributes. You also assign the token to the Axios Authorization header. If the HTTP call fails, the state is updated, and the token is deleted even if it still exists. Next, the action handling register:

     // src/main.js
     
     ...
     async register({commit}, user) {
             commit('AUTH_REQUEST')
             await axios.post(`${process.env.STRAPI_URL}/auth/local/register`, user)
               .then(response => {
                 const token = response.data.jwt
                 const user = response.data.user
     
                 process.isClient ? localStorage.setItem('token', token) : false
     
                 axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
     
                 commit('AUTH_SUCCESS', token, user)
               })
               .catch(err => {
                 commit('AUTH_ERROR')
                 process.isClient ? localStorage.removeItem('token') : false
                 console.error(err)
               })
     
       },
     ...

This is similar with the login action. Finally, let’s handle the action for Logout

     // src/main.js
     
     ...
     logout({commit}){
         commit('LOGOUT')
         process.isClient ? localStorage.removeItem('token') : false
         delete axios.defaults.headers.common['Authorization']  
     }
     ...

That is it for actions. Finally, let’s write the getters. Getters are to an application store what computed properties are to a component. They return computed information from the store state. Add these lines of code to the getters:{} object.

     // src/main.js
     
     ...
     getters: {
           isLoggedIn: state =>  !!state.token,
           authStatus: state => state.status
     }
     ...

Awesome! You have successfully defined Vuex store attributes for our app. Now, let's go ahead to use the store in our components/pages. So far, your main.js should look like this:

     // src/main.js
     
     import DefaultLayout from '~/layouts/Default.vue'
     import Vuex from 'vuex'
     import axios from 'axios'
     
     export default function (Vue, { head, appOptions }) {
       Vue.use(Vuex)
       Vue.prototype.$http = axios
     
       const token = process.isClient ? localStorage.getItem(`Bearer ${token}`) : false
     
       if (token) {
         Vue.prototype.$http.defaults.headers.common['Authorization'] = `Bearer ${token}`
         console.log('token is active',token)
       }
     
       appOptions.store = new Vuex.Store({
         state: {
           status: '',
           token: process.isClient ? localStorage.getItem('token') || '' : false,
           user: {}
         },
         mutations: {
           AUTH_REQUEST(state){
             state.status = 'loading'
           },
           AUTH_SUCCESS(state, token, user) {
             state.status = 'success',
             state.token = token,
             state.user = user
           },
           AUTH_ERROR(state){
             state.status = 'error'
           },
           LOGOUT(state){
             state.status = 'logged out',
             state.token = ''
           }
         },
         actions: {
     
           async login({ commit }, user) {
             commit('AUTH_REQUEST')
             await axios.post(`http://localhost:1337/auth/local/`, user)
               .then(response => {
                 const token = response.data.jwt
                 const user = response.data.user
     
                 if (process.isClient) {
                   localStorage.setItem('token', token)
                   localStorage.setItem('user', JSON.stringify(user))
                 }
                 axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
                 const something =  axios.defaults.headers.common['Authorization']
                 console.log({something})
                 commit('AUTH_SUCCESS', token, user)
                 console.log({user, token})
               })
     
               .catch(err => {
                 commit('AUTH_ERROR')
                 process.isClient ? localStorage.removeItem('token') : false
                 console.error(err)
               })
           },
     
           async register({commit}, user) {
             commit('AUTH_REQUEST')
             await axios.post(`http://localhost:1337/auth/local/register`, user)
               .then(response => {
                 const token = response.data.jwt
                 const user = response.data.user
     
                 process.isClient ? localStorage.setItem('token', token) : false
     
                 axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
     
                 commit('AUTH_SUCCESS', token, user)
               })
               .catch(err => {
                 commit('AUTH_ERROR')
                 process.isClient ? localStorage.removeItem('token') : false
                 console.error(err)
               })
     
           },
           logout({commit}){
             commit('LOGOUT')
             process.isClient ? localStorage.removeItem('token') : false
             delete axios.defaults.headers.common['Authorization']
           }
         },
     
         getters: {
           isLoggedIn: state =>  !!state.token,
           authStatus: state => state.status
         }
       }),
     
       head.link.push({
         rel: 'stylesheet',
         href: 'https://fonts.googleapis.com/css2?family=Karla&display=swap',
       })
     
       Vue.component('Layout', DefaultLayout)
     }

To complete handling authentication, let’s build Login and Signup pages. Create a Login.vue page in the pages directory and add the code snippet below:

     // src/pages/Login.vue
     
     <template>
         <Layout>
             <form class="signup" onsubmit="return false" autocomplete="off">
                 <h1>Welcome Back</h1>
                 <h2>Don't have an account? <g-link to="/signup">Sign up</g-link></h2>
                 <div class="signup__field">
                     <input class="signup__input" type="text"  v-model="user.identifier" name="email"  required />
                     <label class="signup__label" for="email">Email</label>
                 </div>
     
                 <div class="signup__field">
                     <input class="signup__input" type="password" v-model="user.password" name="password"  required />
                     <label class="signup__label" for="password">Password</label>
                 </div>
     
                 <button @click="login">Sign in</button>
             </form>
         </Layout>
     </template>
     
     <script>
     export default {
         data(){
                 return{
                     user: {
                         identifier: '',
                         password: ''
                     }
                 }
             },
         methods:{
     
             login(){
                let identifier = this.user.identifier
                let password = this.user.password
                 console.log({identifier, password})
                this.$store.dispatch('login', {identifier, password})
     
                .then(() => this.$router.push('/home'))
                .catch((err) => {
                  this.$router.push('/')
                  console.error({err})
                 })
             }
         }
     }
     </script>
     
     <style> 
     </style>

If you have experience with working with forms in Vue.js, this code snippet is pretty straightforward. If you don't, you can pause at this point to go through the documentation here. It's important to note that the Strapi login endpoint requires an identifier (email or username) and password fields. Similar to the component for Login, create a Signup.vue file in the pages directory and add this code snippet:

     // src/pages/Signup.vue
     
     <template>
         <Layout>
             <form class="signup" onsubmit="return false" autocomplete="off">
                 <h1>Create account</h1>
                 <h2>Already have an account? <span><g-link to="/login">Sign in</g-link></span></h2>
     
                 <div class="signup__field">
                     <input class="signup__input" type="text" v-model="user.name" name="username" required />
                     <label class="signup__label" for="username">Username</label>
                 </div>
     
                 <div class="signup__field">
                     <input class="signup__input" type="email" v-model="user.email" name="email"  required />
                     <label class="signup__label" for="email">Email</label>
                 </div>
     
                 <div class="signup__field">
                     <input class="signup__input" type="password" v-model="user.password" name="password"  required />
                     <label class="signup__label" for="password">Password</label>
                 </div>
     
                 <button @click="register">Sign up</button>
             </form>
         </Layout>
     </template>
     
     <script>
     import axios from 'axios';
     export default {
         data(){
                 return{
                     user: {
                         email: '',
                         name: '',
                         password: ''
                     }
                 }
             },
         methods:{
     
             register(){
                 let reqObj = {
                   username: this.user.name,
                   email: this.user.email,
                   password: this.user.password,
                 }
     
                 this.$store.dispatch('register', reqObj)
                 .then(() => this.$router.push('/login'))
                 .catch(err => console.log(err))
             }
         }
     }
     </script>
     
     <style>
     </style>

Just like the Login component, we dispatched the reqObj to the Vuex store register() action. If it's successful, it will redirect the user to Login. You've noticed they're no styles for Login and Signup. This is because they have the same styles. So add this code snippet to the style tags of Login.vue and Signup.vue respectfully:

     // src/pages/Login.vue
     // src/pages/signup.vue
     
     <style scoped>
     
     body {
       background-color: #753ff6;
       width: 100%;
       min-height: 100vh;
     
       display: flex;
       align-items: center;
       justify-content: center;
     }
     
     button,
     input {
       border: none;
       outline: none;
     }
     
     /****************
           FORM
     *****************/
     .signup {
       background-color: white;
       width: 100%;
       max-width: 500px;
       padding: 50px 70px;
       display: flex;
       flex-direction: column;
       margin: auto;
     
       border-radius: 20px;
       box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
         0 4px 6px -2px rgba(0, 0, 0, 0.05);
     }
     
     h1 {
       text-align: center;
       color: #753ff6;
     }
     h2 {
       text-align: center;
       font-size: 1.2rem;
       font-weight: lighter;
     
       margin-bottom: 40px;
     }
     
     h2 span {
       text-decoration: underline;
       cursor: pointer;
       color: #753ff6;
     }
     
     /*  Field */
     .signup__field {
       display: flex;
       flex-direction: column;
       width: 100%;
       position: relative;
       margin-bottom: 50px;
     
     }
     
     .signup__field:before {
       content: "";
       display: inline-block;
       position: absolute;
       width: 0px;
       height: 2px;
       background: #753ff6;
       bottom: 0;
       left: 50%;
       transform: translateX(-50%);
       transition: all 0.4s ease;
     }
     
     .signup__field:hover:before {
       width: 100%;
     }
     
     /*  Input */
     .signup__input {
       width: 100%;
       height: 100%;
       font-size: 1.2rem;
       padding: 10px 2px 0;
       border-bottom: 2px solid #e0e0e0;
     }
     
     /*  Label */
     .signup__label {
       color: #bdbdbd;
       position: absolute;
       top: 50%;
       transform: translateY(-50%);
       left: 2px;
       font-size: 1.2rem;
       transition: all 0.3s ease;
     }
     
     .signup__input:focus + .signup__label,
     .signup__input:valid + .signup__label {
       top: 0;
       font-size: 1rem;
       background-color: white;
     }
     
     /*  Button */
     button {
       background: #753ff6;
       color: white;
       padding: 12px 0;
       font-size: 1.2rem;
       border-radius: 25px;
       cursor: pointer;
     }
     
     button:hover {
       background: #753ff6;
     }
     
     @media (max-width: 700px){
       .signup{
         padding: .4rem;
       }
     }
     </style>

Awesome! Save and navigate to your browser. Your signup and login pages should look like this:

Signup

Login

Great! Let’s add a feature that shows the authentication status based on our vuex store state. Add these lines of code to src/layouts/Default.vue:

     // src/layouts/default.vue
     
     <template>
       <header class="header">
          <Navbar />
       </header>
       ...
       <p style="color: #fff"> Auth Status: <span>{{authStatus}}</span> </p>
       ...
       <slot />
     </template>
     
     <script>
     import Navbar from '~/components/Navbar';
     export default {
         components: {
             Navbar,
         },
     
       computed: {
           authStatus(){
               return this.$store.getters.authStatus
           }
       }
     };
     </script>

Here, we get the authentication status state by accessing the Vuex store getters in our component via this.$store. At this point, you can beat your chest that you've handled authentication. We haven't yet tested whether this is working, but we will soon do that. When a user is authenticated, you'll want access to the courses page. Let's now work on that page and other features.

The courses page

The user can access the courses; you must fetch the course data from our Strapi API. Let's handle this with the Gridsome server API.

Gridsome server API allows you to manipulate the server-side features of the framework. In your project, you should have a gridsome.server.js. Here you will export a function that receives an API that will let you perform actions such as:

  • Create custom schema
  • Create custom pages programmatically from external APIs
  • Programmatically perform CRUD actions
  • Modify Webpack settings

In our case, we need to create custom pages programmatically from an external API. To enable this, we will make use of the DataStore API. The DataStore API fetches the data from our Strapi API endpoint and makes it available in our components/pages through GraphQL. Let's get to it already. Replace the existing code in gridsome.server.js with this:

     // gridsome.server.js
     
     const axios = require("axios");
     
     module.exports = function(api) {
       api.loadSource(async (actions) => {
         let data;
         try {
           data = (await axios.get(`${process.env.STRAPI_API_URL}/strapi-courses?populate=*`)).data;
         } catch (error) {
           console.log("ERROR", error);
         }
     
         const collection = actions.addCollection({
           typeName: "Course",
           path: "/course/:id",
         });
     
         for (const course of data.data) {
           collection.addNode({
             id: course.id,
             path: "/course/" + course.id,
             long_description: course.attributes.long_description,
             title: course.attributes.course_title,
             description: course.attributes.short_description,
             price: course.attributes.price,
             course_image: course.attributes.course_image,
             course_video: course.attributes.course_video,
           });
         }
       });
     };

In this code snippet, the collection is equivalent to creating an empty array. typeName is a required parameter, and we assign the template name to it while the path will define a dynamic route using the node.id. A node contains the specific data we want to get from our courses API, such as title, course_image, etc. Save and Reload your dev server. On your terminal, close the connection by hitting CTRL + C and then run gridsome develop again. P.S: If your Gridsome server runs successfully, then you're hooked to the Strapi API data and good to go. But if you encounter issues, take a breather and go through the steps again and if that doesn't work out, please leave a comment on the comment section of this article. Let's now create a page with all courses. Create a home.vue file in the pages directory and add the following lines of code.

     // src/pages/home.vue
     
     <template>
         <Layout>
     
             <section>
                 <AllCourses />
             </section>
         </Layout>
     </template>
     
     <script>
     import AllCourses from '~/components/AllCourses';
     export default {
         components: {
             AllCourses,
         },
         meta: { auth: true }
     }
     </script>

Here, we’re going to create a new component AllCourses.vue which would be used to fetch and display all our courses:

     // src/components/AllCourses.vue
     
     <template>
       <Layout>
         <div>
             <hr />
     
             <div
                 class="course_list"
                 v-for="course in $page.allCourse.edges"
                 :key="course.node.id"
             >
                 <div>
                     <g-image
                         :src="course.node.course_image.url"
                         alt=""
                         class="course-image"
                     />
                 </div>
     
                 <div class="course-content">
                     <h3>{{ course.node.title }}</h3>
                     <p>{{ course.node.description }}</p>
                     <div v-if="disableCourse">
                         <button
                             @click="$router.push(`/course/${course.node.id}`)"
                             class="btn"
                         >
                             Start Course
                         </button>
                     </div>
     
                     <div v-else>
                         <button @click="logout" class="btn ">
                             Login to start course
                         </button>
                     </div>
                 </div>
             </div>
         </div>
       </Layout>
     </template>
     
     <static-query>
         query {
           allCourse {
                 edges {
                     node {
                         id
                         title
                         description
                         price
                         course_image{
                             data {
                                 attributes {
                                     url
                                 }
                             }
                         }
                     }
                 }
             }
         }
     </static-query>
     
     <script>
     export default {
         data() {
             return {
                 courses: [],
             };
         },
         computed: {
             disableCourse() {
                 if (this.$store.getters.authStatus === 'success') {
                     return true;
                 } else if (this.$store.getters.authStatus === 'error') {
                     return false;
                 }
             },
         },
         mounted() {
             this.courses = this.$page.allCourse.edges;
         },
         methods: {
             logout() {
                 this.$store
                     .dispatch('logout')
                     .then(() => {
                         this.$router.push('/login');
                     })
                     .catch(err => {
                         console.log(err);
                     });
             },
         },
     };
     </script>
     
     <style scoped>
     .course_list {
         display: flex;
         margin-top: 2rem;
         margin-bottom: 2rem;
         /* justify-content: space-between; */
         color: #fff;
         border-bottom: 2px solid #fff;
     }
     
     .btn {
         border: none;
         box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
         cursor: pointer;
         height: 4.375em;
         border-radius: 1.5em;
         padding: 0 1.5em;
         background-color: rgb(87, 41, 178);
         color: #fff;
         font-size: 14px;
         font-weight: 600;
         font-family: 'Karla';
         outline: none;
     }
     
     .course-content {
         margin-right: 5rem;
         margin-left: 5rem;
     }
     
     .price {
         font-weight: 700;
     }
     
     @media (max-width: 700px) {
         .course_list {
             display: flex;
             flex-wrap: wrap;
         }
     
         .course-content {
             margin: 0;
         }
     
         img {
             width: 100%;
         }
     }
     </style>

Let’s go through the working parts of this code snippet. Let’s start by examining the query:

     // src/components/AllCourses.vue
     ...
     <page-query>
         query {
           allCourse {
                 edges {
                     node {
                         id
                         title
                         description
                         price
                         course_image{
                             url
                         }
                     }
                 }
             }
         }
     </page-query>

Remember, our data is now in the Gridsome's GraphQL data layer. You are directly querying data into this page by using <page-query>. GraphQL queries always start with query, allCourses is the name of the collection, edges represent the array of courses, and the node is how we get the specific course data we want on the page.

     mounted() {
       this.courses = this.$page.allCourse.edges;
     },

Here, we assign the array that has all the courses to an empty courses array. More important to note that the results of our GraphQL query are stored in $page for pages. If we were querying data into a component, we would use <static-query>, and we will store the results in $static.

     <div v-if="disableCourse">
         <button
             @click="$router.push(`/course/${course.node.id}`)"
             class="btn"
         >
             Start Course
         </button>
     </div>
     
     <div v-else>
         <button @click="logout" class="btn ">
             Login to start course
         </button>
     </div>

Finally, this code snippet performs the following logical conditions:

  • If the user is authenticated, redirect them to view the content of that particular course.
  • if the user is not authenticated, redirect them to the Login

Let's now work on a single course using Gridsome Templates. Gridsome templates are used to create single pages for nodes in collections. In our project, we have sourced our nodes from the Strapi API. Go to the templates directory and create a Course.vue file. Add the following code:

     // src/templates/Course.vue
     
     <template>
         <Layout>
             <h1>{{ $page.course.title }}</h1>
     
             <div class="course_desc">
                 <div class="long">
                     <p>{{ $page.course.description }}</p>
                 </div>
                 <img :src="$page.course.course_image.url" class="image" alt="" />
             </div>
     
             <div class="video" v-if="$page.course.course_video">
                 <video controls class="video-width">
                     <source
                         :src="$page.course.course_video.url"
                         :type="$page.course.course_video.mime"
                     />
     
                     Sorry, your browser doesn't support embedded videos.
                 </video>
             </div>
         </Layout>
     </template>
     
     <page-query> 
     query ($id: ID!){
         course(id: $id) {
             id 
             title 
             long_description
             description
             price
             course_image{
                             data {
                                 attributes {
                                     url
                                 }
                             }
             }
             course_video{
                 data {
                     attributes {
                         url
                         mime
                     }
                 }
             }
         }
     }
     
     </page-query>
     
     <style scoped>
     .course_desc {
         display: flex;
         align-items: center;
         flex-wrap: wrap;
         color: #fff;
         justify-content: space-between;
     }
     .long {
         max-width: 50%;
         flex-basis: 50%;
     }
     
     h1 {
         color: #fff;
     }
     .image {
         height: 200px;
     }
     .video {
         margin-top: 5rem;
         margin-bottom: 5rem;
     }
     .video-width {
         width: 100%;
     }
     
     @media (max-width: 700px) {
         .long {
             max-width: 100%;
             flex-basis: 100%;
         }
         .image {
             width: 100%;
             height: 100%;
         }
     }
     </style>

Awesome! We have to deal with two more cases:

  • when the user has logged in/logged out
  • the information on the homepage should change when a user logs out or is logged in

When the user has logged in/out

Replace the code in src/components/Navbar.vue with these lines of code:

     // src/components/Navbar.vue
     
     <template>
         <div class="navbar">
             <nav>
                 <div>
                     <h1>
                         <g-link to="/"> Strapi Learning Platform </g-link>
                     </h1>
                 </div>
                 <ul>
                     <div v-if="disableNav" class="ul">
                         <li>Welcome, {{ user.username }}</li>
                         <li>
                             <button class="btn btn-outline" @click="logout">
                                 Logout
                             </button>
                         </li>
                     </div>
                     <div v-else class="ul">
                         <li>
                             <g-link to="/signup" class="btn"> Register </g-link>
                         </li>
                         <li>
                             <g-link to="/login" class="btn btn-outline">
                                 Login
                             </g-link>
                         </li>
                     </div>
                 </ul>
             </nav>
         </div>
     </template>
     
     <script>
     export default {
         data() {
             return {
                 user: {},
             };
         },
         computed: {
             isLoggedIn() {
                 return this.$store.getters.isLoggedIn;
             },
     
             disableNav() {
                 if (this.$store.getters.authStatus === 'success') {
                     return true;
                 } else if (this.$store.getters.authStatus === 'error') {
                     return false;
                 }
             },
         },
     
         mounted() {
             this.user = JSON.parse(localStorage.getItem('user'));
         },
         methods: {
             logout() {
                 this.$store
                     .dispatch('logout')
                     .then(() => {
                         console.log('I am serious');
                         this.$router.push('/login');
                     })
                     .catch(err => {
                         console.log(err);
                     });
             },
         },
     };
     </script>
     
     <style scoped>
     .navbar {
         width: 100%;
     }
     h1 a {
         color: #fff;
         text-decoration: none;
     }
     nav {
         display: flex;
         justify-content: space-between;
         align-items: center;
     }
     
     ul {
         list-style-type: none;
         margin: 0;
         padding: 0;
     }
     
     .ul {
         display: flex;
         align-items: baseline;
     }
     
     .btn {
         border: none;
         box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
         cursor: pointer;
         height: 4.375em;
         border-radius: 1.5em;
         text-decoration: none;
         padding: 1.5em 1.5em;
         background-color: rgb(87, 41, 178);
         color: #fff;
         font-size: 14px;
         font-weight: 600;
         font-family: 'Karla';
         outline: none;
     }
     
     .btn-outline {
         background: #fff;
         border: 1px solid rgb(87, 41, 178);
         color: #000;
     }
     
     li {
         margin-left: 0.5rem;
         margin-right: 0.5rem;
         color: #fff;
     }
     
     .nav-link {
         color: #fff;
         text-decoration: none;
     }
     
     @media (max-width: 700px) {
         ul {
             justify-content: space-between;
         }
     }
     </style>

Updating information on homepage when a user logs in/out

Finally, the index page should also be aware of the user’s authentication status. Replace the code in src/pages/index.vue with these lines of code:

     // src/pages/index.vue
     
     <template>
         <Layout>
             <section class="hero-section">
                 <div v-if="!authStatus">
                     <h1>Welcome!</h1>
     
                     <p>
                         Strapi is the leading open-source headless CMS. It’s 100%
                         Javascript, fully customizable and developer-first.
                     </p>
     
                     <p>
                         <g-link to="/login"> Login </g-link> to enjoy all our
                         courses here
                     </p>
                 </div>
     
                 <div v-else>
                     <h2>You're logged in</h2>
     
                     <p>
                         Continue viewing your <g-link to="/home"> courses </g-link>
                     </p>
                 </div>
             </section>
         </Layout>
     </template>
     
     <script>
     export default {
         metaInfo: {
             title: 'Learning Platform',
         },
         computed: {
             authStatus() {
                 if (this.$store.getters.authStatus === 'success') {
                     return true;
                 } else if (this.$store.getters.authStatus === 'error') {
                     return false;
                 } else if (this.$store.getters.authStatus === '') {
                     return false;
                 }
             },
         },
     };
     </script>
     
     <style>
     .home-links a {
         margin-right: 1rem;
     }
     
     .hero-section {
         background: #fff;
         color: #2f2e8b;
         padding: 1rem;
     }
     </style>

Reload your server and navigate to your browser.

Homepage

Login

Signup

Courses Page for Authenticated users

Single Course page

Unauthenticated user course page

img

Congratulations!!! 🙌

Conclusion

In this tutorial, we didn't just build a learning platform but also, in the process, picked up insights in using Strapi as a headless CMS and Gridsome for building statically generated websites/apps.

If this were your first time using Strapi or Gridsome, you'd be capable of creating or handling tasks relating to any of the tools.

Resources

Are you stuck somewhere? Here is the source code for the frontend and backend.

Join our Newsletter

Get all the latest news and events.

Newsletter signup is not configured yet.

App·26 min read

Build a To-Do App with Strapi GraphQL Plugin and Flutter

In this tutorial, we will set up a GraphQL endpoint in a Strapi backend along with Flutter, a powerful opensource UI...

·August 30, 2023
App·16 min read

How to build a To-do App using Next.js and Strapi

How to build a Todo App using Next.js and Strapi Updated July 2023 In this article, we will learn how to use , and to...

·August 29, 2023
How to setup Amazon S3 Upload Provider Plugin for your Strapi App
AppBeginner·14 min read

How to Set up Amazon S3 Upload Provider Plugin for Your Strapi App

Learn how to set up the Amazon S3 Upload Provider Plugin for your Strapi App to work with an Amazon S3 storage bucket.

·July 3, 2023