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>
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)
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
}
]
});
}
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>
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>
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>
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>
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()
}
}
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>
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>
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
}
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 + "'")
}
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>
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>
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)
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.
Thanks, Tony! If I can help with anything, let me know.
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
Hmmm... I'm wondering if it's Vue Simple Markdown. Are you sure that is installed correctly?
I am also having the same issue currently have you found out any way?
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
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
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!