Using Nuxt and Supabase for a Multi-User Blogging App

Avatar of Nader Dabit
Nader Dabit on

Nuxt is a JavaScript framework that extends the existing functionality of Vue.js with features like server-side rendering, static page generation, file-based routing, and automatic code splitting among other things.

I’ve been enjoying using frameworks like Nuxt and Next because they offer not only more features, but better performance and a better developer experience than the underlying libraries alone without having to learn a lot of new concepts. Because of this, many developers are starting to default to these frameworks when creating new projects as opposed to their single-page application (SPA)) ancestors that initially paved the way for their success in the first place.

In the spirit of these abstractions, I’m also a big fan of serverless/managed services that do a lot of the heavy lifting of building out back-end features and functionality for things like authentication, file storage, data, compute, and an API layer. Services and tools like Supabase, Firebase, Netlify, AWS Amplify, and Hasura all enable traditionally front-end developers to extend their personal capabilities and skillsets to add these various important pieces of back-end functionality without having to become back-end developers themselves.

In this tutorial, we’ll be building a multi-user app from scratch with Nuxt and Supabase, while pulling in Tailwind CSS for styling.

Why I’ve been liking Supabase

Supabase is an open source alternative to Firebase that lets you create a real-time back-end in minutes. At the time of this writing, Supabase has support for features like file storage, real-time API + Postgres database, authentication, and soon, serverless functions.

Postgres

One of the reasons I like Supabase because is that it’s easy to set up. Plus, it offers Postgres as its data layer.

I’ve been building apps for 10 years. One of the biggest limitations that I’ve encountered in NoSQL Backend-as-a-Service (BaaS) offerings is how tough it is for developers to scale their apps and be successful. With NoSQL, it is much harder to model data, do migrations, and modify data access patterns after you’ve started to build your app. Enabling things like relationships is also much tougher to grok in the NoSQL world.

Supabase leverages Postgres to enable an extremely rich set of performant querying capabilities out of the box without having to write any additional back-end code. Real time is also baked in by default.

Auth

It’s really easy to set up authorization rules on specific tables, enabling authorization and fine grained access controls without a lot of effort.

When you create a project, Supabase automatically gives you a Postgres SQL database, user authentication, and an API endpoint. From there you can easily implement additional features, like real-time subscriptions and file storage.

Multiple authentication providers

Another thing I like about Supabase is the variety of authentication providers that come ready to use with it right out of the box. Supabase enables all of the following types of authentication mechanisms:

  • Ssername and password
  • Magic email link
  • Google
  • Facebook
  • Apple
  • Discord
  • GitHub
  • Twitter
  • Azure
  • GitLab
  • Bitbucket

The app ingredients

Most applications, while having varying characteristics in their implementation details, often leverage a similar set of functionality tied together. These usually are:

  • user authentication
  • client-side identity management
  • routing
  • file storage
  • database
  • API layer
  • API authorization

Understanding how to build a full-stack app that implements all of these features lays the ground for developers to then continue building out many other different types of applications that rely on this same or similar set of functionality. The app that we’re building in this tutorial implements most of these features.

Unauthenticated users can view others posts in a list and then view the post details by clicking and navigating to that individual post. Users can sign up with their email address and receive a magic link to sign in. Once they are signed in, they are able to view links to create and edit their own posts as well. We will also provide a profile view for users to see their user profile and sign out.

Now that we’ve reviewed the app, let’s start building!

Starting our Supabase app

The first thing we’ll need to do is create the Supabase app. Head over to Supabase.io and click Start Your Project. Authenticate and create a new project under the organization that is provided to you in your account.

Give the project a Name and Password and click Create new project. It will take approximately two minutes for your project to spin up.

Creating the table

Once the project is ready, we create the table for our app along with all of the permissions we’ll need. To do so, click on the SQL link in the left-hand menu.

Click on Query-1 under Open queries and paste the following SQL query into the provided text area and click Run:

CREATE TABLE posts (
  id bigint generated by default as identity primary key,
  user_id uuid references auth.users not null,
  user_email text,
  title text,
  content text,
  inserted_at timestamp with time zone default timezone('utc'::text, now()) not null
);

alter table posts enable row level security;

create policy "Individuals can create posts." on posts for
  insert with check (auth.uid() = user_id);

create policy "Individuals can update their own posts." on posts for
  update using (auth.uid() = user_id);

