DEV Community

Mark Johnson 👔
Mark Johnson 👔

Posted on • Edited on

Building a Portfolio Site With Vue, Bulma, and Airtable

It's been about 3 years since I left full-stack bootcamp, and the first time that I've needed to put together a portfolio site. I wanted to make something that was relatively simple, that could be easily updated, and that would be easy to extend and improve as time goes on.

Is this tutorial perfect? Heck no! It's the first tutorial I've written, and I've been learning Vue self-guided, so some parts I'm sure could be better (let me know in the comments if you would have done something differently). That said, I know this could be helpful to someone out there!

You can see the code for my entire portfolio on github, created from this starting point, here: https://github.com/markjohnson303/portfolio

A finished example is at hellomark.dev, but it is a work in progress and you may see some things that are different from what is described here.

The tools

Vue.js: I chose Vue for this project because it's the framework that I'm most familiar with. Some might say that it's overkill for a small project like this, and for you, it might be. It works well for me because it's comfortable and flexible enough for what I might do with it in the future. It's also what I'm hoping to use in my next role, so why not!

Bulma: I haven't used Bulma before this project, but I wanted something that would allow me to get the site up quickly, then improve the styling easily over time. Bulma is simple to learn, but easy to build upon. It doesn't have the world's largest library of components, but what it does have is solidly built.

Airtable: I've been wanting to use Airtable in a project for a while now. According to Airtable, it's "Part spreadsheet, part database", and was made to be flexible for all sorts of uses. I used it here as a CMS because it's really easy to use and has an awesome API with great documentation (that's customized to your database). Now that it's set up, I can use it across the site for all sorts of fun things. And it's free!

Getting started

The first thing you need to do is set up your Vue project. We're going to use the Vue CLI to scaffold the project. Make sure you have vue and the Vue CLI installed:

$ npm install -g vue
$ npm install -g @vue/cli

Then create your project:
$ vue create portfolio

And fire it up:
$ npm run serve

Vue CLI gives you a very helpful starting point with a lot of the files and folders that we need. We're going to build off of this.

Let's also add our CSS framework, Bulma, now.
$ npm install --s bulma

And add the Sass stylesheet to our App.vue file

<style lang="sass">
@import "~bulma/bulma.sass"
</style>
Enter fullscreen mode Exit fullscreen mode

You can make any adjustments to the Bulma defaults here, above the import.

We'll install Axios (for working with our Airtable API)
$ npm install --s axios

We need VueSimpleMarkdownso we can compose and style our posts with markdown.

$ npm install -s vue-simple-markdown

And in main.js we'll put:

import VueSimpleMarkdown from 'vue-simple-markdown'
import 'vue-simple-markdown/dist/vue-simple-markdown.css'

Vue.use(VueSimpleMarkdown)
Enter fullscreen mode Exit fullscreen mode

Setting up our routes

We're going to have 5 main routes for this site: About, Contact, Home, Project, and Projects. Let's set those up in In src/router.js.

import Vue from "vue";
import Router from "vue-router";
import Home from "./views/Home.vue";
import About from "./views/About.vue";
import Contacts from "./views/Contact.vue";
import Projects from "./views/Projects.vue"
import Project from "./views/Project.vue"

Vue.use(Router);

export default new Router({
  mode: "history",
  base: process.env.BASE_URL,
  routes: [
    {
      path: "/",
      name: "home",
      component: Home
    },
    {
      path: "/about",
      name: "about",
      component: About
    },
    {
      path: "/contact",
      name: "contact",
      component: Contact
    },
    {
      path: "/projects",
      name: "projects",
      component: Projects
    },
    {
      path: "/project/:slug",
      name: "project",
      component: Project
    }
  ]
});
}
Enter fullscreen mode Exit fullscreen mode

The odd one out is path: "/project/:slug". We're going to use this route to display a single project from Airtable based on the slug later.

We're also going to make an empty component for each one in src/views, here's the empty Contact.vue for example. We'll fill these in later.

<template>
  <div>

  </div>
</template>

<script>

export default {
  name: "contact",
};

</script>
Enter fullscreen mode Exit fullscreen mode

Adding header and footer

Let's add our header (with navigation) and footer, a little bit of styling, and a touch of Vue magic to make it work on mobile. We'll put this code in App.vue so that it will show up on every view.

