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.
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.
To follow this article, you’d need the following:
We will build a simple learning platform using Strapi and Gridsome. Strapi will handle the backend. It should be able to do the following:
Then, we'll use Gridsome on the front, and we'll be able to perform the following:
Here's a demo of what we'll be building in action:
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.
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
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:
Now that you've successfully launched the admin dashboard let's create the 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.
Click on the button that says CREATE YOUR FIRST CONTENT-TYPE. This will take you to the 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:
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:
These are the fields we will be adding to our courses type:
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:
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:
1 course_title: Strapi CMS with Gatsby,
2 short_description: Learn how to use Strapi CMS with Gatsby to build powerful web applications,
3 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,
4 course_image: https://res.cloudinary.com/samtech/image/upload/v1608285507/with_gatsby_e4dca7b0c3_9e4d8db26e.png,
5 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
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:
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.
You will get the JSON response below when you send a request to http://localhost:1337/api/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=*
Voila!
Congratulations on getting this far! Let's now deal with uploads before we add more courses.
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.
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:
1 // config/plugins.js
2
3 module.exports = ({ env }) => ({
4 // ...
5 upload: {
6 config: {
7 provider: "cloudinary",
8 providerOptions: {
9 cloud_name: env("CLOUDINARY_NAME"),
10 api_key: env("CLOUDINARY_KEY"),
11 api_secret: env("CLOUDINARY_SECRET"),
12 },
13 actionOptions: {
14 upload: {},
15 delete: {},
16 },
17 },
18 },
19 // ...
20 });
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
.
1 // config/middlewares.js
2
3 module.exports = [
4 'strapi::errors',
5 {
6 name: 'strapi::security',
7 config: {
8 contentSecurityPolicy: {
9 useDefaults: true,
10 directives: {
11 'connect-src': ["'self'", 'https:'],
12 'img-src': ["'self'", 'data:', 'blob:', 'res.cloudinary.com'],
13 'media-src': ["'self'", 'data:', 'blob:', 'res.cloudinary.com'],
14 upgradeInsecureRequests: null,
15 },
16 },
17 },
18 },
19 'strapi::cors',
20 'strapi::poweredBy',
21 'strapi::logger',
22 'strapi::query',
23 'strapi::body',
24 'strapi::favicon',
25 'strapi::public',
26 ];
When you save this, restart your server. Now, by adding another course, let's confirm that it's working fine.
1 course_title: Strapi CMS with Vue,
2 short_description: Learn how to build web apps with Strapi CMS and Vuejs,
3 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.,
4 download this image or just upload via url: https://res.cloudinary.com/samtech/image/upload/v1608287448/with_vue_9386f31664_ccce641ecf.png,
5 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:
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.
Awesome, we just succeeded in Setting up our API! That’s really cool.
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.
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.
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:
1 gridsome create strapi-learning
This might take some time but when it's done, go to the project directory by running this command:
1 cd strapi-learning
Open the Gridsome starter project on your preferred code editor. Then, run
1 gridsome develop
to start your local development server.
Navigate to http://localhost:8080
to view the Gridsome app.
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:
1 // src/main.js
2
3 import DefaultLayout from '~/layouts/Default.vue'
4
5 export default function (Vue, {head}) {
6 head.link.push({
7 rel: 'stylesheet',
8 href: 'https://fonts.googleapis.com/css2?family=Karla&display=swap',
9 })
10 // Set default layout as a global component
11 Vue.component('Layout', DefaultLayout)
12 }
Now, head over to src/layouts/Default.vue
and replace the existing project’s Default.vue
code:
1 // src/layouts/Default.vue
2
3 <template>
4 <div class="layout">
5 <header class="header">
6 <Navbar />
7 </header>
8 <slot />
9 </div>
10 </template>
11
12 <static-query>
13 query {
14 metadata {
15 siteName
16 }
17 }
18 </static-query>
19
20 <script>
21 import Navbar from '~/components/Navbar';
22 export default {
23 components: {
24 Navbar,
25 },
26 };
27 </script>
28 <style>
29 body {
30 font-family: 'Karla', sans-serif;
31 margin: 0;
32 padding: 0;
33 background: #753ff6;
34 line-height: 1.5;
35 }
36
37 span{
38 text-transform: uppercase;
39 }
40
41 .layout {
42 max-width: 950px;
43 margin: 0 auto;
44 padding-left: 20px;
45 padding-right: 20px;
46 }
47
48 .header {
49 display: flex;
50 justify-content: space-between;
51 align-items: center;
52 margin-bottom: 20px;
53
54 }
55
56 .nav__link {
57 margin-left: 20px;
58 }
59
60 @media (max-width: 700px){
61 nav{
62 flex-direction: column;
63 margin-bottom: 1rem;
64 }
65 }
66 </style>
Let's now create the Navbar component. Go to src/Components
and create a Navbar.vue
file with the following content:
1 // src/Components/Navbar.vue
2
3 <template>
4 <div class="navbar">
5 <nav>
6 <div>
7 <h1>
8 <g-link to="/"> Strapi Learning Platform </g-link>
9 </h1>
10 </div>
11 <ul>
12 <div v-if="disableNav" class="ul">
13 <li>Welcome, {{ user.username }}</li>
14 <li>
15 <button class="btn btn-outline" @click="logout">
16 Logout
17 </button>
18 </li>
19 </div>
20 <div v-else class="ul">
21 <li>
22 <g-link to="/signup" class="btn"> Register </g-link>
23 </li>
24 <li>
25 <g-link to="/login" class="btn btn-outline">
26 Login
27 </g-link>
28 </li>
29 </div>
30 </ul>
31 </nav>
32 </div>
33 </template>
34
35 <script>
36 export default {
37 data() {
38 return {
39 user: {},
40 };
41 },
42 computed: {
43 isLoggedIn() {
44 return this.$store.getters.isLoggedIn;
45 },
46
47 disableNav() {
48 if (this.$store.getters.authStatus === 'success') {
49 return true;
50 } else if (this.$store.getters.authStatus === 'error') {
51 return false;
52 }
53 },
54 },
55
56 mounted() {
57 this.user = JSON.parse(localStorage.getItem('user'));
58 },
59 methods: {
60 logout() {
61 this.$store
62 .dispatch('logout')
63 .then(() => {
64 console.log('I am serious');
65 this.$router.push('/login');
66 })
67 .catch(err => {
68 console.log(err);
69 });
70 },
71 },
72 };
73 </script>
74
75 <style scoped>
76 .navbar {
77 width: 100%;
78 }
79 h1 a {
80 color: #fff;
81 text-decoration: none;
82 }
83 nav {
84 display: flex;
85 justify-content: space-between;
86 align-items: center;
87 }
88
89 ul {
90 list-style-type: none;
91 margin: 0;
92 padding: 0;
93 }
94
95 .ul {
96 display: flex;
97 align-items: baseline;
98 }
99
100 .btn {
101 border: none;
102 box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
103 cursor: pointer;
104 height: 4.375em;
105 border-radius: 1.5em;
106 text-decoration: none;
107 padding: 1.5em 1.5em;
108 background-color: rgb(87, 41, 178);
109 color: #fff;
110 font-size: 14px;
111 font-weight: 600;
112 font-family: 'Karla';
113 outline: none;
114 }
115
116 .btn-outline {
117 background: #fff;
118 border: 1px solid rgb(87, 41, 178);
119 color: #000;
120 }
121
122 li {
123 margin-left: 0.5rem;
124 margin-right: 0.5rem;
125 color: #fff;
126 }
127
128 .nav-link {
129 color: #fff;
130 text-decoration: none;
131 }
132
133 @media (max-width: 700px) {
134 ul {
135 justify-content: space-between;
136 }
137 }
138 </style>
Your page should look like this:
Let’s add some content to the homepage. Replace the code in pages/Index.vue
with this code snippet:
1 // src/pages/Index.vue
2
3 <template>
4 <Layout>
5 <section class="hero-section">
6 <h1>Welcome!</h1>
7
8 <p>
9 Strapi is the leading open-source headless CMS. It’s 100%
10 Javascript, fully customizable and developer-first.
11 </p>
12
13 <p>
14 <g-link to="/login"> Login </g-link> to enjoy all our courses
15 here
16 </p>
17 </section>
18 </Layout>
19 </template>
20
21 <style>
22 .home-links a {
23 margin-right: 1rem;
24 }
25
26 .hero-section {
27 background: #fff;
28 color: #2f2e8b;
29 padding: 1rem;
30 }
31 </style>
Awesome! So far, we have implemented the Navbar and Homepage. The page should now look like this:
We have succeeded in setting up the page layout. Next, we will handle the register and login functionality.
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
We will need Axios for our HTTP requests. Open the src/main.js
file and replace the existing code with this:
1 // main.js
2
3 import DefaultLayout from '~/layouts/Default.vue'
4 import Vuex from 'vuex'
5 import axios from 'axios'
6
7 export default function (Vue, { head }) {
8 Vue.use(Vuex)
9
10 Vue.prototype.$http = axios;
11
12 const token = process.isClient ? localStorage.getItem(`Bearer ${token}`) : false
13
14 if (token) {
15 Vue.prototype.$http.defaults.headers.common['Authorization'] = `Bearer ${token}`
16 console.log('token is active',token)
17 }
18
19 head.link.push({
20 rel: 'stylesheet',
21 href: 'https://fonts.googleapis.com/css2?family=Karla&display=swap',
22 })
23
24 Vue.component('Layout', DefaultLayout)
25 }
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.
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:
1 export default function (Vue, { head })
with this:
1 export default function (Vue, { head, appOptions })
Still in src/main.js
, add these lines of code before head.link.push({…})
1 // src/main.js
2
3 ...
4 appOptions.store = new Vuex.Store({
5 state: {},
6 mutations:{},
7 actions:{},
8 getters: {}
9 })
10 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
1 // src/main.js
2
3 ...
4 state: {
5 status: '',
6 token: process.isClient ? localStorage.getItem('token') || '' : false,
7 user: {}
8 },
9 ...
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:
1 // src/main.js
2
3 ...
4 mutations: {
5 AUTH_REQUEST(state){
6 state.status = 'loading'
7 },
8 AUTH_SUCCESS(state, token, user) {
9 state.status = 'success',
10 state.token = token,
11 state.user = user
12 },
13 AUTH_ERROR(state){
14 state.status = 'error'
15 },
16 LOGOUT(state){
17 state.status = 'logged out',
18 state.token = ''
19 }
20 },
21 ...
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.
1 //.env
2
3 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:
1 // src/main.js
2
3 ...
4 actions: {
5 async login({ commit }, user) {
6 commit('AUTH_REQUEST')
7 await axios.post(`${process.env.STRAPI_URL}/auth/local/`, user)
8 .then(response => {
9 const token = response.data.jwt
10 const user = response.data.user
11
12 if (process.isClient) {
13 localStorage.setItem('token', token)
14 localStorage.setItem('user', JSON.stringify(user))
15 }
16 axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
17 const something = axios.defaults.headers.common['Authorization']
18 console.log({something})
19 commit('AUTH_SUCCESS', token, user)
20 console.log({user, token})
21 })
22
23 .catch(err => {
24 commit('AUTH_ERROR')
25 process.isClient ? localStorage.removeItem('token') : false
26 console.error(err)
27 })
28 },
29
30 },
31 ...
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:
1 // src/main.js
2
3 ...
4 async register({commit}, user) {
5 commit('AUTH_REQUEST')
6 await axios.post(`${process.env.STRAPI_URL}/auth/local/register`, user)
7 .then(response => {
8 const token = response.data.jwt
9 const user = response.data.user
10
11 process.isClient ? localStorage.setItem('token', token) : false
12
13 axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
14
15 commit('AUTH_SUCCESS', token, user)
16 })
17 .catch(err => {
18 commit('AUTH_ERROR')
19 process.isClient ? localStorage.removeItem('token') : false
20 console.error(err)
21 })
22
23 },
24 ...
This is similar with the login action. Finally, let’s handle the action for Logout
1 // src/main.js
2
3 ...
4 logout({commit}){
5 commit('LOGOUT')
6 process.isClient ? localStorage.removeItem('token') : false
7 delete axios.defaults.headers.common['Authorization']
8 }
9 ...
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.
1 // src/main.js
2
3 ...
4 getters: {
5 isLoggedIn: state => !!state.token,
6 authStatus: state => state.status
7 }
8 ...
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:
1 // src/main.js
2
3 import DefaultLayout from '~/layouts/Default.vue'
4 import Vuex from 'vuex'
5 import axios from 'axios'
6
7 export default function (Vue, { head, appOptions }) {
8 Vue.use(Vuex)
9 Vue.prototype.$http = axios
10
11 const token = process.isClient ? localStorage.getItem(`Bearer ${token}`) : false
12
13 if (token) {
14 Vue.prototype.$http.defaults.headers.common['Authorization'] = `Bearer ${token}`
15 console.log('token is active',token)
16 }
17
18 appOptions.store = new Vuex.Store({
19 state: {
20 status: '',
21 token: process.isClient ? localStorage.getItem('token') || '' : false,
22 user: {}
23 },
24 mutations: {
25 AUTH_REQUEST(state){
26 state.status = 'loading'
27 },
28 AUTH_SUCCESS(state, token, user) {
29 state.status = 'success',
30 state.token = token,
31 state.user = user
32 },
33 AUTH_ERROR(state){
34 state.status = 'error'
35 },
36 LOGOUT(state){
37 state.status = 'logged out',
38 state.token = ''
39 }
40 },
41 actions: {
42
43 async login({ commit }, user) {
44 commit('AUTH_REQUEST')
45 await axios.post(`http://localhost:1337/auth/local/`, user)
46 .then(response => {
47 const token = response.data.jwt
48 const user = response.data.user
49
50 if (process.isClient) {
51 localStorage.setItem('token', token)
52 localStorage.setItem('user', JSON.stringify(user))
53 }
54 axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
55 const something = axios.defaults.headers.common['Authorization']
56 console.log({something})
57 commit('AUTH_SUCCESS', token, user)
58 console.log({user, token})
59 })
60
61 .catch(err => {
62 commit('AUTH_ERROR')
63 process.isClient ? localStorage.removeItem('token') : false
64 console.error(err)
65 })
66 },
67
68 async register({commit}, user) {
69 commit('AUTH_REQUEST')
70 await axios.post(`http://localhost:1337/auth/local/register`, user)
71 .then(response => {
72 const token = response.data.jwt
73 const user = response.data.user
74
75 process.isClient ? localStorage.setItem('token', token) : false
76
77 axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
78
79 commit('AUTH_SUCCESS', token, user)
80 })
81 .catch(err => {
82 commit('AUTH_ERROR')
83 process.isClient ? localStorage.removeItem('token') : false
84 console.error(err)
85 })
86
87 },
88 logout({commit}){
89 commit('LOGOUT')
90 process.isClient ? localStorage.removeItem('token') : false
91 delete axios.defaults.headers.common['Authorization']
92 }
93 },
94
95 getters: {
96 isLoggedIn: state => !!state.token,
97 authStatus: state => state.status
98 }
99 }),
100
101 head.link.push({
102 rel: 'stylesheet',
103 href: 'https://fonts.googleapis.com/css2?family=Karla&display=swap',
104 })
105
106 Vue.component('Layout', DefaultLayout)
107 }
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:
1 // src/pages/Login.vue
2
3 <template>
4 <Layout>
5 <form class="signup" onsubmit="return false" autocomplete="off">
6 <h1>Welcome Back</h1>
7 <h2>Don't have an account? <g-link to="/signup">Sign up</g-link></h2>
8 <div class="signup__field">
9 <input class="signup__input" type="text" v-model="user.identifier" name="email" required />
10 <label class="signup__label" for="email">Email</label>
11 </div>
12
13 <div class="signup__field">
14 <input class="signup__input" type="password" v-model="user.password" name="password" required />
15 <label class="signup__label" for="password">Password</label>
16 </div>
17
18 <button @click="login">Sign in</button>
19 </form>
20 </Layout>
21 </template>
22
23 <script>
24 export default {
25 data(){
26 return{
27 user: {
28 identifier: '',
29 password: ''
30 }
31 }
32 },
33 methods:{
34
35 login(){
36 let identifier = this.user.identifier
37 let password = this.user.password
38 console.log({identifier, password})
39 this.$store.dispatch('login', {identifier, password})
40
41 .then(() => this.$router.push('/home'))
42 .catch((err) => {
43 this.$router.push('/')
44 console.error({err})
45 })
46 }
47 }
48 }
49 </script>
50
51 <style>
52 </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:
1 // src/pages/Signup.vue
2
3 <template>
4 <Layout>
5 <form class="signup" onsubmit="return false" autocomplete="off">
6 <h1>Create account</h1>
7 <h2>Already have an account? <span><g-link to="/login">Sign in</g-link></span></h2>
8
9 <div class="signup__field">
10 <input class="signup__input" type="text" v-model="user.name" name="username" required />
11 <label class="signup__label" for="username">Username</label>
12 </div>
13
14 <div class="signup__field">
15 <input class="signup__input" type="email" v-model="user.email" name="email" required />
16 <label class="signup__label" for="email">Email</label>
17 </div>
18
19 <div class="signup__field">
20 <input class="signup__input" type="password" v-model="user.password" name="password" required />
21 <label class="signup__label" for="password">Password</label>
22 </div>
23
24 <button @click="register">Sign up</button>
25 </form>
26 </Layout>
27 </template>
28
29 <script>
30 import axios from 'axios';
31 export default {
32 data(){
33 return{
34 user: {
35 email: '',
36 name: '',
37 password: ''
38 }
39 }
40 },
41 methods:{
42
43 register(){
44 let reqObj = {
45 username: this.user.name,
46 email: this.user.email,
47 password: this.user.password,
48 }
49
50 this.$store.dispatch('register', reqObj)
51 .then(() => this.$router.push('/login'))
52 .catch(err => console.log(err))
53 }
54 }
55 }
56 </script>
57
58 <style>
59 </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:
1 // src/pages/Login.vue
2 // src/pages/signup.vue
3
4 <style scoped>
5
6 body {
7 background-color: #753ff6;
8 width: 100%;
9 min-height: 100vh;
10
11 display: flex;
12 align-items: center;
13 justify-content: center;
14 }
15
16 button,
17 input {
18 border: none;
19 outline: none;
20 }
21
22 /****************
23 FORM
24 *****************/
25 .signup {
26 background-color: white;
27 width: 100%;
28 max-width: 500px;
29 padding: 50px 70px;
30 display: flex;
31 flex-direction: column;
32 margin: auto;
33
34 border-radius: 20px;
35 box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
36 0 4px 6px -2px rgba(0, 0, 0, 0.05);
37 }
38
39 h1 {
40 text-align: center;
41 color: #753ff6;
42 }
43 h2 {
44 text-align: center;
45 font-size: 1.2rem;
46 font-weight: lighter;
47
48 margin-bottom: 40px;
49 }
50
51 h2 span {
52 text-decoration: underline;
53 cursor: pointer;
54 color: #753ff6;
55 }
56
57 /* Field */
58 .signup__field {
59 display: flex;
60 flex-direction: column;
61 width: 100%;
62 position: relative;
63 margin-bottom: 50px;
64
65 }
66
67 .signup__field:before {
68 content: "";
69 display: inline-block;
70 position: absolute;
71 width: 0px;
72 height: 2px;
73 background: #753ff6;
74 bottom: 0;
75 left: 50%;
76 transform: translateX(-50%);
77 transition: all 0.4s ease;
78 }
79
80 .signup__field:hover:before {
81 width: 100%;
82 }
83
84 /* Input */
85 .signup__input {
86 width: 100%;
87 height: 100%;
88 font-size: 1.2rem;
89 padding: 10px 2px 0;
90 border-bottom: 2px solid #e0e0e0;
91 }
92
93 /* Label */
94 .signup__label {
95 color: #bdbdbd;
96 position: absolute;
97 top: 50%;
98 transform: translateY(-50%);
99 left: 2px;
100 font-size: 1.2rem;
101 transition: all 0.3s ease;
102 }
103
104 .signup__input:focus + .signup__label,
105 .signup__input:valid + .signup__label {
106 top: 0;
107 font-size: 1rem;
108 background-color: white;
109 }
110
111 /* Button */
112 button {
113 background: #753ff6;
114 color: white;
115 padding: 12px 0;
116 font-size: 1.2rem;
117 border-radius: 25px;
118 cursor: pointer;
119 }
120
121 button:hover {
122 background: #753ff6;
123 }
124
125 @media (max-width: 700px){
126 .signup{
127 padding: .4rem;
128 }
129 }
130 </style>
Awesome! Save and navigate to your browser. Your signup and login pages should look like this:
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
:
1 // src/layouts/default.vue
2
3 <template>
4 <header class="header">
5 <Navbar />
6 </header>
7 ...
8 <p style="color: #fff"> Auth Status: <span>{{authStatus}}</span> </p>
9 ...
10 <slot />
11 </template>
12
13 <script>
14 import Navbar from '~/components/Navbar';
15 export default {
16 components: {
17 Navbar,
18 },
19
20 computed: {
21 authStatus(){
22 return this.$store.getters.authStatus
23 }
24 }
25 };
26 </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 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:
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:
1 // gridsome.server.js
2
3 const axios = require("axios");
4
5 module.exports = function(api) {
6 api.loadSource(async (actions) => {
7 let data;
8 try {
9 data = (await axios.get(`${process.env.STRAPI_API_URL}/strapi-courses?populate=*`)).data;
10 } catch (error) {
11 console.log("ERROR", error);
12 }
13
14 const collection = actions.addCollection({
15 typeName: "Course",
16 path: "/course/:id",
17 });
18
19 for (const course of data.data) {
20 collection.addNode({
21 id: course.id,
22 path: "/course/" + course.id,
23 long_description: course.attributes.long_description,
24 title: course.attributes.course_title,
25 description: course.attributes.short_description,
26 price: course.attributes.price,
27 course_image: course.attributes.course_image,
28 course_video: course.attributes.course_video,
29 });
30 }
31 });
32 };
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.
1 // src/pages/home.vue
2
3 <template>
4 <Layout>
5
6 <section>
7 <AllCourses />
8 </section>
9 </Layout>
10 </template>
11
12 <script>
13 import AllCourses from '~/components/AllCourses';
14 export default {
15 components: {
16 AllCourses,
17 },
18 meta: { auth: true }
19 }
20 </script>
Here, we’re going to create a new component AllCourses.vue
which would be used to fetch and display all our courses:
1 // src/components/AllCourses.vue
2
3 <template>
4 <Layout>
5 <div>
6 <hr />
7
8 <div
9 class="course_list"
10 v-for="course in $page.allCourse.edges"
11 :key="course.node.id"
12 >
13 <div>
14 <g-image
15 :src="course.node.course_image.url"
16 alt=""
17 class="course-image"
18 />
19 </div>
20
21 <div class="course-content">
22 <h3>{{ course.node.title }}</h3>
23 <p>{{ course.node.description }}</p>
24 <div v-if="disableCourse">
25 <button
26 @click="$router.push(`/course/${course.node.id}`)"
27 class="btn"
28 >
29 Start Course
30 </button>
31 </div>
32
33 <div v-else>
34 <button @click="logout" class="btn ">
35 Login to start course
36 </button>
37 </div>
38 </div>
39 </div>
40 </div>
41 </Layout>
42 </template>
43
44 <static-query>
45 query {
46 allCourse {
47 edges {
48 node {
49 id
50 title
51 description
52 price
53 course_image{
54 data {
55 attributes {
56 url
57 }
58 }
59 }
60 }
61 }
62 }
63 }
64 </static-query>
65
66 <script>
67 export default {
68 data() {
69 return {
70 courses: [],
71 };
72 },
73 computed: {
74 disableCourse() {
75 if (this.$store.getters.authStatus === 'success') {
76 return true;
77 } else if (this.$store.getters.authStatus === 'error') {
78 return false;
79 }
80 },
81 },
82 mounted() {
83 this.courses = this.$page.allCourse.edges;
84 },
85 methods: {
86 logout() {
87 this.$store
88 .dispatch('logout')
89 .then(() => {
90 this.$router.push('/login');
91 })
92 .catch(err => {
93 console.log(err);
94 });
95 },
96 },
97 };
98 </script>
99
100 <style scoped>
101 .course_list {
102 display: flex;
103 margin-top: 2rem;
104 margin-bottom: 2rem;
105 /* justify-content: space-between; */
106 color: #fff;
107 border-bottom: 2px solid #fff;
108 }
109
110 .btn {
111 border: none;
112 box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
113 cursor: pointer;
114 height: 4.375em;
115 border-radius: 1.5em;
116 padding: 0 1.5em;
117 background-color: rgb(87, 41, 178);
118 color: #fff;
119 font-size: 14px;
120 font-weight: 600;
121 font-family: 'Karla';
122 outline: none;
123 }
124
125 .course-content {
126 margin-right: 5rem;
127 margin-left: 5rem;
128 }
129
130 .price {
131 font-weight: 700;
132 }
133
134 @media (max-width: 700px) {
135 .course_list {
136 display: flex;
137 flex-wrap: wrap;
138 }
139
140 .course-content {
141 margin: 0;
142 }
143
144 img {
145 width: 100%;
146 }
147 }
148 </style>
Let’s go through the working parts of this code snippet. Let’s start by examining the query:
1 // src/components/AllCourses.vue
2 ...
3 <page-query>
4 query {
5 allCourse {
6 edges {
7 node {
8 id
9 title
10 description
11 price
12 course_image{
13 url
14 }
15 }
16 }
17 }
18 }
19 </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.
1 mounted() {
2 this.courses = this.$page.allCourse.edges;
3 },
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
.
1 <div v-if="disableCourse">
2 <button
3 @click="$router.push(`/course/${course.node.id}`)"
4 class="btn"
5 >
6 Start Course
7 </button>
8 </div>
9
10 <div v-else>
11 <button @click="logout" class="btn ">
12 Login to start course
13 </button>
14 </div>
Finally, this code snippet performs the following logical conditions:
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:
1 // src/templates/Course.vue
2
3 <template>
4 <Layout>
5 <h1>{{ $page.course.title }}</h1>
6
7 <div class="course_desc">
8 <div class="long">
9 <p>{{ $page.course.description }}</p>
10 </div>
11 <img :src="$page.course.course_image.url" class="image" alt="" />
12 </div>
13
14 <div class="video" v-if="$page.course.course_video">
15 <video controls class="video-width">
16 <source
17 :src="$page.course.course_video.url"
18 :type="$page.course.course_video.mime"
19 />
20
21 Sorry, your browser doesn't support embedded videos.
22 </video>
23 </div>
24 </Layout>
25 </template>
26
27 <page-query>
28 query ($id: ID!){
29 course(id: $id) {
30 id
31 title
32 long_description
33 description
34 price
35 course_image{
36 data {
37 attributes {
38 url
39 }
40 }
41 }
42 course_video{
43 data {
44 attributes {
45 url
46 mime
47 }
48 }
49 }
50 }
51 }
52
53 </page-query>
54
55 <style scoped>
56 .course_desc {
57 display: flex;
58 align-items: center;
59 flex-wrap: wrap;
60 color: #fff;
61 justify-content: space-between;
62 }
63 .long {
64 max-width: 50%;
65 flex-basis: 50%;
66 }
67
68 h1 {
69 color: #fff;
70 }
71 .image {
72 height: 200px;
73 }
74 .video {
75 margin-top: 5rem;
76 margin-bottom: 5rem;
77 }
78 .video-width {
79 width: 100%;
80 }
81
82 @media (max-width: 700px) {
83 .long {
84 max-width: 100%;
85 flex-basis: 100%;
86 }
87 .image {
88 width: 100%;
89 height: 100%;
90 }
91 }
92 </style>
Awesome! We have to deal with two more cases:
Replace the code in src/components/Navbar.vue
with these lines of code:
1 // src/components/Navbar.vue
2
3 <template>
4 <div class="navbar">
5 <nav>
6 <div>
7 <h1>
8 <g-link to="/"> Strapi Learning Platform </g-link>
9 </h1>
10 </div>
11 <ul>
12 <div v-if="disableNav" class="ul">
13 <li>Welcome, {{ user.username }}</li>
14 <li>
15 <button class="btn btn-outline" @click="logout">
16 Logout
17 </button>
18 </li>
19 </div>
20 <div v-else class="ul">
21 <li>
22 <g-link to="/signup" class="btn"> Register </g-link>
23 </li>
24 <li>
25 <g-link to="/login" class="btn btn-outline">
26 Login
27 </g-link>
28 </li>
29 </div>
30 </ul>
31 </nav>
32 </div>
33 </template>
34
35 <script>
36 export default {
37 data() {
38 return {
39 user: {},
40 };
41 },
42 computed: {
43 isLoggedIn() {
44 return this.$store.getters.isLoggedIn;
45 },
46
47 disableNav() {
48 if (this.$store.getters.authStatus === 'success') {
49 return true;
50 } else if (this.$store.getters.authStatus === 'error') {
51 return false;
52 }
53 },
54 },
55
56 mounted() {
57 this.user = JSON.parse(localStorage.getItem('user'));
58 },
59 methods: {
60 logout() {
61 this.$store
62 .dispatch('logout')
63 .then(() => {
64 console.log('I am serious');
65 this.$router.push('/login');
66 })
67 .catch(err => {
68 console.log(err);
69 });
70 },
71 },
72 };
73 </script>
74
75 <style scoped>
76 .navbar {
77 width: 100%;
78 }
79 h1 a {
80 color: #fff;
81 text-decoration: none;
82 }
83 nav {
84 display: flex;
85 justify-content: space-between;
86 align-items: center;
87 }
88
89 ul {
90 list-style-type: none;
91 margin: 0;
92 padding: 0;
93 }
94
95 .ul {
96 display: flex;
97 align-items: baseline;
98 }
99
100 .btn {
101 border: none;
102 box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
103 cursor: pointer;
104 height: 4.375em;
105 border-radius: 1.5em;
106 text-decoration: none;
107 padding: 1.5em 1.5em;
108 background-color: rgb(87, 41, 178);
109 color: #fff;
110 font-size: 14px;
111 font-weight: 600;
112 font-family: 'Karla';
113 outline: none;
114 }
115
116 .btn-outline {
117 background: #fff;
118 border: 1px solid rgb(87, 41, 178);
119 color: #000;
120 }
121
122 li {
123 margin-left: 0.5rem;
124 margin-right: 0.5rem;
125 color: #fff;
126 }
127
128 .nav-link {
129 color: #fff;
130 text-decoration: none;
131 }
132
133 @media (max-width: 700px) {
134 ul {
135 justify-content: space-between;
136 }
137 }
138 </style>
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:
1 // src/pages/index.vue
2
3 <template>
4 <Layout>
5 <section class="hero-section">
6 <div v-if="!authStatus">
7 <h1>Welcome!</h1>
8
9 <p>
10 Strapi is the leading open-source headless CMS. It’s 100%
11 Javascript, fully customizable and developer-first.
12 </p>
13
14 <p>
15 <g-link to="/login"> Login </g-link> to enjoy all our
16 courses here
17 </p>
18 </div>
19
20 <div v-else>
21 <h2>You're logged in</h2>
22
23 <p>
24 Continue viewing your <g-link to="/home"> courses </g-link>
25 </p>
26 </div>
27 </section>
28 </Layout>
29 </template>
30
31 <script>
32 export default {
33 metaInfo: {
34 title: 'Learning Platform',
35 },
36 computed: {
37 authStatus() {
38 if (this.$store.getters.authStatus === 'success') {
39 return true;
40 } else if (this.$store.getters.authStatus === 'error') {
41 return false;
42 } else if (this.$store.getters.authStatus === '') {
43 return false;
44 }
45 },
46 },
47 };
48 </script>
49
50 <style>
51 .home-links a {
52 margin-right: 1rem;
53 }
54
55 .hero-section {
56 background: #fff;
57 color: #2f2e8b;
58 padding: 1rem;
59 }
60 </style>
Reload your server and navigate to your browser.
Congratulations!!! 🙌
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.
Are you stuck somewhere? Here is the source code for the frontend and backend.