create policy "Individuals can delete their own posts." on posts for
  delete using (auth.uid() = user_id);

create policy "Posts are public." on posts for
  select using (true);

This creates the posts table for the database of our app. It has also enables some row-level permissions on the database:

  • Any user can query for a list of posts or individual posts.
  • Only signed in users can create a post. Authorization rules state that their user ID must match the user ID passed into the arguments.
  • Only the owner of a post can update or delete it.

Now, if we click on the Table editor link, we should see our new table created with the proper schema.

That’s all we need for the Supabase project! We can move on to our local development environment to begin building out the front end with Nuxt.

Project setup

Let’s get started building the front end. Open up a terminal in an empty directory and create the Nuxt app:

yarn create nuxt-app nuxt-supabase

Here, we’re prompted with the following questions:

? Project name: nuxt-supabase
? Programming language: JavaScript
? Package manager: (your preference)
? UI framework: Tailwind CSS
? Nuxt.js modules: n/a
? Linting tools: n/a
? Testing framework: None
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Server (Node.js hosting)
? Development tools: n/a
? What is your GitHub username? (your username)
? Version control system: Git

Once the project has been created, change into the new directory:

cd nuxt-supabase

Configuration and dependencies

Now that the project has been initialized, we need to install some dependencies for both Supabase, as well as Tailwind CSS. We also need to configure the Nuxt project to recognize and use these tools.

Tailwind CSS

Let’s start with Tailwind. Install the Tailwind dependencies using either npm or Yarn:

npm install -D tailwindcss@latest postcss@latest autoprefixer@latest @tailwindcss/typography

Next, run the following command to create a tailwind.config.js file:

npx tailwind init

Next, add a new folder named assets/css to the project directory and a file in it named tailwind.css. Here’s some code we can throw in there to import what we need from Tailwind:

/* assets/css/tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

Next, add the @nuxtjs/tailwindcss module to the buildModules section of the nuxt.config.js file (this may have already been updated by the Tailwind CLI):

buildModules: [
  '@nuxtjs/tailwindcss'
],

Tailwind is now set up and we can begin using the utility classes directly in our HTML! 🎉

Markdown editor and parser

Next, let’s install and configure a Markdown editor and parser that allows users to write blog posts with formatting and rich text editing features. We’re using marked along with the Vue SimpleMDE library to make this happen.

npm install vue-simplemde marked

Next, we need to define a new Vue component to use the new Markdown editor in our HTML. So, create a new plugins folder and add a new file in it named simplemde.js. Here’ the code we need in there to import what we need:

/* plugins/simplemde.js */
import Vue from 'vue'
import VueSimplemde from 'vue-simplemde'
import 'simplemde/dist/simplemde.min.css'
Vue.component('vue-simplemde', VueSimplemde)

Next, open nuxt.config.js and update the css globals so that they include the simplemde CSS as well as the plugins array:

css: [
  'simplemde/dist/simplemde.min.css',
],
plugins: [
  { src: '~plugins/simplemde.js', mode: 'client' },
],

Now, we can use vue-simplemde directly in our HTML any time we’d like to use the component!

Configuring Supabase

The last thing we need to configure is for the Supabase client. This is the API we use to interact with the Supabase back-end for authentication and data access.

First, install the Supabase JavaScript library:

npm install @supabase/supabase-js

Next, let’s create another plugin that injects a $supabase variable into the scope of our app so we can access it any time and anywhere we need it. We need to get the API endpoint and public API key for our project, which we can get from the Supabase dashboard in the Settings tab.

Click the Settings icon in the Supabase menu, then select API to locate the information.

Now let’s create a new client.js file in the plugins folder with the following code in there:

/* plugins/client.js */
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
  "https://yoururl.supabase.co",
  "your-api-key"
)
export default (_, inject) => {
  inject('supabase', supabase)
}

Now we can update the plugins array in nuxt.config.js with the new plugin:

plugins: [
  { src: '~plugins/client.js' },
  { src: '~plugins/simplemde.js', mode: 'client' },
],

That’s the last thing we need to do to set up our project. Mow we can start writing some code!

Creating the layout

Our app needs a good layout component to hold the navigation as well as some basic styling that will be applied to all of the other pages.

To use a layout, Nuxt looks for a layouts directory for a default layout that’s applied to all pages. We can override layouts on a page-by-page basis if we need to customize something specific. We’re sticking to the default layout for everything in this tutorial for the sake of simplicity.

We need that layouts folder, so add it to the project directory and add a default.vue file in it with the following markup for the default layout:

<!-- layouts/default.vue -->
<template>
  <div>
    <nav class="p-6 border-b border-gray-300">
      <NuxtLink to="/" class="mr-6">
        Home
      </NuxtLink>
      <NuxtLink to="/profile" class="mr-6">
        Profile
      </NuxtLink>
      <NuxtLink to="/create-post" class="mr-6" v-if="authenticated">
        Create post
      </NuxtLink>
      <NuxtLink to="/my-posts" class="mr-6" v-if="authenticated">
        My Posts
      </NuxtLink>
    </nav>
    <div class="py-8 px-16">
      <Nuxt />
    </div>
  </div>
</template>
<script>
export default {
  data: () => ({
    authenticated: false,
    authListener: null
  }),
  async mounted() {
    /* When the app loads, check to see if the user is signed in */
    /* also create a listener for when someone signs in or out */
    const { data: authListener } = this.$supabase.auth.onAuthStateChange(
      () => this.checkUser()
    )
    this.authListener = authListener
    this.checkUser()
  },
  methods: {
    async checkUser() {
      const user = await this.$supabase.auth.user()
      if (user) {
        this.authenticated = true 
      } else {
        this.authenticated = false
      }
    }
  },
  beforeUnmount() {
    this.authListener?.unsubscribe()
  }
}
</script>

The layout has two links that are shown by default, and two others that are only displayed if a user is signed in.

To fetch the signed in user at any time (or to see if they are authenticated), we are using the supabase.auth.user() method. If a user is signed in, their profile is returned. If they are not, the return value is null.

The home page

Next, let’s update the home page. When the user opens the app, we want to show a list of posts and allow them to click on and navigate to read the post. If there are no posts, we show them a message instead.

In this component, we’re making our first call to the Supabase back-end to fetch data — in this case, we’re calling an array that contains all posts. See how the Supabase API interacts with your data, which to me, is very intuitive:

/* example of how to fetch data from Supabase */   
const { data: posts, error } = await this.$supabase
  .from('posts')
  .select('*')

Supabase offers filters and modifiers that make it straightforward to implement a rich set of various data access patterns and selection sets of your data. For instance, if we want to update that last query to only query for users with a specific user ID, we could do this:

const { data: posts, error } = await this.$supabase
  .from('posts')
  .select('*')
  .filter('user_id', 'eq', 'some-user-id')

Update the template file for the homepage, pages/index.vue, with the following markup and query for displaying a loop of posts:

<!-- pages/index.vue -->
<template>
  <main>
    <div v-for="post in posts" :key="post.id">
      <NuxtLink key={post.id} :to="`/posts/${post.id}`">
        <div class="cursor-pointer border-b border-gray-300 mt-8 pb-4">
          <h2 class="text-xl font-semibold">{{ post.title }}</h2>
          <p class="text-gray-500 mt-2">Author: {{ post.user_email }}</p>
        </div>
      </NuxtLink>
    </div>
    <h1 v-if="loaded && !posts.length" class="text-2xl">No posts...</h1>
  </main>
</template>
<script>
export default {
  async created() {
    const { data: posts, error } = await this.$supabase
      .from('posts')
      .select('*')
    this.posts = posts
    this.loaded = true
  },
  data() {
    return {
      loaded: false,
      posts: []
    }
  }
}
</script>

User profile

Now let’s create the profile page with a new profile.vue file in the pages with the following code:

<!-- pages/profile.vue -->
<template>
  <main class="m-auto py-20" style="width: 700px">
    <!-- if the user is not signed in, show the sign in form -->
    <div v-if="!profile && !submitted" class="flex flex-col">
      <h2 class="text-2xl">Sign up / sign in</h2>
      <input v-model="email" placeholder="Email" class="border py-2 px-4 rounded mt-4" />
      <button
        @click="signIn"
        class="mt-4 py-4 px-20 w-full bg-blue-500 text-white font-bold"
      >Submit</button>
    </div>
    <!-- if the user is signed in, show them their profile -->
    <div v-if="profile">
      <h2 class="text-xl">Hello, {{ profile.email }}</h2>
      <p class="text-gray-400 my-3">User ID: {{ profile.id }}</p>
      <button
        @click="signOut"
        class="mt-4 py-4 px-20 w-full bg-blue-500 text-white font-bold"
      >Sign Out</button>
    </div>
    <div v-if="submitted">
      <h1 class="text-xl text-center">Please check your email to sign in</h1>
    </div>
  </main>