<template>
  <div id="app">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <nav class="navbar" role="navigation" aria-label="main navigation">
      <div class="navbar-brand">
        <router-link class="navbar-item" to="/">
          <img src="./assets/name-mark.jpg" width="112" height="28">
        </router-link>

        <a role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample" :class="{ 'is-active': showNav }" @click="showNav = !showNav">
          <span aria-hidden="true"></span>
          <span aria-hidden="true"></span>
          <span aria-hidden="true"></span>
        </a>
      </div>

      <div id="navbarBasicExample" class="navbar-menu" :class="{ 'is-active': showNav }">
        <div class="navbar-start">

        </div>

        <div class="navbar-end">
          <router-link to="/" class="navbar-item">
            Home
          </router-link>
          <router-link to="/about" class="navbar-item">
           About
          </router-link>
          <router-link to="/projects" class="navbar-item">
           Projects
          </router-link>
          <router-link to="/contact" class="navbar-item">
           Contact
          </router-link>
       </div>
     </div>
   </nav>
   <router-view />
   <footer class="footer">
    <div class="content has-text-centered">
      <p>
        Built by Mark Johnson with Vue.js, Bulma, and Airtable.
      </p>
    </div>
  </footer>
</div>
</template>

<script>
  export default {
    name: "App",
    data() {
      return{
        showNav: false
      }
    },
  };
</script>

<style type="text/css">
#app {
  min-height: 100vh;
  overflow: hidden;
  display: block;
  position: relative;
  padding-bottom: 168px; /* height of your footer */
}
footer {
 position: absolute;
 bottom: 0;
 width: 100%;
}
</style>

<style lang="sass">
@import "~bulma/bulma.sass"
</style>

Enter fullscreen mode Exit fullscreen mode

Static pages

The About, Home, and Contact pages don't have any dynamic content on them, so feel free to add whatever content you like. Here's what I did with the homepage, for example. I kept it very simple here, but you can embellish it however you like.

<template>
  <div>
    <div class="hero is-cover is-relative is-fullheight-with-navbar is-primary">
        <div class="hero-body">
            <div class="container">
                <h1 class="title is-1">Hello, I'm Mark.</h1>
                <h2 class="subtitle is-3">A customer focused, entrepreneurially minded web developer.</h2>
            </div>
        </div>
    </div>
  </div>
</template>

<script>

export default {
  name: "home",
};
</script>
Enter fullscreen mode Exit fullscreen mode

Building the Projects page

The projects page is where things start to get interesting. We're going to be pulling our information in from Airtable and displaying a summary card for each project.

Set up Airtable

Create a new base on Airtable called "Projects". Create the following fields: "Title" (single line text), "slug" (single line text), "Body"(long text), "Image"(attachment), "Date Published" (date), "Published" (checkbox), "Excerpt" (single line text).

Voila! You have a simple CMS! Fill it in with a few rows of dummy data so you can see what you're working with. Make sure the "slug" is unique! We'll use this to build our url in a later step.

Build a card for displaying projects

We're going to create a component to display our project summary. It's also reusable so that you could create a blog with the same thing later!

In src/components create a new file called PostCard.vue. Fill it in as follows:

<template>
    <div class="post-card">
        <div class="card">
            <div class="card-image">
                <figure class="image is-square">
                    <img :src="image" alt="Placeholder image">
                </figure>
            </div>
            <div class="card-content">
                <div class="media">
                    <div class="media-content">
                        <p class="title is-4">{{title}}</p>
                        <p class="subtitle is-6">{{date}}</p>
                    </div>
                </div>
                <div class="content">
                    <p>{{snippet}}</p>
                    <router-link :to="'/project/'+slug" class="button is-fullwidth">View Project</router-link>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
    export default {
        name: "PostCard",
        props: {
            title: String,
            date: String,
            snippet: String,
            image: String,
            slug: String
        }
    };
</script>

Enter fullscreen mode Exit fullscreen mode

We're going to bring in the props from the Projects page after we get the projects from Airtable's API. They'll fill in the template with content and a link to the full project view.

Creating a service to bring in projects

Let's set up the connection to the Airtable API. Make a directory at src/services, and in it, put a file called ProjectsService.js.

