fonseca.wiki is getting an upgrade 🎉
I have always been, primarily, a backend developer, with a focus on PHP, it provided a certain level of comfort with its stability. I first became interested in PHP around 2012, at the time PHP would've been at version 5.4. I started my first "coding" job in college in 2015, and at that time I started with PHP 5.6, which was the same version I used for the following 2 years, as I helped the institution painstakingly migrate internal projects to PHP 7.1.
Today, the landscape is different, it's faster, slimmer, and at times unstable. The market has moved a lot more towards JavaScript. JS is a fast-paced language with frameworks entering and falling out of fashion as often as I fill up my coffee. With such a wide range of options and shifting standards, my job as a developer becomes trickier when picking the best possible stack. Long gone are the days when a language and framework I used previously would be the clear choice for the next project.
Each solution brings its unique set of challenges. Not all available features are the same, and based on the client's needs it might not be a great fit. I use my website as a means to explore tech I don't work with regularly for my work. Queue Nuxt. I have worked with Nuxt in the past, back to version 2.x. The context of the application was very different, it was a Laravel app using Sanctum to leverage Nuxt in the frontend. In general, it was a pleasant experience, the learning curve wasn't terrible, but my focus was still on PHP.
v1 - The Vue App
My portfolio is built for a serverless environment, which means no Laravel or PHP code is involved. It reduces cost and overall overhead of infrastructure. It was a trade I was willing to make. The first iteration of the portfolio site was a simple Vue JS app. The normal request flow is pretty straightforward. When the user visits my site, Vercel receives the request and returns a very bare HTML file and a handful of JavaScript & CSS scripts, the browser then parses the code and renders the output to the user.
Performance
This model is great for simple use cases and personal projects. But it leaves a lot to be desired in the form of performance and SEO. To most users in the US, the load and rendering are instant, so it might seem odd to say performance is an issue, especially for an app hosted on a serverless instance. But there's more to performance than what the user sees. Let's review the PageSpeed Insights of the Vue App.
A performance score of 61. That's a D
in performance. Not great for indexing and the problem is, that each new line of code will add volume to those scripts, which will in turn lower the performance score even further. On a personal portfolio website like this one, performance might not be critical, but still, I feel like I can do better than 61. The performance also limits my ability to include some external libraries I might want to, to either improve the look/feel of the site or to test specific features.
SEO, meta tags, and a whole lotta nothing
A secondary problem I had started to notice was that project pages I would like to share with a title, a description, and a featured image were being sent with just very generic info. Pretty straightforward to solve, I thought, just add <meta>
tags to those pages and it will be used when sharing. Except, remember how this app is built, and the request flow.
When a user shares the link: https://fonseca.wiki/projects/fordpass
. The sharing will look just like every other page on the site:
On something like iMessage, that means all they will know is I am sending them this random link, the path is not even visible, it's not meaningful, there's no flash, there's certainly no context. But that is only a small problem compared to the SEO blunder this represents. There are no meta tags, there's no description (other than the one in the index.html
file) and the title is always just "Portfolio."
Digging a bit deeper, try to curl
the site, and you will get a Redirect...
message from Vercel:
Try to use wget
and download the payload, this is all the bots will see.
Not much of anything.
And just to drive the point home, there's this cool app called Meta Tags which provides a preview of a given page across different platforms, including Twitter, Facebook, and Slack. As expected, they are all the same generic title & description without any featured media.
v2?
So the above problems are not deal breakers, the site works well for real users, so who cares if the bot overlords aren't happy? Then I remembered, I started this for the sole purpose of researching and sharpening my skills. I have measurable problems, which when solved will give me definite metrics to show how the site is better than it used to be. I will also increase my exposure to a technology that is popular within the community.
Before I decided to uproot the entire app and convert it to a completely different framework, I thought I would do some research. That's when I came across these two concepts for a possible solution to the Vue app: SSR and SSG.
Static Site Generation
This was the first time I considered implementing a Static Site Generation. I hoped to add as little overhead as possible to the portfolio and continue to host it on Vercel. Vue even recommends that for a simple site, SSG is the best path to take.
If you're only investigating SSR to improve the SEO of a handful of marketing pages (e.g.
/
,/about
,/contact
, etc.), then you probably want SSG instead of SSR
So I began my research. The build stack is simple, it was a Vue site compiled using Vite. The first library I looked at was VitePress. But I quickly moved on from VitePress, which is designed primarily for a Markdown site. Almost none of my existing components would be portable to VitePress.
The next option I took a look at is called Vite SSG. At first, it seemed promising, installing the dependency and launching the site seemed to work. But when it came time to generate a static version of the site the problems began. I started to run into issues. I started modifying config files, the route file, and different components, and addressing each error as they kept coming up. Eventually, I decided that it was a no-go. I was running in circles to what felt like an unsolvable problem. So I moved on.
Server-Side Rendering
Finally, the moment I dreaded arrived, if I can't solve my problem with SSG, then I need to start looking into Server-Side Rendering. After all, I am a backend dev 😭. Choosing the tool was not difficult, I picked Nuxt, as it is the official framework for Vue.
At first, I thought I'd just install Nuxt as a dependency in my existing environment and create the necessary files while cleaning up anything I did not need. However, that did not work, between TypeScript errors & compiling errors I had done nothing more than make a working site, not work. After a little research I found that the migration wouldn't be as painful as I had previously believed, I found this Article by Nicolas Granja: How to migrate a VueJs project to NuxtJs in 8 steps.
This made much better sense, instead of trying to retro-fit Nuxt to work within a Vue app, I had to start with Nuxt and import my code into this new app. A risky move that could blow up my local environment. But I gave it a try. Before I did any work towards migrating I read up on Nuxt's documentation, I felt like it would be helpful to familiarize myself with Nuxt's requirements.
Nuxt is not difficult to get started working with, especially if you are already familiar with Vue, after all, Nuxt is a Vue framework. Much like the upgrade of Vue 2 to 3, Nuxt 3 brought a lot of new features to the table and I had to relearn some of the basics before I was able to complete the migration for my portfolio site into Nuxt.
The directory structure was very different. With the Vue app, everything lived inside of the src/
directory. There's no such directory in Nuxt. Everything lives in the root of the project.
- portfolio
- assets/
- components/
- layouts/
- pages/
- plugins/
- public/
- server/
- stores/
- tests/
- utils/
- app.vue
- error.vue
To my absolute surprise, once I started bringing the files from Vue to the new structure, most of them worked as expected. I registered my stylesheets and within the first 10 minutes, I had a working home page. Next, I had to focus on asynchronous behaviors across the site. The Projects page and the project view itself fetch the content from Builder IO when the pages were mounted, which means I still need to wait before I can populate the data and my meta tags.
The server/
directory quickly became my favorite, paired with useFetch()
the sky was the limit to what I could do with my Builder IO integration. I quickly set up a couple of endpoints with server/projects/index.ts
and defined the logic.
const apiKey = process.env.BUILDER_IO_KEY
const endpoint = 'https://cdn.builder.io/api/v3/content/projects'
export default defineEventHandler(async (event) => {
const response = await $fetch<BuilderIoResponse>(endpoint, {
method: 'GET',
query: {
limit: 100,
apiKey,
noTraverse: false,
includeRefs: true
}
})
if (!response || !response.results || response.results.length < 1) return {}
return response.results.map((project) => fillProject(project))
})
Now I had access to all my entries in the server, and I could get it ready before the user ever saw a frame of the site. For the /projects
homepage, this was neat, but it didn't matter much for SEO. The star here was the /projects/[slug]/index.ts
. With a very similar structure as the /projects
endpoint, I could fetch the entry in the server. Then within my component define the Meta tags that will be necessary for SEO.
const url = computed(() => `/api/projects/${route.params.slug}`)
const { data: project } = await useFetch<Project>(url)
if (typeof project === 'undefined' || !project.value || !project.value.id) {
throw createError({
statusCode: 404,
statusMessage: `Oops! The project ${route.params.slug} could not be found.`
})
}
useSeoMeta({
title: computed(() => `Project - ${project.value?.title}`),
description: computed(() => project.value?.description),
ogDescription: computed(() => project.value?.description),
ogTitle: computed(() => `Project - ${project.value?.title}`),
ogImage: computed(() => project.value?.featuredImage),
ogUrl: computed(() => `https://fonseca.wiki/projects/${project.value?.slug}`),
twitterTitle: computed(() => `Project - ${project.value?.title}`),
twitterDescription: computed(() => project.value?.description),
twitterImage: computed(() => project.value?.featuredImage)
})
Nuxt's useFetch
composable is one of the many great additions that came with Vue 3 and its new reactivity. It is a piece of cake to implement with the server API and does all the heavy lifting in my component. I recommend this article by Eugen Sawitzki on useFetch
.
Once the site was somewhat stable and ready for preview I checked out a new branch of the project and uploaded it. After running a PageSpeed Insights on the site I was impressed. With no optimization, I bumped the score up to an 80.
That is a 19% improvement with a simple switch to Server-Side Rendering. No changes to the logic or existing code. Digging into the results I could see that one of the major problems was Google reCaptcha and Vercel's scripts being injected into a preview branch, which is infuriating that it causes such a hit in performance for an item that lives at the very bottom of the home page.
Optimization
Digging a bit deeper into Google reCaptcha a lot of Googling didn't seem to offer very easy solutions to this problem. So after pondering on possible solutions, I felt like I could approach Google reCaptcha in the same way images are lazy loaded. So I would have to ensure the reCaptcha component would only be loaded for the client, and I could also prevent it from loading until the user scrolled into view using IntersectionObserver
:
const lazyLoadRecaptcha = () => {
if (!recaptchaContainer.value || alreadyLoaded()) return
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
createScriptTag()
observer.disconnect()
}
})
})
observer.observe(recaptchaContainer.value)
}
I got everything uploaded and deployed and reran the PageSpeed Insights, and to my shock, I had improved the score to 97! That is lightning fast. After implementing a couple more fixes to serve images more efficiently I got to a score of 98. Which I felt was a pretty decent bump from my initial 61.
What about the SEO?
Finally the moment of truth for my SEO, the second issue part of the problem was a lack of descriptive meta tags for Projects and pages across the site. So the next stop is MetaTag.io. As expected my site now serves the HTML page with the appropriate SEO tags, making sharing possible.
Takeaway
Knowing what I know today has convinced me that an SPA can be great, but there are a lot of important details that need to be considered beforehand. However, tools like Nuxt and Vue make those, seemingly impossible, tasks a pleasure to try and solve. I am glad I started the Portfolio site using Vue, it allowed me to focus on the tech I was not comfortable with, the design driven by TailwindCSS, and building out the Projects section integrated with Builder.io. If I had started with Nuxt I would have spent far more time simply debugging the very tool that is supposed to make my experience easier.
This process has also allowed me to peek a little further at all that a serverless app is capable of achieving. With a full client SPA there are some limitations. Extending some functionality to be handled before the client receives any data enables me to pursue more integrations, and improve the speed and SEO for my projects.