Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi-app
my-project
A headless CMS is a great tool for building modern applications. Unlike a traditional monolithic CMS like WordPress and Drupal, a headless CMS allows developers to connect their content to any frontend architecture of their choice, allowing for more flexibility, functionality and performance.
With a customizable Headless CMS like Strapi for example, we have the ability to choose a database for our content, integrate a media library and even expand the backend functionality using plugins that can be found on the Strapi marketplace.
In this tutorial, we'll cover the concept of a Headless CMS and its benefits. We'll set up a working Strapi backend with PostgreSQL as our database and Cloudinary for image uploads.
We'll also look into how we can build our frontend using Nuxt 3 which gives us SSR support right out of the box and is compatible with Vue3. We're also going to set up GraphQL instead of the default REST API.
At the end of this tutorial, we'd have created a modern photo sharing app where users can sign in and upload photos and add comments.
We will learn how to set up Strapi with Postgres and integrate our Strapi backend with the Strapi comments plugin. We’ll also be able to build the frontend with the new Vue 3 Composition API, GraphQL, and SSR.
Let’s take a quick look at these concepts and technologies that are at the core of our backend.
Headless CMS is a type of Content Management System (CMS) that usually provides an Application Programming Interface (API) which allows the frontend to be built independently from the backend (or CMS). Unlike traditional CMS options like WordPress, a Headless CMS allows developers to build the frontend of their application with any framework of their choice.
Strapi is a world-leading open-source headless CMS. Strapi makes it very easy to build custom APIs either REST APIs or GraphQL that can be consumed by any client or front-end framework of choice.
Strapi runs an HTTP server based on Koa, a back-end JavaScript framework. It also supports databases like SQLite and Postgres (which we’ll be using in this tutorial).
PostgreSQL, also known as Postgres, is a free and open-source relational database management system emphasizing extensibility and SQL compliance. As one of the databases Strapi currently supports, Postgres is a solid choice over SQLite and we’ll see how we can set it up.
To follow this tutorial, you’ll need to have a few things in place, including:
First, we’ll create a new Strapi app. To do this:
yarn create strapi-app photo-app-api
Quickstart is recommended, which uses the default database (SQLite)
Once the installation is complete, the Strapi admin dashboard should automatically open in your browser at http://localhost:1337/admin/
.
For windows users, refer to this guide on how to Set Up a PostgreSQL Database on Windows. Also, refer to the Postgres documentation to see how to create a new database.
Once installed, start the Postgres server and obtain the port, username, and password.
To create a new database, in your terminal, run:
createdb -U postgres photosdb
postgres
superuserTo use PostgreSQL in Strapi, we’ll add some configurations in our ./config/database.js
file.
./config/database.js
and paste the below code in the file:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ./config/database.js
module.exports = ({ env }) => ({
connection: {
client: 'postgres',
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'photosdb'),
user: env('DATABASE_USERNAME', 'user'),
password: env('DATABASE_PASSWORD', '1234'),
schema: env('DATABASE_SCHEMA', 'public'), // Not required
ssl: {
rejectUnauthorized: env.bool('DATABASE_SSL_SELF', false),
},
},
debug: false,
},
});
connection
object, we set the client
to postgres
. This client is the PostgreSQL database client to create the connection to the DB.host
is the hostname of the PostgreSQL server we set it to localhost
.port
is set to 5432
, and this is the default port of the PostgreSQL server.database
is set to the photosdb
, and this is the name of the database we created in the PostgreSQL server.password
is the password of our PostgreSQL server.username
is the username of our PostgreSQL. It is set to Postgres
because it is the username of our PostgreSQL server.schema
is the database schema, and it is set to the public
here. This schema is used to expose databases to the public.With this, our Strapi is using PostgreSQL to store data.
yarn develop
N/B: If you encounter an error: Cannot find module 'pg'
, install pg
. Run:
yarn add pg
#OR
npm install pg
Once the app has started, it should open the admin registration page at http://localhost:1337
. Proceed to create an admin account.
To get started with GraphQL in our Strapi backend, we’ll install the GraphQL plugin first. To do that:
yarn strapi install graphql
#OR
npm run strapi install graphql
Let’s create the content type for our posts.
First, in the Configurations modal, set the display name - **Post**
, then create the fields for the collection
Caption
- Text (Long Text)photo
- Media (Multiple media)user
- Relation (one-to-many relation with Users
from users-permissions
)The collection type should look like this:
Finally, we have to install the Strapi comments plugin.
yarn add strapi-plugin-comments@latest
comments
.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
// ./config/plugins.js
module.exports = ({ env }) => ({
//...
comments: {
relatedContentTypes: {
posts: {
contentManager: true,
isSingle: true,
key: "title",
value: "id",
},
},
enabled: true,
config: {
enabledCollections:["api::post.post"],
badWords: false,
moderatorRoles: ["Authenticated"],
approvalFlow: ["api::post.post"],
entryLabel: {
"*": ["Title", "title", "Name", "name", "Subject", "subject"],
"api::post.post": ["MyField"],
},
reportReasons: {
MY_CUSTOM_REASON: "MY_CUSTOM_REASON",
},
gql: {
// ...
},
},
},
graphql: {
}
});
You can see more information on configuration from the Strapi Comments plugin docs.
yarn build && yarn develop
When we open our admin dashboard, the Comments plugin should appear in the Plugins section of Strapi sidebar.
Next, we’ll configure our Comments plugin. Navigate to SETTINGS > COMMENTS PLUGIN > CONFIGURATION then confgure the following settings:
1
2
3
4
- General configuration - *Enable comments only for* : **`Posts`**
- Additional configuration
- Bad words filtering: **`Enabled`**
- GraphQL queries authorization: **`Enabled`**
Click on SAVE to save the configuration and restart the server for the changes to take effect.
By default, we won't be able to access any API from Strapi unless we set the permissions.
Go to PUBLIC and enable the following actions for
Posts
find
findOne
findAllFlat
findAllInHierarchy
findOne
✅ User > find
Next, to set permissions for authenticated users, go to AUTHENTICATED and enable the following actions for
count
findOne
find
In the second stage, we are going to set up our frontend.
npx nuxi init photos-app-frontend
cd photos-app-frontend
yarn install
#OR
npm install
Once the installation is complete, add Tailwind CSS to the project using the @nuxt/tailwind
module. We’ll also install the tailwind form plugin. This module helps set up Tailwind CSS (version 3) in our Nuxt 3 application in seconds.
yarn add --dev @nuxtjs/tailwindcss @tailwindcss/forms
#OR
npm install --save-dev @nuxtjs/tailwindcss @tailwindcss/forms
modules
section in nuxt.config.ts
:1
2
3
4
5
// nuxt.config.ts
// ...
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss']
})
tailwind.config.js
by running: npx tailwindcss init
tailwind.config.js
1
2
3
4
5
6
7
8
9
10
// tailwind.config.js
module.exports = {
theme: {
// ...
},
plugins: [
require('@tailwindcss/forms'),
// ...
],
}
/.assets/css/main.css
file:1
2
3
4
5
// ./assets/css/main.css
@tailwind base;
@tailwind components;
@tailwind utilities;
nuxt.config.ts
file, enter the following:1
2
3
4
5
6
7
8
// ./nuxt.config.ts
// ...
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
tailwindcss: {
cssPath: '~/assets/css/main.css',
}
})
We’ll use Heroicons for our icon library. To get started, install the Vue library:
yarn add @heroicons/vue
#OR
npm install @heroicons/vue
We want users to be able to register, sign in, and sign out. To do that, we first need to define our user
and session
state.
We’ll be using Nuxt runtime config to access global config in our application like the Strapi GraphQL URLs. In the ./nuxt.config.ts
file,
1
2
3
4
5
6
7
8
9
10
11
12
// ./nuxt.config.ts
import { defineNuxtConfig } from 'nuxt'
export default defineNuxtConfig({
// ...
runtimeConfig: {
public: {
graphqlURL: process.env.STRAPI_GRAPGHQL || 'http://localhost:1337/graphql',
strapiURL: process.env.STRAPI_URL || 'http://localhost:1337',
}
},
})
Click here to view the code on GitHub.
This way, we can have access to these URLs throughout our application.
Nuxt 3 provides useState
composable to create a reactive and SSR-friendly shared state across components.
./composables/state.js
and enter the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ./composables/state.js
// READ USER STATE
export const useUser = () => useState("user", () => ({}));
// SET USER STATE
export const useSetUser = (data) => useState("set-user", () => (useUser().value = data));
// USE SESSION STATE
export const useSession = () => useState("session", () => ({ pending: true, data: null }));
// SET SESSION STATE
export const useSetSession = (data) => {
// save session state to localstorage
localStorage.setItem("session", JSON.stringify(data));
useState("set-session", () => {
// update session state
useSession().value.pending = false;
useSession().value.data = data;
// update user state
useUser().value = data?.user;
});
};
Click here to view the code on Github
Here, we’re using [useState](https://v3.nuxtjs.org/guide/features/state-management)
, an SSR-friendly [ref](https://vuejs.org/api/reactivity-core.html#ref)
replacement. Its value will be preserved after server-side rendering (during client-side hydration) and shared across all components using a unique key.
The useState
function accepts two parameters, the key
which is a string and a function.
So, we create state for useUser
& useSession
which will be used to read/get the current user and session state. setUser
& setSession
will be used to set state with setSession
saving the session data to localStorage
and setting the useUser
state as well.
Next, we’ll create a composable function that we’ll use to send requests to our Strapi backend.
./composables/sendReq.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
27
28
// ./composables/sendReq.js
// function to send requests
// pass GraphQL URL and request options
export const sendReq = async (graphqlURL, opts) => {
try {
let res = await fetch(graphqlURL, {
method: "POST",
// fetch options
...opts,
});
let result = await res.json();
console.log(result.errors);
// Handle request errors
if (result.errors) {
result.errors.forEach((error) => alert(error.message));
// Throw an error to exit the try block
throw Error(JSON.stringify(result.errors));
}
// save result response to page data state
return result.data;
} catch (error) {
console.log(error);
return {
errors: error,
};
}
}
Click here to view the code on GitHub
Here, we create a sendReq
function which will be used to send requests and return the data
or errors
. In order to catch errors from the request, we check if result.errors
and then loop through errors
array to alert each error message. Then throw the Error
to exit the try
block to the catch
block.
Now, let's proceed to create our authentication page.
layouts/default.vue
1
2
3
4
5
6
7
<!-- layouts/default.vue -->
<template>
<div class="layout default">
<SiteHeader />
<slot />
</div>
</template>
<SiteHeader/>
component. In a new file components/SiteHeader.vue
enter the following: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
<!-- ./components/SiteHeader.vue -->
<script setup>
import { RefreshIcon } from "@heroicons/vue/outline";
const session = useSession();
console.log({ session: session });
</script>
<template>
<header class="site-header">
<div class="wrapper">
<NuxtLink to="/">
<figure class="site-logo"><h1>Photos</h1></figure>
</NuxtLink>
<nav class="site-nav">
<!-- Hide if session state is pending -->
<ul v-if="!session.pending" class="links">
<!-- Render Register link if no user in session -->
<li v-if="!session.data?.user" class="link">
<NuxtLink to="/auth/register">
<button class="cta">Register</button>
</NuxtLink>
</li>
<!-- Render Sign in link if no user in session -->
<li v-if="!session.data?.user" class="link">
<NuxtLink to="/auth/sign-in">
<button class="cta">Sign in</button>
</NuxtLink>
</li>
<!-- Else, Render Sign out link if user in session -->
<li v-else class="link">
<NuxtLink to="/auth/sign-out">
<button class="cta">Sign out</button>
</NuxtLink>
</li>
</ul>
<!-- Display loading if session state is pending -->
<div v-else class="cta">
<RefreshIcon class="icon stroke animate-rotate" />
</div>
</nav>
</div>
</header>
</template>
<style scoped>
/* ... */
</style>
In the <script>
, we get the session
state from useSession()
which is automatically imported by Nuxt. Using v-if
, we conditionally render the links to our auth pages depending on if session.user
exists or not.
./app.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
<!-- ./app.vue -->
<script setup>
import "@/assets/css/main.css";
const router = useRouter();
const session = useSession();
onMounted(() => {
// set session from localStorage
useSetSession(JSON.parse(localStorage.getItem("session")));
// route guard to prevent users from routing to
// sign in and register page when alrady logged in
router.beforeEach((to, from) => {
if ((to.path === "/auth/register" || to.path === "/auth/sign-in") && session.value?.jwt) {
return false;
}
});
});
</script>
<template>
<NuxtLayout name="default">
<!-- Render page depending on route -->
<NuxtPage />
</NuxtLayout>
</template>
In the <script>
, we set the session from localStorage
and use useRouter
to implement a route guard to prevent routing to register
and sign-in
pages when already signed in.
Now, we create our home page ./pages/index.vue
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- ./pages/index.vue -->
<script setup>
const user = useUser();
</script>
<template>
<main class="home-main">
<div class="wrapper">
<header>
<h1 class="text-4xl font-bold">Hey, {{ user?.username }}</h1>
</header>
</div>
</main>
</template>
<style scoped>
/* ... */
</style>
Here, in the <script>
, we simply get the user
state from useUser()
. In the <template/>
, we display the user username.
Next, we’ll create our authentication pages
./pages/auth/register.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
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
<!-- ./pages/register.vue -->
<script setup>
// retrive GraphQL URL from runtime config
const {
public: { graphqlURL },
} = useRuntimeConfig();
// session & set session
const session= useSession();
const setSession = useSetSession;
// page state
const isLoading = ref(false);
// form state
const name = ref("");
const email = ref("");
const password = ref("");
// handle form submit
const handleRegister = async (e) => {
e.preventDefault();
if (name && email && password) {
isLoading.value = true;
// GraphQL Register Query
let registerQuery = {
query: `mutation($username: String!, $email: String!, $password: String!,){
register(input: {username: $username, email: $email, password: $password}){
jwt
user{
id
username
email
}
}
}`,
variables: {
username: name.value,
email: email.value,
password: password.value,
},
};
try {
const { register, errors } = await sendReq(graphqlURL, { body: JSON.stringify(registerQuery), headers: { "Content-Type": "application/json" } });
// throw error if any
if (errors) throw Error(errors);
// set session, notify and navigate to the home page
setSession(register);
alert("Registeration successful!");
navigateTo("/");
} catch (error) {
console.log(error);
} finally {
isLoading.value = false;
}
}
};
</script>
<template>
<main class="site-main register-main">
<div class="wrapper">
<div class="form-cont">
<form @submit="handleRegister" class="form auth-form">
<div class="wrapper">
<header class="form-header">
<h3 class="text-3xl mb-4">Sign Up</h3>
</header>
<section class="input-section">
<div class="form-control">
<label for="name">Name</label>
<input v-model="name" id="name" name="name" type="text" class="form-input" required />
</div>
<div class="form-control">
<label for="email">Email address</label>
<input v-model="email" id="email" name="email" type="email" class="form-input" required />
</div>
<div class="form-control">
<label for="password">Password</label>
<input v-model="password" id="password" name="password" type="password" class="form-input" required />
</div>
<div class="action-cont">
<button :disabled="isLoading" class="cta">{{ isLoading ? "..." : "Register" }}</button>
</div>
</section>
</div>
</form>
</div>
</div>
</main>
</template>
In the <script>
, we have handleRegister()
function which gets called when the registration form is submitted. We get the form data from the name
, email
and password
refs and v-model
on the input elements in the <template>
.
Then, we create a GraphQL mutation
with the data inserted as variables. We’re using the Fetch API to send the request to our Strapi GraphQL endpoint which will be configured in our Nuxt runtimeConfig
.
If the request was succesful, we save the response to session
, if there were errors, we throw
it. These are the values from our query that will be returned:
1
2
3
4
5
6
jwt
user{
id
username
email
}
Next, we’ll create the sign in page.
./pages/auth/sign-in.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
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
<!-- ./pages/auth/sign-in.vue -->
<script setup>
// retrive GraphQL URL from runtime config
const {
public: { graphqlURL },
} = useRuntimeConfig();
// session & user state
const session = useSession();
const setSession = useSetSession;
// page state
const isLoading = ref(false);
const data = ref({});
const error = ref({});
// form state
const name = ref("");
const email = ref("");
const password = ref("");
// handle form submit
const handleSignIn = async (e) => {
e.preventDefault();
if (name && email && password) {
// GraphQL Sign in Query
let signInQuery = {
query: `mutation( $email: String!, $password: String!) {
login(input: { identifier: $email, password: $password }) {
jwt,
user{
id
username
email
}
}
}`,
variables: { email: email.value, password: password.value },
};
try {
isLoading.value = true;
const { login, errors } = await sendReq(graphqlURL, { body: JSON.stringify(signInQuery), headers: { "Content-Type": "application/json" } });
if (errors) throw Error(errors);
setSession(login);
// notify and navigate to the home page
alert("Sign in successful!");
navigateTo("/");
} catch (error) {
console.log(error);
} finally {
isLoading.value = false;
}
}
};
</script>
<template>
<main class="site-main sign-in-main">
<div class="wrapper">
<div class="form-cont">
<form @submit="handleSignIn" class="form auth-form">
<div class="wrapper">
<header class="form-header">
<h3 class="text-3xl mb-4">Sign In</h3>
</header>
<section class="input-section">
<div class="form-control">
<label for="email">Email address</label>
<input v-model="email" id="email" name="email" type="email" class="form-input" required />
</div>
<div class="form-control">
<label for="password">Password</label>
<input v-model="password" id="password" name="password" type="password" class="form-input" required />
</div>
<div class="action-cont">
<button :disabled="isLoading" class="cta">{{ isLoading ? "..." : "Sign in" }}</button>
</div>
</section>
</div>
</form>
</div>
</div>
</main>
</template>
The sign-in
page is basically the same as the register
page. Except we’re using the signInQuery
which returns the response with login
.
Also, we remove the name
input from the form in the <template>
.
The sign-out
page is pretty simple.
Create a new file ./pages/sign-out.vue
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- ./pages/sign-out.vue -->
<script setup>
onMounted(() => {
// set session to null
useSetSession(null);
setTimeout(() => {
alert("You've signed out. See you back soon!");
navigateTo("/");
}, 1000);
});
</script>
<template>
<main class="sign-out-main auth-main">
<div class="wrapper">
<h3 class="text-3xl">Signing Out...</h3>
</div>
</main>
</template>
Here, we simply set the session
state to null
, which in turn sets the localStorage
and user
state to null
.
Now, let’s try to register a new user.
Awesome.
Let’s hop back into our admin dashboard and create a new entry in the Post
collection type with our new user.
Click on SAVE and PUBLISH.
We’ll have to create a Post
component which will be used to display a post.
./components/Post.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
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
<!-- ./components/Post.vue -->
<script setup>
// import icons
import { ChatIcon, PaperAirplaneIcon } from "@heroicons/vue/solid";
// define `post` props
defineProps(["post"]);
const {
public: { strapiURL },
} = useRuntimeConfig();
// helper function to get username from `post` attributes
const getUsername = (post) => post?.attributes?.user.data?.attributes?.username;
// helper to get and display date
const showDateTime = (post) => new Date(post?.attributes?.updatedAt).toDateString() + " | " + new Date(post?.attributes?.updatedAt).toLocaleTimeString();
</script>
<template>
<li class="post">
<header class="post-header">
<div class="author-cont">
<div class="avatar">
<!-- Get first letter in user name -->
<p>{{ getUsername(post)?.substr(0, 1) }}</p>
</div>
<p class="username">{{ getUsername(post) }}</p>
</div>
<!-- Post options go here -->
</header>
<ul class="photos">
<li v-for="photo in post?.attributes?.photo.data" :key="photo.id" class="photo">
<div class="img-cont">
<img :src="strapiURL + photo?.attributes?.url" :alt="post?.attributes?.caption" />
</div>
</li>
</ul>
<p class="caption">{{ post?.attributes?.caption }}</p>
<div class="info-cont text-xs text-gray-600">
<div class="time">{{ showDateTime(post) }}</div>
</div>
<div class="comment-cont">
<ChatIcon class="icon solid" />
<form class="comment-form">
<div class="form-control">
<textarea name="comment" id="comment" cols="30" rows="1" class="form-textarea comment-textarea" placeholder="Leave a comment"> </textarea>
</div>
<button class="cta submit-btn">
<PaperAirplaneIcon class="icon solid rotate-90" />
</button>
</form>
</div>
</li>
</template>
<style scoped>
/* ... */
</style>
Here, we define the props
for our component with the defineProps()
method which accepts a props array or object. We also have a few helper functions which help with displaying some information like avatar and date.
Next, let’s modify our home page to fetch the posts and render the component.
Back in our ./pages/index.vue
page,
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
<!-- ./pages/index.vue -->
<script setup>
import { RefreshIcon } from "@heroicons/vue/outline";
const {
public: { graphqlURL, strapiURL },
} = useRuntimeConfig();
const session = useSession();
const user = useUser();
// page state
const isLoading = ref(false);
const posts = ref({});
const error = ref({});
// function to get posts
const getPosts = async (page) => {
// post query with page and page size variables
const postsQuery = {
query: `query($page: Int, $pageSize: Int) {
posts(pagination: {page: $page, pageSize:$pageSize}, sort: "updatedAt:desc") {
data {
id
attributes {
user {
data {
id
attributes {
username
}
}
}
photo {
id
data {
attributes {
url
}
}
}
caption
updatedAt
}
}
meta {
pagination {
total
pageSize
page
}
}
}
}
`,
variables: {
page: parseInt(page),
pageSize: 10,
},
};
try {
let { posts, errors } = await sendReq(graphqlURL, { body: JSON.stringify(postsQuery), headers: { "Content-Type": "application/json" } });
if (errors) throw Error(errors);
return posts;
} catch (error) {
console.log({ error });
}
};
// Get current route page by destructuring useAsyncData context to get route > query > page
const { data } = await useAsyncData("posts", async ({ _route: { query } }) => {
let { page } = query;
return await getPosts(page);
});
</script>
As we can see here in our <script>
section, we have an async getPosts()
function which takes page
as a parameter. Within this function, we define the postsQuery
with $page
and $pageSize
variables which determines the page and number of posts in a page we get from our query.
In order to determine the page, within the useAsyncData()
function, we have access to the request context from the ctx
parameter in the useAsyncData(``'``posts``'``, (ctx) => {})
. By destructuring obtain the page
from the route query.
Next, in our <template>
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
<!-- ./pages/index.vue -->
<template>
<main class="site-main home-main">
<div class="wrapper">
<header>
<h1 class="text-4xl font-bold">Hey, {{ user?.username || "Stranga!" }}</h1>
</header>
<div class="content-wrapper">
<aside class="create-post-aside">
<div class="wrapper">
<header class="px-2 mb-2">
<h3 class="font-medium">Create a new post</h3>
</header>
<!-- Editor goes here -->
<!-- Pagination goes here -->
</div>
</aside>
<section class="posts-section">
<ul class="posts">
<Post v-for="post in data?.data" :key="post.id" :post="post" />
</ul>
</section>
</div>
</div>
</main>
</template>
<style scoped>
/* ... */
</style>
With that, we should have our first post displayed like this:
Next, we’ll create our Editor
component to upload pictures and create posts.
Eventually, our posts will be more than just one and we’ll need a way to navigate through them, let’s quickly create a pagination component for that.
./components/Pagination.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
27
28
29
30
31
32
33
34
35
36
37
38
<!-- ./components/Pagination.vue -->
<script setup>
defineProps(["pagination"]);
// helper function to create pagination
const getPagination = ({ page, pageSize, total, pageCount }) => {
// Get previous page number with `1` as the lowest
let prev = Math.abs(page - 1) > 0 ? Math.abs(page - 1) : 1;
// Get next page with number of total pages as highest
let next = page + 1 < pageCount ? page + 1 : pageCount;
// console.log({ pages, prev, next });
return {
pageCount, page, pageSize, total, prev, next,};
};
</script>
<template>
<div class="pagination">
<ul class="list">
<li>
<a :href="`?page=` + getPagination(pagination).prev">
<button class="cta">Previous</button>
</a>
</li>
<li v-for="n in getPagination(pagination).pageCount" :key="n">
<a :href="`?page=${n}`">
<button class="cta">
{{ n }}
</button>
</a>
</li>
<li>
<a :href="`?page=` + getPagination(pagination).next">
<button class="cta">Next</button>
</a>
</li>
</ul>
</div>
</template>
In this component we get the pagination
data as a prop which we defined in the defineProps()
function. Then, we create a getPagination()
helper function which calculates the next
and previous
page numbers. Let’s add it in our ./pages/index.vue
file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- ./pages/index.vue -->
<template>
<main class="site-main home-main">
<div class="wrapper">
<!-- ... -->
<div class="content-wrapper">
<aside class="create-post-aside">
<div class="wrapper">
<!-- ... -->
<!-- Editor goes here -->
<Pagination :pagination="data?.meta?.pagination" />
</div>
</aside>
<!-- ... -->
</div>
</div>
</main>
</template>
Next, we’ll work on the Editor
component to create posts.
In order to create posts, we will need to send authenticated requests with our mutation queries. To create a Post we will need to send two requests to:
To do this:
./components/Editor.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
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
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
<!-- ./components/Editor.vue -->
<script setup>
// import icons
import { PlusCircleIcon } from "@heroicons/vue/solid";
// Get graphqlURL from runtime config
const {
public: { graphqlURL },
} = useRuntimeConfig();
// init useRouter
const router = useRouter();
// Get `user` & `session` application state
const user = useUser();
const session = useSession().value;
// data state
const isLoading = ref(false);
const data = ref(null);
const error = ref({});
// component states
const imagesURL = ref([]);
const images = ref({});
const fileBtnText = ref("Choose Images");
const caption = ref("");
// header object for fetch request
let headersList = {
Accept: "*/*",
// set authorization token
Authorization: `Bearer ${session.data.jwt}`,
"Content-Type": "application/json",
};
// function to generate preview URLs for selected images before upload
const previewImg = (e) => {
// save files to `images` state
images.value = e.target.files;
// create iterable array from `FileList`
let files = Array.from(images.value);
// iterate over `files` array to create temporary URLs
// URLs will be used to preview selected images before upload
imagesURL.value = [];
files.forEach((file, i) => {
let url = URL.createObjectURL(file);
imagesURL.value.push(url);
});
// set upload file button content to [number of] files selected
let length = imagesURL.value.length;
fileBtnText.value = `${length} file${length != 1 ? "s" : ""} selected`;
};
// function to upload files to Strapi media library and get the uploaded file `ids`
const uploadFiles = async () => {
// mutation to upload multiple files
const uploadMultipleMutation = {
query: `
mutation($files: [Upload]!){
multipleUpload(files: $files){
data{
id
}
}
}
`,
variables: {
// dynamically generate array with null values to match the number of images selected
// files: [null, null, null]
files: imagesURL.value.map((x) => null),
},
};
// image variables map
/*
{
"0": [
"variables.files.0"
],
"1": [
"variables.files.1"
],
"2": [
"variables.files.2"
]
}
*/
let map = {};
// init `FormData()`
const formData = new FormData();
// append `operations` to FormData(), which conatin the graphQL query
formData.append("operations", JSON.stringify(uploadMultipleMutation));
// create map for variable files
for (let i = 0; i < images.value.length; i++) {
map[`${i}`] = [`variables.files.${i}`];
}
// add mapp to `FormData()`
formData.append("map", JSON.stringify(map));
// append images to formData
// with names corresponding to the keys in `map`
for (let i = 0; i < images.value.length; i++) {
const image = images.value[i];
formData.append(`${i}`, image);
}
try {
// send request with `formdata` and authorization headers
let { multipleUpload, errors } = await sendReq(graphqlURL, { body: formData, headers: { Authorization: headersList.Authorization } });
if (errors) throw Error(errors);
console.log(multipleUpload);
return multipleUpload;
} catch (error) {
console.log(error);
}
};
// function to create post
const createPost = async (e) => {
e.preventDefault();
isLoading.value = true;
// run if images and caption are not null
if (images && caption) {
// upload images and get the IDs
let uploads = await uploadFiles();
// mutation query for creating posts
let createPostQuery = {
query: `
mutation($data: PostInput!){
createPost(data: $data){
data{
id
attributes{
caption
photo{
data{
id
attributes{
url
}
}
}
user{
data{
id
}
}
updatedAt
}
}
}
}
`,
variables: {
data: {
caption: caption.value,
user: user.value.id,
// photo: [array of uploaded photo ids]
// e.g. photo: [21, 22, 23]
photo: uploads.map((file) => file.data.id),
publishedAt: new Date(),
},
},
};
try {
// send request to create post
const { createPost, errors } = await sendReq(graphqlURL, { body: JSON.stringify(createPostQuery), headers: headersList });
if (errors) throw Error(errors);
// save to state
data.value = createPost;
// reload page
window.location.replace("/");
} catch (error) {
console.log(error);
} finally {
isLoading.value = false;
}
}
};
</script>
<template>
<form :disabled="isLoading || data" @submit="createPost" class="upload-form">
<div class="wrapper">
<div class="form-control file">
<div class="img-cont upload-img-cont">
<!-- Selected image previews -->
<ul v-if="imagesURL.length >= 1" class="images">
<li v-for="(src, i) in imagesURL" :key="i" class="image">
<img :src="src" alt="Image" class="upload-img" />
</li>
</ul>
</div>
<label for="image-input" class="file-label" :class="{ 'mt-4': imagesURL.length > 0 }">
<span type="button" class="cta file-btn">
{{ fileBtnText }}
</span>
<input
:disabled="isLoading || data"
@change="previewImg"
id="image-input"
class="file-input"
type="file"
accept=".png, .jpg, .jpeg"
multiple
required
/>
</label>
</div>
<div class="form-control">
<label for="caption">Caption</label>
<textarea
:disabled="isLoading || data"
v-model="caption"
name="caption"
id="caption"
cols="30"
rows="1"
class="form-textarea caption-textarea"
placeholder="Enter you caption"
required
>
</textarea>
</div>
<div class="action-cont">
<button :disabled="isLoading || data" type="submit" class="cta w-icon">
<PlusCircleIcon v-if="!data" class="icon solid" />
<span v-if="!data">{{ !isLoading ? "Create Post" : "Hang on..." }}</span>
<span v-else>Post created! 🚀</span>
</button>
</div>
</div>
</form>
</template>
<style scoped>
/* ... */
</style>
Let’s go over a few things happening here.
First, we have the previewImg()
function which generates a temporary URL which will be used to display images that have been selected for uploads. It also gets the total number of selected files and updates the fileBtnText
state accordingly.
Next, we have the uploadFiles()
function which does the following:
FormData()
and append the uploadMultipleMutation
query to it with the operations
name.for
loop with each file from the images
array state mapped to it’s index, the map should end up like this:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// create map for variable files
for (let i = 0; i < images.value.length; i++) {
map[`${i}`] = [`variables.files.${i}`];
}
// image variables map
{
"0": [
"variables.files.0"
],
"1": [
"variables.files.1"
],
"2": [
"variables.files.2"
]
}
Then, the map
is appended to formData()
with the "``map``"
name.
Finally, we send the request with formdata
and authorization headers.
After that, we have the createPost()
function which runs on form submit. When fired, the function runs the uploadFiles()
function and gets the image IDs. We also define the createPostQuery
mutatation query where we pass in the caption
, user
, photo
- which is an array of the IDs of the uploaded images and publishedAt
to immediately publish the post once created.
Back in our ./pages/index.vue
homepage, let’s add the <Editor />
component where we added the placeholder comment.
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
<!-- ./pages/index.vue -->
<template>
<main class="site-main home-main">
<div class="wrapper">
<!-- ... -->
<div class="content-wrapper">
<aside class="create-post-aside">
<div class="wrapper">
<!-- ... -->
<!-- Render editor if session is not pending -->
<div v-if="!session.pending" class="editor-con">
<Editor v-if="user?.email" />
<div v-else class="unauthenticated-message px-2">
<p>Sorry, you have to sign in to post</p>
</div>
</div>
<div v-if="session.pending" :class="{ 'loading-state': session.pending }">
<RefreshIcon class="icon stroke animate-rotate" />
</div>
<Pagination :pagination="data?.meta?.pagination" />
</div>
</aside>
<!-- ... -->
</div>
</div> </main>
</template>
Now, if we try to create a post, we should have something like this:
Splendid. Next, let’s see how we can edit and delete posts.
In order to be able to edit and delete posts, we will have to make a few changes to our current code.
First, let’s add the state that holds the current post to be edited.
In our ./composables/state.js
file:
1
2
3
4
5
6
7
8
9
10
11
// ./composables/state.js
// ...
// READ CURRENT POST STATE
export const usePost = () => useState("post", () => ({}));
// SET CURRENT POST STATE
export const useSetPost = (data) =>
useState("set-post", () => {
usePost().value = data;
console.log({ data, usePost: usePost().value });
});
Next, we have to add the options to edit and delete a post to the
In our ./components/Post.vue
template, we render the options dropdown only when the user.id
matches.
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
<!-- ./components/Post.vue -->
<template>
<li class="post">
<header class="post-header">
<div class="author-cont">
<div class="avatar">
<!-- Get first letter in user name -->
<p>{{ getUsername(post)?.substr(0, 1) }}</p>
</div>
<p class="username">{{ getUsername(post) }}</p>
</div>
<!-- Render if logged in user id matches with post user id -->
<div v-if="user.id == getUserID(post)" class="options dropdown">
<button class="dropdown-btn">
<DotsHorizontalIcon class="icon solid" />
</button>
<ul class="dropdown-list">
<li>
<button @click="editPost" class="w-icon w-full">
<PencilIcon class="icon solid" />
<span>Edit</span>
</button>
</li>
<li>
<button @click="deletePost" class="w-icon w-full">
<TrashIcon class="icon solid" />
<span>Delete</span>
</button>
</li>
</ul>
</div>
</header>
<!-- ... -->
</li>
</template>
In the <script>
, let’s initialize the current post state and also create the getUserID()
, editPost()
and deletePost()
functions.
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
<!-- ./components/Post.vue -->
<script setup>
// import icons
import { ChatIcon, PaperAirplaneIcon, DotsHorizontalIcon, PencilIcon, TrashIcon } from "@heroicons/vue/solid";
// define `post` props
const props = defineProps(["post"]);
// Get graphqlURL from runtime config
const {
public: { graphqlURL, strapiURL },
} = useRuntimeConfig();
// Get global session and user state
const session = useSession();
const user = useUser();
// Get & Set global curresnt post state
const currentPost = usePost();
const setCurrentPost = useSetPost;
// header object for fetch request
let headersList = {
Accept: "*/*",
Authorization: `Bearer ${session.value?.data?.jwt}`,
"Content-Type": "application/json",
};
// ... other helper functions
// helper function to get username from `post` attributes
const getUserID = (post) => post?.attributes?.user.data?.id;
// function to edit post
const editPost = () => {
// get post data from props
const post = props.post;
// set post action to edit
post.action = "edit";
// set modified post to global state
setCurrentPost(post);
};
// function to delete post
const deletePost = async () => {
// confirm deletion
const confirmDelete = confirm("Are you sure you want to delete this post?");
// run if user confirms deletion
if (confirmDelete) {
// mutation query for deleting post
let deletePostQuery = {
query: `
mutation($id: ID!){
deletePost(id: $id){
data{
id
}
}
}
`,
variables: {
id: props.post.id,
},
};
try {
// send request to delete post
const { deletePost, errors } = await sendReq(graphqlURL, { body: JSON.stringify(deletePostQuery), headers: headersList });
if (errors) throw Error(errors);
// reload page
window.location.reload();
} catch (error) {
console.log(error);
} finally {
isLoading.value = false;
}
}
};
</script>
Let’s quickly go over the two functions we just created:
editPost()
- This function simply gets the post
data from the component props
, sets the action
key to "``edit``"
and sets the currentPost
application state to the modified post
. This way, we can set up a watch
function in the <Editor />
component to modify the editor state accordingly.deletePost()
- This function, after the action has been confirmed, sends a request with deletePost
mutation in deletePostQuery
which deletes the post by id
.Now, we have to be able to handle the edit post action from the <Editor />
component.
In our ./components/Editor.vue
file, we have to add a few things.
First, in the <template>
we need to run a different function - handleSubmit()
when the form is submitted. This function will be responsible for determining whether to run the createPost()
or a new editPost()
function.
We also slightly modify the submit button and add a new reset
button which will run the resetAction()
function to reset the component state.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- ./components/Editor.vue -->
<template>
<form :disabled="isLoading || data" @submit="handleSubmit" class="upload-form">
<div class="wrapper">
<!-- ... -->
<div class="action-cont">
<!-- Submit button -->
<button :disabled="isLoading || data" type="submit" class="cta w-icon capitalize">
<PlusCircleIcon v-if="!data" class="icon solid" />
<span v-if="!data">{{ !isLoading ? `${action} Post` : "Hang on..." }}</span>
<span v-else>Successfull! 🚀</span>
</button>
<!-- Reset button -->
<button @click="resetAction" type="button" v-show="action == 'edit'" class="cta w-icon">
<XCircleIcon class="icon solid" />
<span>Reset</span>
</button>
</div>
</div>
</form>
</template>
In our <script>
, we’ll first initialize the state variables
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
<!-- ./components/Editor.vue -->
<script setup>
// import icons
import { PlusCircleIcon, XCircleIcon } from "@heroicons/vue/solid";
// Get graphqlURL & strapiURL from runtime config
const {
public: { graphqlURL, strapiURL },
} = useRuntimeConfig();
// Get `user` & `session` application state
const user = useUser();
const session = useSession();
// Get global current post state
const currentPost = usePost();
const setCurrentPost = useSetPost;
// to hold the image data from the current post
const currentImages = ref([]);
// data state
const isLoading = ref(false);
const data = ref(null);
// component states
const imagesURL = ref([]);
const images = ref({});
const fileBtnText = ref("Choose Images");
const caption = ref("");
const action = ref("create");
// header object for fetch request
let headersList = {
Accept: "*/*",
// set authorization token
Authorization: `Bearer ${session.value.data?.jwt}`,
"Content-Type": "application/json",
};
// ...
</script>
Next, we create the required functions:
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
<!-- ./components/Editor.vue -->
<script setup>
// ...
// function to run the create or edit post functions depending on the action
const handleSubmit = (e) => {
e.preventDefault();
action.value == "create" ? createPost() : editPost();
};
// function to create post
const createPost = async () => {
isLoading.value = true;
// run if `images` has no files and caption are not null
if (images.value.length > 0 && caption) {
// ...
}
}
// function to edit post
const editPost = async () => {
isLoading.value = true;
// if caption is not null
if (caption) {
// set uploads to uploaded files if user selects new files to upload
// else, set uploads to images from the current post data
let uploads = images.value.length > 0 ? await uploadFiles().then((res) => res.map((file) => file.data)) : currentImages.value;
// query to update post
let updatePostQuery = {
query: `
mutation($id: ID!, $data: PostInput!) {
updatePost(id: $id, data: $data) {
data {
id
attributes {
user {
data {
id
}
}
caption
photo {
data {
id
}
}
}
}
}
}
`,
variables: {
// set id to current post id
id: currentPost.value.id,
data: {
caption: caption.value,
// photo: [array of uploaded photo ids]
// e.g. photo: [21, 22, 23]
photo: uploads.map((file) => file.id),
},
},
};
try {
// send request to update post
const { updatePost, errors } = await sendReq(graphqlURL, { body: JSON.stringify(updatePostQuery), headers: headersList });
if (errors) throw Error(errors);
// save to state
data.value = updatePost;
// reload page
window.location.replace("/");
} catch (error) {
console.log(error);
} finally {
isLoading.value = false;
}
}
};
// function to reset action to "create"
const resetAction = () => {
// set current post to empty object
setCurrentPost({});
// reset component state
action.value = "create";
caption.value = "";
imagesURL.value = [];
};
// watch for changes in the current post data
watch(
currentPost,
(post) => {
// catch block catches errors that occur whenever the state is reset
try {
// destructure post data
const {
attributes: { photo, caption: _caption },
action: _action,
} = post;
// generate image URLs and ids from post photo data
currentImages.value = photo.data.map((image) => {
let src = strapiURL + "" + image.attributes.url;
let id = image.id;
return { src, id };
});
// set imagesURL to currentImages `src`
imagesURL.value = currentImages.value.map((image) => image.src);
// update component state to post data
caption.value = _caption;
action.value = _action;
} catch (error) {
console.log(error);
}
},
{ deep: true }
);
</script>
What’s going on here is pretty straightforward. The handleSubmit()
button runs either the createPost()
or editPost()
function depending on the action
.
The editPost()
function if pretty similar to createPost()
except for the fact that the uploads
data is gotten differently.
In the editPost()
function, we see this line of code:
1
2
3
// set uploads to uploaded files if user selects new files to upload
// else, set uploads to images from the current post data
let uploads = images.value.length > 0 ? await uploadFiles().then((res) => res.map((file) => file.data)) : currentImages.value;
With this, if the user selects new images to update the post (which means that images.value
will contain those selected images and will have a length greater than 0), the uploadFiles()
function will upload the files and return an array of image id
s.
If the user does not select any image on the other hand, the uploads
will be equal to the original images of the post.
We also have the watch
function which listens to changes to the currentPost
state and updates the component state accordingly.
🚨 It’s important to note that you’ll have to remove the
required
attribute from the fileinput
so that the form will be able to submit even if the user did not select any new files.
Let’s create a new component for displaying, creating and editing comments for each post. Create a new file - ./components/Comments.vue
and copy over the comment markup from the <Post />
component.
In the <script>
, we’ll create a few functions to get, create, edit and remove comments. First, let’s set up the application state and a few helper functions:
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
<!-- ./components/Comments.vue -->
<script setup>
// import icons
import { ChatIcon, PaperAirplaneIcon, XCircleIcon } from "@heroicons/vue/solid";
import { RefreshIcon } from "@heroicons/vue/outline";
// define component props
const { post } = defineProps(["post"]);
// Get graphqlURL from runtime config
const {
public: { graphqlURL },
} = useRuntimeConfig();
// Get `user` & `session` application state
const user = useUser();
const session = useSession();
// component state
const comments = ref([]);
const comment = ref({});
const action = ref("create");
const isLoading = ref(false);
// ref for comment <ul> list item
const commentList = ref(null);
// header object for fetch request
let headersList = {
Authorization: `Bearer ${session.value?.data?.jwt}`,
"Content-Type": "application/json",
};
// helper to get and display date
const showDateTime = (dateString) => new Date(dateString).toDateString() + " | " + new Date(dateString).toLocaleTimeString();
// function to activate edit action
const handleEdit = (data) => {
comment.value = { ...data };
action.value = "edit";
};
// function to reset comment state
const resetAction = () => {
comment.value = {};
action.value = "create";
};
// ...
</script>
We’re doing a few things here. For the component state, we have:
comments
- Which will be an array of all comments fetched from a post.comment
- An object containing a single comment dataaction
- Which will determine if a comment is to be created or edited when the user clicks the submit button. isLoading
- Component loading stateWe also have a ref
to a DOM element - commentList
, which is the <ul>
elemt which will contain all fetched comments.
A few helper functions we should take note of are:
handleEdit()
- Which sets the comment
value and also sets the action
to "``edit``"
resetAction()
- This resets the comment
value and resets the action
to "``create``"
Next, let’s look at how we can fetch comments.
To get comments, we’ll need to use the findAllFlat
query provided by the Strapi Comments plugin for fetching all comments.
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
<!-- ./components/Comments.vue -->
<script setup>
// ...
// function to get comments
const getComments = async () => {
// comments query
const commentsQuery = {
query: `
query($relation: String!) {
findAllFlat(relation: $relation, sort: "updatedAt:desc") {
data {
id
content
approvalStatus
updatedAt
author {
id
name
}
}
}
}
`,
variables: {
relation: `api::post.post:${post?.id}`,
},
};
try {
// send request and return results or errors
let { findAllFlat, errors } = await sendReq(graphqlURL, { body: JSON.stringify(commentsQuery), headers: { "Content-Type": "application/json" } });
if (errors) throw Error(errors);
return findAllFlat;
} catch (error) {
console.log({ error });
}
};
// watch function to update the comments list scroll postion
watch(
comments,
(comments) => {
// if commentList element exists
if (commentList.value) {
setTimeout(() => {
// scoll comment list to the top
commentList.value.scroll(0, 0);
}, 100);
}
},
{ deep: true }
);
// fetch comments and save to acomments array
comments.value = await getComments();
</script>
Here, we get the comments by running `await getComments()` and assigning the result to `comments.value`
We also have a simple `watch` function which scrolls the `commentList` element to the top whenever the `comments` state changes.
Let’s take a look at how we can create comments
To create a comment, we’ll have to use the createComment
mutation query. Also, we’ll create a handleSubmit()
function which will either run a create or edit function depending in the current action.
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
<!-- ./components/Comments.vue -->
<script setup>
// ...
// function to either create or edit comments
const handleSubmit = (e) => {
e.preventDefault();
action.value == "create" ? createComment() : editComment(comment.value?.id);
};
// function to create comments
const createComment = async () => {
// check if the comment is not empty or just spaces
if (comment.value.content.trim()) {
isLoading.value = true;
// create comment mutation
const createCommentQuery = {
query: `
mutation($input: CreateComment!) {
createComment(input: $input) {
id
content
approvalStatus
updatedAt
author {
id
name
}
}
}
`,
variables: {
input: { relation: `api::post.post:${post?.id}`, content: comment.value.content.trim() },
},
};
try {
// send request and return results or errors
let { createComment, errors } = await sendReq(graphqlURL, { body: JSON.stringify(createCommentQuery), headers: headersList });
if (errors) throw Error(errors);
// add created comment to top of comments array
comments.value.data = [createComment, ...comments.value.data];
// reset comment and action state
resetAction();
} catch (error) {
console.log({ error });
} finally {
isLoading.value = false;
}
}
};
// ...
</script>
Here, you can see that we add the newly created comment createComment
to the comments
array. We then reset the comment state with resetAction()
. Let’s proceed to how we can edit a comment.
This is pretty similar to how we created a comment. Here, we’ll be using the updateComment
mutation query.
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
<!-- ./components/Comments.vue -->
<script setup>
// ...
// function to edit comment based on it's `id`
const editComment = async (id) => {
// check if the comment is not empty or just spac
if (comment.value.content.trim()) {
isLoading.value = true;
// update comment mutataion
const updateCommentQuery = {
query: `
mutation($input: UpdateComment!) {
updateComment(input: $input) {
id
content
approvalStatus
updatedAt
author {
id
name
}
}
}
`,
variables: {
input: { id, relation: `api::post.post:${post?.id}`, content: comment.value.content.trim() },
},
};
try {
// send request and return results or errors
let { updateComment, errors } = await sendReq(graphqlURL, { body: JSON.stringify(updateCommentQuery), headers: headersList });
if (errors) throw Error(errors);
// get index of comment to edit
let editIndex = comments.value.data.findIndex(({ id }) => id == updateComment.id);
// remove comment from array at that index
comments.value.data.splice(editIndex, 1);
// add edited comment to top of array
comments.value.data.unshift(updateComment);
// reset comment and action state
resetAction();
} catch (error) {
console.log({ error });
} finally {
isLoading.value = false;
}
}
};
// ...
</script>
In this case, we don’t simply add the updated comment updateComment
to the comments
array. Instead we get the index - editIndex
by matching the id
. Then we remove the current comment from the array and add the updated comment to the top of the array with the unshift()
method.
Finally, to remove a comment we simply use the removeComment
mutation query.
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
<!-- ./components/Comments.vue -->
<script setup>
// ...
// function to remove comment
const removeComment = async (id) => {
// confirm if the user wants to proceed to remove the comment
let confirmRemove = confirm("Are you sure you want to remove this comment?");
// if confirmed
if (confirmRemove) {
isLoading.value = true;
// remove comment mutation
const removeCommentQuery = {
query: `
mutation($input: RemoveComment!) {
removeComment(input: $input) {
id
content
approvalStatus
author {
id
name
}
}
}
`,
variables: {
input: { relation: `api::post.post:${post?.id}`, id },
},
};
try {
// reset comment and action state
let { removeComment, errors } = await sendReq(graphqlURL, { body: JSON.stringify(removeCommentQuery), headers: headersList });
if (errors) throw Error(errors);
// get index of comment to remove
let removeIndex = comments.value.data.findIndex(({ id }) => id == removeComment.id);
// remove comment from array
comments.value.data.splice(removeIndex, 1);
} catch (error) {
console.log({ error });
} finally {
isLoading.value = false;
}
}
};
// ...
</script>
Here, once the comment has been removed, we remove from the comments
array by getting the index - removeIndex
and using the splice()
method to remove it at that index.
Now we can create our template.
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
<template>
<div class="comments">
<ul ref="commentList" class="comment-list" :class="{ 'border-t': comments.data[0] }">
<li v-for="comment in comments?.data" :key="comment.id" class="comment">
<main class="body">
<p class="author">
{{ comment.author?.name }}
</p>
<pre class="content">{{ comment?.content }}</pre>
</main>
<footer class="actions">
<span class="details"> {{ showDateTime(comment?.updatedAt) }} </span>
<div v-if="user?.id == comment.author?.id" class="options">
<button @click="() => handleEdit(comment)">Edit</button> |
<button @click="() => removeComment(comment.id)" class="hover:text-red-500">Remove</button>
</div>
</footer>
</li>
</ul>
<div v-if="user?.id" class="comment-cont">
<ChatIcon class="icon solid" />
<form @submit="handleSubmit" class="comment-form">
<div class="form-control">
<textarea
v-model="comment.content"
name="comment"
id="comment"
cols="30"
rows="1"
class="form-textarea comment-textarea"
placeholder="Leave a comment"
required
>
</textarea>
</div>
<!-- Reset button -->
<button @click="resetAction" type="button" v-show="action == 'edit'" class="cta submit-btn">
<XCircleIcon class="icon solid" />
</button>
<!-- Send Button -->
<button :disabled="isLoading" class="cta submit-btn">
<PaperAirplaneIcon v-if="!isLoading" class="icon solid rotate-90" />
<RefreshIcon v-else class="icon stroke animate-rotate" />
</button>
</form>
</div>
</div>
</template>
<style scoped> /* ... */ </style>
🚨 Take note of the
v-if
's. We’re mostly using the conditionv-if="user?.id"
to display certain elements only when the user is signed in. On the other hand, we use the conditionv-if="user?.id == comment.author?.id"
to render elements only if the signed in user owns that comment.
Back in the <Post />
component, let’s add our new <Comments />
component:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- ./components/Post.vue -->
<template>
<li class="post">
<header class="post-header">
<!-- ... -->
</header>
<ul class="photos">
<!-- ... -->
</ul>
<article class="details">
<h3 class="caption">{{ post?.attributes?.caption }}</h3>
<span class="time">{{ showDateTime(post) }}</span>
</article>
<Comments :post="post" />
</li>
</template>
To use Cloudinary as a media provider, we have to first set up a Cloudinary account. Go to your Cloudinary Console to obtain API keys.
Let’s head back to our Strapi backend and integrate Cloudinary. Create a new .env
file in the root folder of the project.
1
2
3
4
5
#.env
CLOUDINARY_NAME=<cloudinary_name_here>
CLOUDINARY_KEY=<cloudinary_key_here>
CLOUDINARY_SECRET=<cloudinary_secret_here>
CLOUDINARY_FOLDER=strapi-photos-app
Stop the server and install the @strapi/provider-upload-cloudinary package:
# using yarn
yarn add @strapi/provider-upload-cloudinary
# using npm
npm install @strapi/provider-upload-cloudinary --save
Next, configure the plugin in the ./config/plugins.js
file
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ./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: {},
uploadStream: {
folder: env('CLOUDINARY_FOLDER'),
},
delete: {},
},
},
},
//...
});
NOTE: In order to fix the preview issue on the Strapi dashboard where after uploading a photo, it will upload to Cloudinary, but the preview of the photo won’t display on the Strapi admin dashboard, replace
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
27
// config/middlewares.js
module.exports = [
'strapi::errors',
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'connect-src': ["'self'", 'https:'],
'img-src': ["'self'", 'data:', 'blob:', 'dl.airtable.com', 'res.cloudinary.com'],
'media-src': ["'self'", 'data:', 'blob:', 'dl.airtable.com', 'res.cloudinary.com'],
upgradeInsecureRequests: null,
},
},
},
},
'strapi::cors',
'strapi::poweredBy',
'strapi::logger',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];
Awesome. Restart the Strapi app for the changes to take effect.
Back in our frontend however, we need to make a few changes to make sure that the images display correctly. This is because, up until now, the image URLs returned by Strapi were relative URLs (e.g. /uploads/<file_name>.jpeg
) and we had to append this image URL to the Strapi URL (http://localhost:1337/
) in order to load the image.
This won’t be necessary anymore with Cloudinary integrated. We can now replace any instance of :src="strapiURL + photo?.attributes?.url"
with just :src="photo?.attributes?.url"
.
With Cloudinary integrated, we can now easily deploy our Strapi application to Heroku and not worry about losing the images due to Heroku’s storage. We won’t be covering deployment however as it’s beyond the scope of this tutorial.
In this tutorial we’ve managed to build a photo hsaring app with many of its core functionalities. With this we're able to learn and understand how we can setup a Strapi nstance with a Postgres database instead of the default SQLite, add and and manage our content with GraphQL instead of REST and finally integrate Cloudinary for image uploads. On the frontend, we learnt how to setup an SSR enabled application with Nuxt 3 and communicate with our GraphQL backend using the native Fetch API.
With this we can now see how we can build almost anything using Strapi as a Headless CMS.
Here are a few resources you might find useful.
Are you stuck anywhere? Here are links to the code for this project: