How to Build a Photo Sharing App with Nuxt 3, GraphQL, Cloudinary, Postgres and Strapi

In this tutorial we’ll build an SSR application with Nuxt and integrate GraphQL, Postgres and Cloudinary with Strapi

Strapi
Strapi
36 min readJul 26, 2022

--

Author: Miracle Onyenma

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.

Overview

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.

Goals

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.

An Overview of Headless CMS, Strapi & Postgres

Let’s take a quick look at these concepts and technologies that are at the core of our backend.

Headless CMS

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

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).

Postgres

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.

Prerequisites

To follow this tutorial, you’ll need to have a few things in place, including:

  • Basic JavaScript knowledge,
  • Node.js (I’ll be using v16.13.0), and
  • A code editor. I’ll be using VScode; you can get it from the official website.

Step 1: Set up Backend with Strapi and Postgres

First, we’ll create a new Strapi app. To do this:

  • Navigate to the directory of your choice and run:
  • Next, Choose the installation type:

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:
  • Then, enter the password for your postgres superuser

Configure Postgres in Strapi

To use PostgreSQL in Strapi, we’ll add some configurations in our ./config/database.js file.

  • Open the ./config/database.js and paste the below code in the file:
  • In the connection object, we set the client to postgres. This client is the PostgreSQL database client to create the connection to the DB.
  • The host is the hostname of the PostgreSQL server we set it to localhost.
  • The port is set to 5432, and this is the default port of the PostgreSQL server.
  • The database is set to the photosdb, and this is the name of the database we created in the PostgreSQL server.
  • The password is the password of our PostgreSQL server.
  • The username is the username of our PostgreSQL. It is set to Postgres because it is the username of our PostgreSQL server.
  • The 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.

  • Now, start Strapi.

N/B: If you encounter an error: Cannot find module 'pg', install pg . Run:

Once the app has started, it should open the admin registration page at http://localhost:1337. Proceed to create an admin account.

Set up GraphQL with Strapi

To get started with GraphQL in our Strapi backend, we’ll install the GraphQL plugin first. To do that:

  • Stop the server and run the following command:
  • Then, start the app and open your browser at http://localhost:1337/graphql. We’ll see the interface (GraphQL Playground) that will help us write GraphQL query to explore our data.

Create Post Content Type

Let’s create the content type for our posts.

  • Navigate to CONTENT-TYPE BUILDER > COLLECTION TYPES > CREATE NEW COLLECTION TYPE.
  • 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:

Collection type structure for post
  • Click on SAVE and wait for the server to restart.

Set up Strapi Comments Plugin

Finally, we have to install the Strapi comments plugin.

  • Stop the server and run:
  • Next, in order to properly add the types and access our comments actions with GraphQL, we have to create a plugin config for comments.

You can see more information on configuration from the Strapi Comments plugin docs.

  • We now have to rebuild and start our app; run:

When we open our admin dashboard, the Comments plugin should appear in the Plugins section of Strapi sidebar.

Comments plugin added
  • Next, we’ll configure our Comments plugin. Navigate to SETTINGS > COMMENTS PLUGIN > CONFIGURATION then confgure the following settings:
  • 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.

Configure Permissions for Public and Authenticated Users

By default, we won’t be able to access any API from Strapi unless we set the permissions.

  • To set the permissions, navigate to SETTINGS > USERS & PERMISSIONS PLUGIN > ROLES.
  • Go to PUBLIC and enable the following actions for
  • Posts
  • find
  • findOne
Enable actions for posts under Public
  • Comments
  • findAllFlat
  • findAllInHierarchy
Enable actions for comments under Public
  • Users-permissions
  • ✅ User > findOne
  • ✅ User > find
Enable actions for user-permissions under public

Next, to set permissions for authenticated users, go to AUTHENTICATED and enable the following actions for

  • Posts — Enable all ✅
Enable all actions for posts under Authenticated
  • Comments — Enable all ✅
Enable all actions for comments under Authenticated
  • Users-permissions
  • ✅ User > count
  • ✅ User > findOne
  • ✅ User > find
Enable permissions for user-permissions under Authenticated

Step 2: Setting up the Frontend

In the second stage, we are going to set up our frontend.

  • To create our Nuxt 3 frontend, run:
  • Next, navigate into the newly-created project folder and install.

Add Tailwind CSS

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.

  • Run:
  • Add it to the modules section in nuxt.config.ts:
  • Create tailwind.config.js by running:
  • Add Tailwind form plugin to tailwind.config.js
  • Next, let’s create our /.assets/css/main.css file:
  • In the nuxt.config.ts file, enter the following:

Add Heroicons

We’ll use Heroicons for our icon library. To get started, install the Vue library:

Step 3: User Registration and Authentication

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.

Configure Runtime Config

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,

Click here to view the code on GitHub.

This way, we can have access to these URLs throughout our application.

Create Application State

Nuxt 3 provides useState composable to create a reactive and SSR-friendly shared state across components.

  • Create a new file ./composables/state.js and enter the following:

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.

  • Create a new file ./composables/sendReq.js

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.

Create Default Layout

Now, let’s proceed to create our authentication page.

  • First, create a default layout for our application. Create a new layout file layouts/default.vue

View code on GitHub

  • Let’s create the <SiteHeader/> component. In a new file components/SiteHeader.vue enter the following:

View complete code on GitHub

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.

  • Next, we have to configure our app to use layouts. In ./app.vue:

View code on GitHub

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:

View code on GitHub

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

Create Authentication Pages

  • First, we’ll create the register page at ./pages/auth/register.vue

View code on GitHub

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:

Next, we’ll create the sign in page.

  • Create a new file ./pages/auth/sign-in.vue

View code on GitHub

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.

View code on GitHub

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.

Authentication in the application

Awesome.

Step 4: Fetch and Display Posts

Let’s hop back into our admin dashboard and create a new entry in the Post collection type with our new user.

Create new user entry

Click on SAVE and PUBLISH.

Create Post Component

We’ll have to create a Post component which will be used to display a post.

  • Create a new file ./components/Post.vue

View code on GitHub

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.

Fetch Posts with AsyncData

Back in our ./pages/index.vue page,

View Code on GitHub

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>

With that, we should have our first post displayed like this:

A Sample Screenshot

Next, we’ll create our Editor component to upload pictures and create posts.

Add Pagination

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.

  • Create a new file, ./components/Pagination.vue

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:

Next, we’ll work on the Editor component to create posts.

Step 5: 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:

  • First, upload images and obtain the uploaded image IDs
  • Then, create the post with the caption and array of uploaded image IDs

To do this:

  • Create a new file ./components/Editor.vue.

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:

  • Initialize a new FormData() and append the uploadMultipleMutation query to it with the operations name.
  • Create a map using a for loop with each file from the images array state mapped to it’s index, the map should end up like this:

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.

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.

Step 6: 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.

Add Current Post State

First, let’s add the state that holds the current post to be edited. In our ./composables/state.js file:

Next, we have to add the options to edit and delete a post to the component. We must also make sure that these options only appear if the signed in user owns that post.

Add Edit and Delete Actions in Post Component

In our ./components/Post.vue template, we render the options dropdown only when the user.id matches.

In the <script>, let’s initialize the current post state and also create the getUserID(), editPost() and deletePost() functions.

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.
A Sample Screenshot

Now, we have to be able to handle the edit post action from the <Editor /> component.

Handle Edit Post Action from 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.

In our <script>, we’ll first initialize the state variables

Next, we create the required functions:

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:

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 ids. 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 file input so that the form will be able to submit even if the user did not select any new files.

A Sample Screenshot

Step 7: Add Comment Functionality

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:

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 data
  • action - Which will determine if a comment is to be created or edited when the user clicks the submit button.
  • isLoading - Component loading state

We 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.

Get Comments

To get comments, we’ll need to use the findAllFlat query provided by the Strapi Comments plugin for fetching all comments.

Create Comment

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.

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.

Edit Comment

This is pretty similar to how we created a comment. Here, we’ll be using the updateComment mutation query.

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.

Remove Comment

Finally, to remove a comment we simply use the removeComment mutation query.

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.

The

🚨 Take note of the v-if's. We’re mostly using the condition v-if="user?.id" to display certain elements only when the user is signed in. On the other hand, we use the condition v-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:

A Sample Screenshot

Step 8: Integrating Cloudinary

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.

A Sample Screenshot

Let’s head back to our Strapi backend and integrate Cloudinary. Create a new .env file in the root folder of the project.

Stop the server and install the @strapi/provider-upload-cloudinary package:

Next, configure the plugin in the ./config/plugins.js file

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.

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.

Conclusion

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.

Further Reading & Resources

Here are a few resources you might find useful.

Code

Are you stuck anywhere? Here are links to the code for this project:

Reading

--

--

Strapi
Strapi

The open source Headless CMS Front-End Developers love.