</template>
<script>
export default {
  data: () => ({
    profile: null,
    submitted: false,
    email: ''
  }),
  methods: {
    async signOut() {
      /* signOut deletes the user's session */
      await this.$supabase.auth.signOut()
      this.profile = null
    },
    async signIn() {
      /* signIn sends the user a magic link */
      const { email } = this
      if (!email) return
      const { error, data } = await this.$supabase.auth.signIn({
        email
      })
      this.submitted = true
    },
  },
  async mounted() {
    /* when the component loads, fetch the user's profile */
    const profile = await this.$supabase.auth.user()
    this.profile = profile
  }
}
</script>

In the template, we have a few different view states:

  1. If the user is not signed in, show them the sign in form.
  2. If the user is signed in, show them their profile information and a sign out button.
  3. If the user has submitted the sign in form, show them a message to check their email.

This app utilizes magic link authentication because of its simplicity. There is no separate process for signing up and signing in. All the user needs to do is submit their email address and they are sent a link to sign in. Once they click on the link, a session is set in their browser by Supabase, and they are redirected to the app.

Once the user is able to sign in, they can create a new post!

Creating a post

Next, let’s create the page with the form that allows users to create and save new posts. That means a new create-post.vue file in the pages directory with some code for the post editor:

<!-- pages/create-post.vue -->
<template>
  <main>
    <div id="editor">
      <h1 class="text-3xl font-semibold tracking-wide mt-6">Create new post</h1>
      <input
        name="title"
        placeholder="Title"
        v-model="post.title"
        class="border-b pb-2 text-lg my-4 focus:outline-none w-full font-light text-gray-500 placeholder-gray-500 y-2"
      />
      <client-only>
        <vue-simplemde v-model="post.content"></vue-simplemde>
      </client-only>
      <button
        type="button"
        class="mb-4 w-full bg-blue-500 text-white font-semibold px-8 py-4"
        @click="createPost"
      >Create Post</button>
    </div>
  </main>
</template>
<script>
export default {
  data() {
    return {
      post: {}
    }
  },
  methods: {
    async createPost() {
      const {title, content} = this.post
      if (!title || !content) return
      const user = this.$supabase.auth.user()
      const { data } = await this.$supabase
        .from('posts')
        .insert([
            { title, content, user_id: user.id, user_email: user.email }
        ])
        .single()
      this.$router.push(`/posts/${data.id}`)
    }
  }
}
</script>

This code is using the vue-simplemde component we registered as a plugin in an earlier step! It is wrapped in a client-only component that renders the component only on client-side — vue-simplemde is a client-side-only plugin so it’s unnecessary for it to be on the server.

The createPost function creates a new post in the Supabase database, and then redirects us to view the individual post in a page we have yet to create. Let’s create it now!

Dynamic routes for viewing individual posts

To create a dynamic route in Nuxt, we need to add an underscore before .vue in the file name (or before the name of the directory).

If a user navigates to a page, say /posts/123. We want to use post ID 123 to fetch the data for the post. In the app, we can then access the route parameters in the page by referencing route.params.

So, let’s add yet another new folder, pages/posts, with a new file named in it, _id.vue:

<!-- pages/posts/_id.vue -->
<template>
  <main>
    <div>
      <h1 class="text-5xl mt-4 font-semibold tracking-wide">{{ post.title }}</h1>
      <p class="text-sm font-light my-4">by {{ post.user_email }}</p>
      <div class="mt-8 prose" >
        <div v-html="compiledMarkdown"></div>
      </div>
    </div>
  </main>
</template>
<script>
import marked from 'marked'
export default {
  computed: {
    compiledMarkdown: function () {
      return marked(this.post.content, { sanitize: true })
    }
  },
  async asyncData({ route, $supabase }) {
    /* use the ID from the route parameter to fetch the post */
    const { data: post } = await $supabase
      .from('posts')
      .select()
      .filter('id', 'eq', route.params.id)
      .single()
    return {
      post
    }
  }
}
</script>

When the page is loaded, the route parameter is used to fetch the post metadata.

Managing posts

The last piece of functionality we want is to allow users the ability to edit and delete their own posts, but in order to do that, we should provide them with a page that displays their own posts instead of everyone’s.

