Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi-app
my-project
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
2
3
4
5
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
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 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.
1
2
3
4
5
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:
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
2
3
4
5
6
7
8
9
10
11
12
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
// 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:
Let’s add some content to the homepage. Replace the code in pages/Index.vue
with this code snippet:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 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:
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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.
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
2
3
4
5
6
7
8
9
10
// 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
1
2
3
4
5
6
7
8
9
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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.
1
2
3
//.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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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
1
2
3
4
5
6
7
8
9
// 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.
1
2
3
4
5
6
7
8
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// 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:
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 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 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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.
1
2
3
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
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<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:
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// 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:
Replace the code in src/components/Navbar.vue
with these lines of code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
// 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>
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 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.
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.