In the projects service, we're going to use Axios to call the Airtable API and get all of the projects. Your file should look like this:

import axios from 'axios'

const Axios = axios.create({
  baseURL: "https://api.airtable.com/v0/[YOUR APP ID]/Projects"
});

Axios.defaults.headers.common = {'Authorization': `Bearer ` + process.env.VUE_APP_AIRTABLEKEY}

export default{
  getProjects() {
    return Axios.get()
  }
}
Enter fullscreen mode Exit fullscreen mode

You'll need to set up the baseURL with the ID of your Airtable base. Find it in the URL from Airtable.

You'll also need to add your API key from Airtable. I put mine in a file called .env.local in the root directory of the Vue project. This file is listed in .gitignore so you don't risk exposing it. All that's in the file is this: VUE_APP_AIRTABLEKEY=[YOUR API KEY].

Finally, we're exporting a function that calls get on the API endpoint in the baseURL.

Displaying the project cards

We're going to display the cards for our projects on the Projects view. In your Projects.vue template:

<template>
    <div>
        <section class="hero is-medium is-primary is-bold">
            <div class="hero-body">
                <div class="container">
                    <h1 class="title is-2">
                        Projects that I have built
                    </h1>
                </div>
            </div>
        </section>
        <section class="section">
            <div class="container is-fluid">
                <div class="columns is-multiline">
                        <div class="column is-one-third" v-for="project in projects">
                            <post-card v-bind="project"></post-card>
                        </div>              
                </div>
            </div>
        </section>
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

The thing to note here is v-for="project in projects" where we're creating an instance of post-card for every project and passing in the project details with v-bind.

The script section of the template looks like this:

<script>
    import ProjectsService from '@/services/ProjectsService'
    import PostCard from '@/components/PostCard'
    export default {
        name: "projects",
        components: {
            PostCard
        },
        data() {
            return{
                airtableResponse: []
            }
        },
        mounted: function () {
            let self = this
            async function getProjects() {
                try{
                    const response = await ProjectsService.getProjects()
                    console.log(response)
                    self.airtableResponse = response.data.records

                }catch(err){
                    console.log(err)
                }
                }
              getProjects()         
        },
        computed: {
            projects(){
                let self = this
                let projectList = []
                for (var i = 0; i < self.airtableResponse.length; i++) {
                    if (self.airtableResponse[i].fields.Published){
                        let project = {
                            title: self.airtableResponse[i].fields.Title,
                            date: self.airtableResponse[i].fields["Date Published"],
                            snippet: self.airtableResponse[i].fields.Excerpt,
                            image: self.airtableResponse[i].fields.Image[0].url,
                            slug: self.airtableResponse[i].fields.slug
                        }
                        projectList.push(project)
                    }
                }
                return projectList
            }
        }
    };
</script>
Enter fullscreen mode Exit fullscreen mode

From the top, here's what happening:

  • Import the ProjectsService and PostCard we created earlier
  • Set up a variable to hold the response from Airtable
  • When the component is mounted, call the endpoint we set up in the service.
  • Put the portion of the response we need in the airtableResponse variable.
  • Create computed property with an array called "Projects" with one object for each published item in the Airtable response and the variables we need for each card.

If all went well, you should be able to load /projects and see each project you created in Airtable in a grid

Crete the project view

Remember this bit of code from our router setup?

{
 path: "/project/:slug",
 name: "project",
 component: Project
}
Enter fullscreen mode Exit fullscreen mode

It's going to make it so we can access the slug in our Project component and pass it into our Projects Service so we can retrieve all of the information for the item with that slug Airtable.

Let's add a call for that in ProjectsService.js:

getProject(slug) {
  return Axios.get("?filterByFormula={Slug}='" + slug + "'")
}
Enter fullscreen mode Exit fullscreen mode

We're using the features of Airtable's API here to search for the post that contains the slug and return it.

Now let's create our Project view template:

<template>
    <div>
        <section class="hero is-medium is-primary is-bold">
            <div class="hero-body">
                <div class="container">
                    <h1 class="title is-2">
                        {{project.title}}
                    </h1>
                    <h2 class="subtitle is-4">
                        {{project.snippet}}
                    </h2>
                </div>
            </div>
        </section>
        <section class="section">
            <div class="container is-fluid">
                <div class="columns">
                    <div class="column is-two-thirds">
                        <vue-simple-markdown :source="project.body"></vue-simple-markdown>
                    </div>
                    <div class="column is-one-third">
                        <div class="columns is-multiline">
                            <div class="column is-full" v-for="image in project.images">
                                <img :src="image.url"/>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </section>
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

This template is using the VueSimpleMarkdown plugin that we installed earlier. That means you can use markdown in the body field on Airtable to style your project. We're displaying the body in a column on the left, and all of the images from the item on the right.

Finally, the script section of the project component:

<script>
    import ProjectsService from '@/services/ProjectsService'
    import PostCard from '@/components/PostCard'
    export default {
        name: "project",
        components: {
            PostCard
        },
        data() {
            return{
                airtableResponse: []
            }
        },
        mounted: function () {
            let self = this
            console.log("here 1")
            async function getProject() {
                try{
                    const response = await ProjectsService.getProject(self.$route.params.slug)
                    console.log(response)
                    self.airtableResponse = response.data.records

                }catch(err){
                    console.log(err)
                }
            }
            getProject()            
        },
        computed: {
            project(){
                let self = this
                if (self.airtableResponse[0]){
                    let thisProject = {
                        title: self.airtableResponse[0].fields.Title,
                        snippet: self.airtableResponse[0].fields.Excerpt,
                        images: self.airtableResponse[0].fields.Image,
                        body: self.airtableResponse[0].fields.Body
                    }
                    return thisProject
                }
            }
        }
    };
</script>
Enter fullscreen mode Exit fullscreen mode

Similar to the Projects view, but this time we're passing the slug into the getProject call. We should only get one response if the slug is unique.

Go to /projects/[your-slug] to see your project live!

Conclusion

Whew. That was a lot! Now that we're done, we have a simple CMS displaying live data on a site built in Vue and styled with Bulma. Pretty cool!

I'm going to be playing with the styling, adding some new features, and cleaning things up, so keep an eye on hellomark.dev to see what's next!

Let me know what you thought of this tutorial and any questions you have!

Top comments (8)

Collapse
 
pantsme profile image
Antonio Savage

Awesome rundown. You really made this approachable and useful for someone who hasn't done a project like this before and wants to follow along. I will be trying it out tomorrow and I'll let you know how it turns out. Thank you.

Collapse
 
markjohnson303 profile image
Mark Johnson 👔

Thanks, Tony! If I can help with anything, let me know.

Collapse
 
atrixx profile image
atrixx

Hi,
Nice, I tried and I get
[Vue warn]: Unknown custom element: - did you register the component correctly? For recursive components, make sure to provide the "name" option.
found in
---> at src/App.vue

any idea?
Thanks

Collapse
 
markjohnson303 profile image
Mark Johnson 👔

Hmmm... I'm wondering if it's Vue Simple Markdown. Are you sure that is installed correctly?

Collapse
 
csleong98 profile image
Chee Seng Leong

I am also having the same issue currently have you found out any way?

Collapse
 
kurikania profile image
Ekaterina

Hi! I came across your post and tried to repeat your projects step by step. Well, it yells at me
"57:11 error Expected to return a value in "project" computed property vue/return-in-computed-property"
What could be a problem?

Anyway thanks for this post, you are doing a great job

Collapse
 
kidaqrus profile image
ahmed sule

hello sir,
for a beginner, your explanation was not thorough enough especially(airtable) not all your audiences are intermediate or advanced developers so i think you should explain the concept better so everybody could understand thanks

Collapse
 
dizid profile image
Marc de Ruyter

Hi Mark,

Using #vue-simple-markdown# is genius and will be so usefull in many projects!
Also, using Airtable is very nice (and was on my TODO list for some time now)
because it saves us from having the code all those pesky entry forms.
Thanks.

Everything works fine, but there still is this error popping up:
TypeError: Cannot read property 'title' of undefined"
found in ---> at src/views/Project.vue

After some Googling, it seems like 'title' is not there at render time.
Probably because it is fetched with an asynchronious function.
I think has to be changed in the data() part, but not sure what.

Nice tutorial though!