That’s right, we need another new file, this time called my-posts.vue, in the pages directory. It’s going to fetches only the posts of the current authenticated user:

<!-- pages/my-posts.vue -->
<template>
  <main>
    <div v-for="post in posts" :key="post.id">
      <div class="cursor-pointer border-b border-gray-300 mt-8 pb-4">
        <h2 class="text-xl font-semibold">{{ post.title }}</h2>
        <p class="text-gray-500 mt-2">Author: {{ post.user_email }}</p>
        <NuxtLink :to="`/edit-post?id=${post.id}`" class="text-sm mr-4 text-blue-500">Edit Post</NuxtLink>
        <NuxtLink :to="`/posts/${post.id}`" class="text-sm mr-4 text-blue-500">View Post</NuxtLink>
        <button
          class="text-sm mr-4 text-red-500"
          @click="deletePost(post.id)"
        >Delete Post</button>
      </div>
    </div>
    <h1 v-if="loaded && !posts.length" class="text-2xl">No posts...</h1>
  </main>
</template>
<script>
export default {
  async created() {
    this.fetchPosts()
  },
  data() {
    return {
      posts: [],
      loaded: false
    }
  },
  methods: {
    async fetchPosts() {
      const user = this.$supabase.auth.user()
      if (!user) return
      /* fetch only the posts for the signed in user */
      const { data: posts, error } = await this.$supabase
        .from('posts')
        .select('*')
        .filter('user_id', 'eq', user.id)
      this.posts = posts
      this.loaded = true
    },
    async deletePost(id) {
      await this.$supabase
        .from('posts')
        .delete()
        .match({ id })
      this.fetchPosts()
    }
  }
}
</script>

The query in this page for fetching the posts uses a filter, passing in the user ID of the signed in user. There is also a button for deleting a post and a button for editing a post. If a post is deleted, we then refetch the posts to update the UI. If a user wants to edit a post, we redirect them to the edit-post.vue page that we’re creating next.

Editing a post

The last page we want to create allows users to edit a post. This page is very similar to the create-post.vue page, the main difference being we fetch the post using the id retrieved from the route parameter. So, create that file and drop it into the pages folder with this code:

<!-- pages/edit-post.vue -->
<template>
  <main>
    <div id="editor">
      <h1 class="text-3xl font-semibold tracking-wide mt-6">Create new post</h1>
      <input
        name="title"
        placeholder="Title"
        v-model="post.title"
        class="border-b pb-2 text-lg my-4 focus:outline-none w-full font-light text-gray-500 placeholder-gray-500 y-2"
      />
      <client-only>
        <vue-simplemde v-model="post.content"></vue-simplemde>
      </client-only>
      <button
        type="button"
        class="mb-4 w-full bg-blue-500 text-white font-semibold px-8 py-4"
        @click="editPost"
      >Edit Post</button>
    </div>
  </main>
</template>
<script>
export default {
  async created() {
    /* when the page loads, fetch the post using the route id parameter */
    const id = this.$route.query.id
    const { data: post } = await this.$supabase
      .from('posts')
      .select()
      .filter('id', 'eq', id)
      .single()
    if (!post) this.$router.push('/')
    this.post = post
  },
  data() {
    return {
      post: {}
    }
  },
  methods: {
    async editPost() {
      /* when the user edits a post, redirect them back to their posts */
      const { title, content } = this.post
      if (!title || !content) return
      await this.$supabase
        .from('posts')
        .update([
            { title, content }
        ])
        .match({ id: this.post.id })
      this.$router.push('/my-posts')
    }
  }
}
</script>

Testing it out

That’s all of the code, we should be able to test it out! We can test locally with the following command:

npm run dev

When the app loads, sign up for a new account using the magic link enabled in the profile page. Once you’ve signed up, test everything out by adding, editing, and deleting posts.

Wrapping up

Pretty nice, right? This is the sort of ease and simplicity I was talking about at the beginning of this tutorial. We spun up a new app with Supabase, and with a few dependencies, a little configuration, and a handful of templates, we made a fully-functional app that lets folks create and manage blog post — complete with a back end that supports authentication, identity management, and routing!

What we have is baseline functionality, but you can probably see what a high ceiling there is to do more here. And I hope you do! With all the right ingredients in place, you can take what we made and extend it with your own enhancements and styling.