03/31/2020
Create a blazingly fast static SaaS marketing page with Vuepress and TailwindCSS
tl/dr: I migrated our marketing site from Wordpress to Vuepress. I learned a lot along the way. This is the post I would have loved to read myself when I was at the beginning of the project. Its not meant to be a step-by-step guide but show you specific solutions for some issues I faced along the way.
But be careful: I am not a programmer, just a guy who can help himself with lots of StackOverflow research. So what you will find below is definitly not best-practice and leaves room for improvement / refactoring. It works for me. YMMV.
- Why using Vuepress?
- Why using TailwindCSS and TailwindUI
- Creating a custom theme
- Creating custom components
- Internationalization (i18n)
- Search-engine-optimization (SEO)
- Migration from Wordpress
- Other nice features
- Issues along the way
- Other helpful resources
- Wrap-up
Why using Vuepress?
At AMALYTIX we wanted to rebuild our marketing site mainly based on Wordpress because the existing design was a bit outdated (and looked "Wordpressy" if you know what I mean) and the site was not really fast looking at the Lighthouse scores for page speed.
Using Wordpress was great at the time because the following things are quite easy to achieve:
- Setting up a new Wordpress sitze is really quick
- Nice design, you just buy a theme with Visual Composer and off you go
- Internationalization (i18n) using a plugin like WPML
- Caching using another plugin
- There is basically a plugin for everythig in Wordpress
- Built-in CMS which is easy to use for normal people
Wordpress has a strong ecosystem but all the plugins and themes have a major impact on the overall performance.
The goal was to achieve the following:
- The new site should be fast
- The new site should allow to improve our efforts on SEO
- The new site should allow us to easily implement more interactive features
So I looked for alternatives and I did not have to look far: We were already using Vuepress for our help page and this worked great. We used Vuepress default theme which worked great for us. I played around with custom themes and its easy to get up and running so I tried how far I could get with it. Spoiler: We finally made the switch and your are looking at the new site in this very moment.
Why using TailwindCSS and TailwindUI
At the same time Adam Wathan and Steve Schoger released TailwindUI - a component library based on TailwindCSS - which was great so I decided to buy it right from the start as both designing and implementing this in HTML / CSS for both desktop and mobile is not my strength. The component library is already great and helps you with things like feature or pricing pages. Its easy to come up with something more unique based on the many examples they already have included in their collection. Hat tip to both of you, I could not have done this without you!
Here is my tailwind.config.js
file for reference:
module.exports = {
important: true, // Components in Markdown require this
theme: {
extend: {
fontFamily: {
'sans': ['-apple-system', 'BlinkMacSystemFont'],
'serif': ['Georgia', 'Cambria'],
'mono': ['SFMono-Regular', 'Menlo'],
'display': ['Oswald'],
'body': ['Open Sans'],
},
}
},
variants: {},
plugins: [
require('@tailwindcss/ui')
]
}
Creating a custom theme
Creating a custom theme is easy. Check out the Vuepress documentation to get started. Here is what my theme directory looks like:
The Layout.vue
file is manadatory the rest is optional.
This is what my Layout.vue
file contains:
<template>
<div class="theme-container">
<div>
<component :is="layout"></component>
</div>
</div>
</template>
<script>
import HomeLayout from '../layouts/HomeLayout.vue';
export default {
components: { HomeLayout },
computed: {
layout() {
return this.$page.frontmatter.layout || 'HomeLayout'
}
},
}
</script>
It basically includes all other layouts. In each README.md page the layout is defined by the layout
variable. E.g. in this post its set to:
layout: blog-post-layout
This ensures that the blog-post-layout.vue
layout is loaded.
And here is how this blog-post-layout.vue
file looks like:
<template>
<div>
<Nav />
<main class="mt-8">
<div class="max-w-4xl px-5 pb-8 mx-auto sm:px-6 lg:px-8">
<div v-if="$page.frontmatter.coverImage" class="py-16">
<img
:src="'/blog/' + $page.frontmatter.coverImage"
:alt="$page.frontmatter.title"
class="max-h-64"
/>
</div>
<article>
<p
v-if="$page.frontmatter.date"
class="text-gray-400"
>{{ formatDate($page.frontmatter.date)}}</p>
<h1
class="pb-4 text-lg font-bold tracking-tight text-green-500 uppercase lg:text-4xl"
>{{$page.frontmatter.title}}</h1>
<span v-for="tag in $page.frontmatter.tags" class>
<span
class="inline-flex items-center px-3 py-0.5 rounded-full text-sm font-medium leading-5 bg-green-100 text-green-800 mr-4"
>{{tag}}</span>
</span>
<div class="mt-8 markdown">
<transition name="fade">
<Content />
</transition>
</div>
</article>
</div>
</main>
<PostsRelated />
<CTA1 />
<Footer />
<CookieConsent />
<JsonLdArticle />
</div>
</template>
<script>
import Nav from "../components/Nav.vue";
import Footer from "../components/Footer.vue";
import PostsRelated from "../components/PostsRelated.vue";
import CTA1 from "../components/CTA1.vue";
import JsonLdArticle from "../components/JsonLdArticle.vue";
import CookieConsent from "../components/CookieConsent.vue";
import dayjs from "dayjs";
import "dayjs/locale/de";
export default {
components: { Nav, PostsRelated, CTA1, Footer, CookieConsent, JsonLdArticle },
name: "blog-post-layout",
methods: {
formatDate: function(dt) {
return dayjs(dt).format(this.$site.locales[this.$localePath].dateFormat);
}
}
};
</script>
<style>
/* purgecss start ignore */
a.header-anchor {
font-size: 0.85em;
float: left;
margin-left: -0.87em;
padding-right: 0.23em;
margin-top: 0.125em;
opacity: 0;
}
.table-of-contents ul > li > ul > li > a {
@apply font-light;
}
svg {
display: inline !important;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
/* purgecss end ignore */
</style>
You can see the following things:
- We include theme components here like
<Nav />
- We make heavy use of frontmatter stuff
- We also include external libraries like
dayjs
Creating custom components
As shown in the example before for each major element of our pages we created single file components in Vue.js
Here is an example for our Headline.vue
component:
<template>
<div>
<div class="relative mt-8 mb-4">
<h1
class="text-3xl font-extrabold leading-8 tracking-tight text-center text-gray-900 sm:text-4xl sm:leading-10"
>{{$page.frontmatter.headline.text}}</h1>
<p
v-if="$page.frontmatter.headline.subtext"
class="max-w-3xl mx-auto mt-4 text-xl leading-7 text-center text-gray-500"
>{{$page.frontmatter.headline.subtext}}</p>
</div>
<div class="py-8 mb-8 text-center" v-if="$page.frontmatter.headline.pricingLinkText">
<a href="#pricing" class="text-green-500 hover:underline">{{$page.frontmatter.headline.pricingLinkText}}</a>
</div>
</div>
</template>
It also gets data from the frontmatter of the respective page. In the README.md
file this e.g. looks like this:
headline: {
text: Amazon Seller Tool,
subtext: Please find an overview on most relevant features below,
pricingLinkText: Go directly to our pricing overview
}
Internationalization (i18n)
Vuepress supports i18n out of the box. This made me use Vuepress instead of Gridsome as this is still not implemented yet.
In config.js
you just add you locales
variable. Here you can also add other global localized data. We use this e.g. to populate the navigation:
locales: {
"/": {
lang: "de-DE", // this will be set as the lang attribute on <html>
title: "AMALYTIX DE",
description: "Deutscher titel",
topNavigation: [
{ text: "Home", link: "/" },
{ text: "Seller", link: "/amazon-seller-tool/" },
{ text: "Vendoren", link: "/amazon-vendor-tool/" },
{ text: "Tools", link: "/tools/" }, // was /downloads/
{ text: "Blog", link: "/blog/" },
{ text: "Kontakt", link: "/#contact" }
],
...
In our Nav.vue
component we then use this variable like this:
<!-- Menu items -->
<div class="hidden lg:block lg:ml-10">
<span v-for="item in $site.locales[$localePath].topNavigation">
<router-link
class="ml-4 font-medium text-gray-500 lg:ml-8 xl:ml-10 hover:text-gray-900 focus:outline-none focus:text-gray-900"
:to="$withBase(item.link)"
>{{ item.text }}</router-link>
</span>
</div>
Search-engine-optimization (SEO)
Vuepress gives you full control on relevant elements like the page title, meta description, image alt-tags, document structure, and also it creates a table of contents for longer posts if you like. All great elements important for SEO.
To set the page title and meta description you need to put this in your frontmatter:
metaTitle: Create a blazingly fast static site with Vuepress and TailwindCSS
meta:
- name: description
content: We moved our Wordpress-Site to Vuepress using TailwindCSS. This is what we learned!
This is how the Lighthouse Audit in Chrome looks like out of the box without any more finetuning:
We also use frontmatter to categorize our blog posts. This helps showing related articles from the same topic below each article. Here is the script part of the component which does this:
<script>
import dayjs from "dayjs";
import "dayjs/locale/de";
export default {
computed: {
posts() {
let currentPage = this.$page;
let posts = this.$site.pages
.filter(x => {
return (
x.frontmatter.lang == this.$page.frontmatter.lang && // Only show posts of the same language and not the start page
x.frontmatter.hideInBlogList != true && // Don't show hidden blog posts
x.frontmatter.pageType == "blog" && // Only show pages of type "blog"
x.frontmatter.cluster == this.$page.frontmatter.cluster && // Show posts of same cluster
x.regularPath != this.$page.regularPath // Don't show current post
);
})
.sort((a, b) => {
// Show recent articles
return new Date(a.frontmatter.date) - new Date(b.frontmatter.date);
});
// Only return last 3 elements
return posts.slice(Math.max(posts.length - 3, 0)).reverse();
}
},
methods: {
formatDate: function(dt) {
return dayjs(dt).format(this.$site.locales[this.$localePath].dateFormat);
}
}
};
</script>
Migration from Wordpress
We had an existing blog in Wordpress and wanted to move the content to Vuepress of course. I used this handy script to export the content:
https://github.com/lonekorean/wordpress-export-to-markdown
Even if you only want to export your posts and not your pages you want to export everything as (I had some errorshttps://github.com/lonekorean/wordpress-export-to-markdown/issues/27) if I reduced it to "posts". You also need Node.js version >= 12.9 to run this script.
The great advantage is that the folder names match the URL so the URL structure keeps the same. However we use explicit permaLink in frontmatter to define the slug as the slug is SEO relevant as well.
Here is the slug for this page:
permalink: '/vuepress-tailwindcss-tailwindui/'
Something you need to fix from your exports are tables. HTML tables are not converted into markdown tables.
Other nice features
Create free micro-tools
The great thing about Vuepress is that its basically a Vue.js app. So you can easily create single file components and integrate them in your layouts or even markdown files. This creates the possibility to create interactive elements within the page or some micro tools.
Cookie Management / Consent / GDPR
If you need to comply with GDPR, e.g. because you want to use Google Analytics you need to ask the user for consent. There is no real out-of-the-box solution for that so I created my own solution based on what I found in other places.
Feel free to steal and modify it:
<template>
<div v-if="showCookieBanner" class="fixed inset-x-0 bottom-0">
<div class="bg-gray-600 dark:bg-gray-400">
<div class="max-w-screen-xl px-3 py-3 mx-auto sm:px-6 lg:px-8">
<div class="flex flex-wrap items-center justify-between">
<div class="flex items-center">
<div class="ml-3 font-medium text-white truncate">
<span class="mb-4 md:hidden"><router-link to="/ueber-uns/datenschutzerklaerung/" class="">{{$site.locales[$localePath].cookie.cookieNoticeShort}}</router-link></span>
<span class="hidden md:inline">
{{$site.locales[$localePath].cookie.cookieNoticeLong}}
<router-link :to="$site.locales[$localePath].cookie.privacyPolicyLink" class="underline">{{$site.locales[$localePath].cookie.privacyPolicy}}</router-link>
</span>
</div>
</div>
<div class="">
<div class="flex items-center">
<div v-if="!showSettings" @click="showSettings =! showSettings" class="ml-4 mr-8 text-sm text-white cursor-pointer">{{$site.locales[$localePath].cookie.settings}}</div>
<button
v-if="showSettings"
@click="optOut"
class="flex items-center justify-center px-4 py-2 mx-4 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-red-600 border border-transparent rounded-md hover:bg-red-800 focus:outline-none focus:shadow-outline"
>{{$site.locales[$localePath].cookie.declineButton}}</button>
<div class="rounded-md shadow-sm">
<button
@click="optIn"
class="flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-green-600 border border-transparent rounded-md hover:bg-green-800 focus:outline-none focus:shadow-outline"
>{{$site.locales[$localePath].cookie.okButton}}</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Cookies from "js-cookie";
export default {
name: "CookieConsent",
data: function() {
return {
showCookieBanner: false,
cookieStatus: undefined,
showSettings: false
};
},
mounted() {
// Disable tracking until consent is given
this.setTracking(false);
this.cookieStatus = Cookies.getJSON("amalytix_cookie");
this.checkConsent();
},
methods: {
optOut() {
this.$emit("updateConsent", false);
this.updateConsent(false);
},
optIn() {
this.$emit("updateConsent", true);
this.updateConsent(true);
this.$router.go(); // Reload the page to get the page view send to Google Analytics
//window.ga('send', 'pageview') // Reload google analytics with new setting
},
checkConsent() {
if (this.cookieStatus === undefined) this.showCookieBanner = true; // If no amalytix_cookie, show the cookie banner
if (this.cookieStatus) this.setTracking(true); // If cookie is true, start tracking
},
updateConsent(consent) {
this.setCookie(consent); // Update consent according to user response
this.showCookieBanner = false; // Hide banner
this.setTracking(consent); // Set tracking based on response
},
setCookie(consent) {
Cookies.set("amalytix_cookie", consent, { expires: 90, sameSite: 'lax', secure: false }); // Cookie to track consent
},
setTracking(consent) {
window["ga-disable-UA-12345678-12"] = !consent; // Set consent
}
}
};
</script>
You need to set the secure
option to true
in Cookies.set
if you deploy this in production. For local dev leave it to false
otherwise not cookie gets saved.
Structured Data (JSON-LD)
For our blog articles I also created a JSON-LD component which creates a structured data article
element. Hope this helps with SEO as well.
<template>
<div>
<script v-if="this.$page.frontmatter.pageType == 'blog'" type='application/ld+json' v-html="jsonLd"></script>
</div>
</template>
<script>
export default {
computed: {
jsonLd() {
return '{ "@context": "https://schema.org", "@type": "Article", "headline": "' + this.$page.frontmatter.title + '", "image": "https://www.amalytix.com/blog/' + this.$page.frontmatter.coverImage + '", "author": { "@type": "Person", "name": "Trutz Fries"}, "publisher": { "@type": "Organization", "name": "AMALYTIX", "logo": { "@type": "ImageObject", "url": "https://www.amalytix.com/amalytix-logo.png" } }, "url": "https://www.amalytix.com/' + this.$page.regularPath + '", "datePublished": "' + this.$page.frontmatter.date + '", "dateCreated": "' + this.$page.frontmatter.date + '", "dateModified": "' + this.$page.frontmatter.date + '", "description": "' + this.$page.frontmatter.description + '", "mainEntityOfPage": { "@type": "WebPage", "@id": "https://www.amalytix.com/" } }';
}
},
}
</script>
You want to test this with Googles validator tool.
Vue.JS devtools
Its also nice to install the Vue.js developer tools so you can peak inside your components to check the data or events.
Font awesome
After installing the vue-fontawesome package/https://github.com/FortAwesome/vue-fontawesome) I included font awesome via enhanceApp.js
file:
// https://github.com/FortAwesome/vue-fontawesome
import { library } from '@fortawesome/fontawesome-svg-core'
import { faUserSecret,
faEuroSign,
faEye,
faFilter,
faHistory,
faSortAmountUp,
faFileInvoice,
faGavel,
faRuler,
faCogs,
faBell,
faStar,
faQuestion,
faComments,
faHandshake,
faBullseye,
faCrosshairs,
faUser,
faTag,
faPercent,
faHourglassStart,
faMagic,
faBoxOpen,
faListOl,
faSearchDollar,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faSlack } from '@fortawesome/free-brands-svg-icons'
export default ({
Vue, // the version of Vue being used in the VuePress app
options, // the options for the root Vue instance
router, // the router instance for the app
siteData, // site metadata
isServer // is this enhancement applied in server-rendering or client
}) => {
// Allow debugging mode
Vue.config.devtools = true;
// Add needed FontAwesome Icons here and in the import statement above
library.add(faUserSecret,
faEuroSign,
faEye,
faFilter,
faHistory,
faSortAmountUp,
faFileInvoice,
faGavel,
faRuler,
faSlack,
faCogs,
faBell,
faStar,
faQuestion,
faComments,
faHandshake,
faBullseye,
faCrosshairs,
faUser,
faTag,
faPercent,
faHourglassStart,
faMagic,
faBoxOpen,
faListOl,
faSearchDollar,
)
Vue.component('font-awesome-icon', FontAwesomeIcon)
}
Plugins used
plugins: [
[
"sitemap", {
hostname: "https://www.amalytix.com/"
}
],
[
"@vuepress/search", {
searchMaxSuggestions: 5
}
],
[
"@vuepress/google-analytics", {
ga: "UA-2659898-23"
}
],
['img-lazy']
],
I am using a sitemap plugin to generate a XML sitemap during the build process.
Also the official search plugin to make the blog posts searchable based on the headers and I use the official Google analytics plugin for Vuepress.
The vuepress-plugin-img-lazy plugin enables lazy-loading for images, e.g. used in blog post. This keeps the inital page load small and helps especially in long posts where many images are used.
Issues along the way
Public images
I could not find any other way but e.g. putting cover images for blog posts in the public
directory of Vuepress. I would have loved to keep them in the respective post folder but I could not find a way to achieve this.
Deployment to Netlify
Netlify is great for hosting and sharing your app during development. However I had some issues along the way with the build process in Netlify, e.g. the build process for the vuepress/google-analytics plugin failed for some reason for some time. It works without issues when I build the files locally (not in dev mode).
Deployment via Bitbucket pipelines
If you want to use e.g. Bitbucket for deploying the site via FTP, here is a bitbucket-pipelines.yml file you can use:
image: node
pipelines:
default:
- step:
name: VuePress Build + FTP
caches:
- node
script:
- install
- run amalytix:build
- apt-get update
- apt-get -qq install git-ftp
- git status -uno --porcelain
- git ftp push --insecure --user $FTP_USERNAME --passwd $FTP_PASSWORD ftp://$FTP_HOST --all --syncroot amalytix/.vuepress/dist
Important to know: On the first time you need to run this with git ftp init
instead of git ftp push
(last line).
On the positive side Netlify helps with debugging a lot. I developed new major parts of the page in feature branches. You can deploy those as well and you can go back to earlier commits with a single click. Each commit gets its unique URL which is fantastic for debugging to find the step where you added this nasty little bug.
I also was not able to deploy Vuepress 1.4.0 to Netlify. There was always some package missing and the build process failed. I tried both yarn and npm. Did not matter. If you get it to work, shoot me an email to trutz (AT) trutz dot de
Hot reloading
Hot reloading did not work for me when I changed data in frontmatter or in config.js. Thats kind of annoying but it seems the Vuepress team is already working on it. I am using Vuepress 1.3.1 as of now.
PurgeCSS configuration
I was wondering why styles where missing sometimes. As we are using TailwindCSS we use PurgeCSS to keep the filesize small. You need to pay attention to the PurgeCSS config to ensure it monitors all places where you use e.g. TailwindCSS or other styles.
I also used the /*! purgecss start ignore / and /! purgecss end ignore */ syntax to "protect" some styles defined in components.
Pay attention the exclmation mark (!). I had some issues with these ignore messages so I use this modified version instead:
/*! purgecss start ignore */
/*! purgecss end ignore */
Here is how the beginning of my config.js
file looks like:
const purgecss = require("@fullhuman/postcss-purgecss")({
// Specify the paths to all of the template files in your project
content: [
"./amalytix/.vuepress/**/*.vue",
"./amalytix/**/*.md",
"./*.md",
"./node_modules/@vuepress/**/*.vue",
"./amalytix/.vuepress/theme/styles/index.styl",
],
whitelist: [
"fade-enter-active",
"fade-leave-active",
"fade-enter",
"fade-leave-to",
"language-html",
"language-js",
"language-text",
"language-",
"extra-class",
"token",
"tag",
"punctuation",
],
// Include any special characters you're using in this regular expression
defaultExtractor: content => content.match(/[\w-/.:]+(?<!:)/g) || []
});
module.exports = {
postcss: {
plugins: [
require("tailwindcss")("./tailwind.config.js"),
require("autoprefixer"),
...(process.env.NODE_ENV === "production" ? [purgecss] : [])
]
},
...
As you can see I also added the index.styl
file as a content file to ensure PurgeCSS does not remove the styles listed there. Looks like a bad workaround? It truely is but I did not know better.
TailwindCSS styles resetting some Vuepress styles
As TailwindCSS was resetting some styles during preflight you need to bring them back in your index.styl
file. Here is how the beginning of my index.styl looks like.
@tailwind base;
/*! purgecss start ignore */
@import url(https://rsms.me/inter/inter.css);
body {
font-family: -apple-system,system-ui,BlinkMacSystemFont,"Inter var", "Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif
}
a.header-anchor {
font-size: .85em;
float: left;
margin-left: -.87em;
padding-right: .23em;
margin-top: .125em;
opacity: 0;
}
.icon.outbound {
color: #aaa;
display: inline-block;
vertical-align: middle;
position: relative;
top: -1px;
}
@tailwind components;
@tailwind utilities;
h1:hover .header-anchor, h2:hover .header-anchor, h3:hover .header-anchor, h4:hover .header-anchor, h5:hover .header-anchor, h6:hover .header-anchor {
opacity: 1;
}
...
Fix syntax highlighting in markdown
PurgeCSS was agressively removing all styles coming from Prism. I copied the styles from the file node_modules/prismjs/themes/prism.css
to amalytix/.vuepress/theme/styles/index.styl
(protected by PurgeCSS ignore rules) and added it as a "content" file to PurgeCSS config and only then it worked again. There is sure a better way but I did not find it.
Other helpful resources
- Transition from Alpine.js to Vue.js
- Create markdown tables: https://jmalarcon.github.io/markdowntables/
- Image swiper which works with Vuepress: https://github.surmon.me/vue-awesome-swiper/ / https://swiperjs.com/
- https://tinypng.com/: Upload your images and download minified versions to improve loading time
Wrap-up
It took me many hours and nights to set this up from scratch as I had no experience at all with Vue.js or TailwindCSS before. However I am satisfied with the result. It was worth it! If you have some ideas how to improve certain parts please shoot me an email to trutz (AT) trutz dot de
Did this article help you out? If so please link to it from your blog, your forum, your page or from whereever you want. It helps me to increase the visibility for the overall site which pays my bills. Thank you!