Want to Contribute to Open Source? You’re Doing It Wrong
I have been actively contributing to open-source since 2020. Since then, I've spoken with many developers who want to contribute but don't know where to start, often asking for an issue to work on.
I'm deeply convinced that open-source is more than that and, more importantly, doesn't work like that.
To me, open-source, at its core, is a mindset. It's about the freedom to explore, understand, modify, and improve the source code of the software you want. Of course, this model encourages collaboration and sharing by allowing anyone to contribute, but it doesn't require you to.
Open-source shouldn't, and should never, be a goal in itself. It's part of the journey, but it's not the destination. Otherwise, you'll end up feeling frustrated and unfulfilled by never being able to contribute.
Let me tell you a story to illustrate this point.
I'm building a new website called infra.soubiran.dev to document my own infrastructure. It's a personal wiki but open to everyone. I won't go into details; a dedicated post will come later.
To build it, I needed to find the right tools. Until now, I was using VitePress with a custom theme for my personal blog, the one you're reading right now. It works great, but I've nearly reached its limits, and with all the ideas I have, I know it could be time to move on.
So, I started looking into alternatives. Honestly, there are two options:
- Using Nuxt with Nuxt Content
- Using Vite with Vue and some unplugins to handle markdown files.
I went for the second option. Funny enough, before moving to VitePress, I had the same dilemma: Nuxt Content, VitePress, or Vite with Vue. At that time, I chose VitePress.
Anyway, I started building the website.
For the past couple of months, there's one tech I can't live without: Nuxt UI. It's an incredible component library made by the Nuxt team. All my projects that use Vue or Nuxt use Nuxt UI, and I regularly push it to its limits. This is also a reason why I decided to move away from VitePress, which is not compatible with Nuxt UI.
Building with Open-Source Software
The website I'm building is content-driven. This means that the content is primarily text-based and, in my case, all the pages are written in Markdown. To update the website, I edit the Markdown files and then rebuild the site.
The pages are generated and pre-rendered at build time. There is no need to generate them at runtime, as every user will receive the same pre-rendered content. To make it work, I use Vite SSG.
It's a drop-in replacement for Vite when building the website. It automatically builds the client and the server, then uses the server to pre-render each page, as if a user were requesting it.
After installing it, everything worked as expected.
So I continued to build the website. I installed Nuxt UI and started configuring it. To use Nuxt UI within a Vue application, you have to use a plugin.
import ui from '@nuxt/ui/vue-plugin'
import { ViteSSG } from 'vite-ssg'
import App from './App.vue'
export const createApp = ViteSSG(
App,
{},
({ app }) => {
app.use(ui)
},
)And then, everything broke. During the pre-rendering, something tried to access document to create a style: const style = document.createElement('style'). Really strange.
When pre-rendering, Vite SSG injects import.meta.env.SSR to detect if it's running in SSR mode. It also allows the bundler to tree-shake unused code effectively. So, to fix the bug, I wrapped the plugin usage in a conditional check for SSR mode.
import ui from '@nuxt/ui/vue-plugin'
import { ViteSSG } from 'vite-ssg'
import App from './App.vue'
export const createApp = ViteSSG(
App,
{},
({ app }) => {
if (import.meta.env.SSR) {
app.use(ui)
}
},
)Now, pre-rendering worked as expected, or so I thought.
I wrote some pages, created components, and pushed everything to production. It built successfully, so I opened the website and saw the following:
Can you see it?
Before going further, and if you noticed it, would you have fixed it? You know it will take time, and you won't be able to continue writing content. That's a real dilemma. Taking time for something you might consider a detail, or continuing to work on your content.
Stunning websites are made of details. For me, details are very important, and it's essential to pay attention to them. They can transform the user experience, improve user comfort, and increase loyalty. Never underestimate them.
For those who don't see it, that's called a FOUC, a flash of unstyled content. The content appears before all the style is available. It usually happens when the script needs to add a dark class to the HTML element. The website loads with the default theme (light mode), appears, and then the JavaScript is parsed and executed. The script adds the dark class and the theme switches to dark mode. The light mode lasts a few hundred milliseconds, but it's enough to be noticed. This is all related to the way a browser renders a page.
It can be very tricky to fix. Usually, it happens when tags in your head aren't correctly ordered or when the script is not executed at the right time. So, I jumped into the head of the generated page:
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.gstatic.com/" crossorigin="anonymous">
<link rel="preload" as="style" onload="this.rel='stylesheet'" href="https://fonts.googleapis.com/css2?family=DM Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Sofia Sans:ital,wght@0,1..1000;1,1..1000&display=swap">
<meta charset="utf-8">
<meta name="author" content="Estéban Soubiran">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Estéban's Infra · Estéban Soubiran</title>
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="theme-color" content="#ffffff">
<script>
;(function () {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const setting = localStorage.getItem('vueuse-color-scheme') || 'auto'
if (setting === 'dark' || (prefersDark && setting !== 'light'))
document.documentElement.classList.toggle('dark', true)
})()
</script>
<script type="module" crossorigin="" src="/assets/app-IQH-kuhC.js"></script>
<link rel="stylesheet" crossorigin="" href="/assets/app-B27WEjTb.css">
<link rel="modulepreload" crossorigin="" href="/assets/pages-DeF1Ma75.js">
<link rel="modulepreload" crossorigin="" href="/assets/WrapperContent-BVl8okbX.js">
<meta property="og:title" content="Estéban's Infra">
<meta name="twitter:title" content="Estéban's Infra">
</head>
</html>And I tried to understand what was happening.
To know if the head is correctly sorted, you can use capo.js. After a quick inspection, I had the following result:

In the Vite SSG documentation, it says to use Unhead, so I read all the documentation about it. I discovered that Unhead automatically sorts the head elements, but if it's already used, why isn't the head correctly sorted?
Everything Didn't Go as Planned
If Unhead is supposed to sort the head elements, could the problem be in Vite SSG?
Only one way to find out: debugging.
After reading the whole source code of Vite SSG, I found an interesting part:
// create jsdom from renderedHTML
const jsdom = new JSDOM(renderedHTML)
// render current page's preloadLinks
renderPreloadLinks(jsdom.window.document, ctx.modules || new Set<string>(), ssrManifest)
// render head
if (head)
await renderDOMHead(head, { document: jsdom.window.document })
const html = jsdom.serialize()
let transformed = (await onPageRendered?.(route, html, appCtx)) || html
if (beasties)
transformed = await beasties.process(transformed)
const formatted = await formatHtml(transformed, formatting)This part is responsible for generating the HTML before being saved. The interesting line is await renderDOMHead(head, { document: jsdom.window.document }). This is the only part where the head is used and rendered inside the HTML.
More interestingly, the function comes from @unhead/dom. Strange, because we're using SSR, so having a front-end function on the server looks suspicious.
And I was right. This function doesn't optimize the head. It's supposed to change the head elements once the page is loaded, on the client. Bringing the whole sorting logic to the client side is completely pointless.
While reading the Unhead documentation, I discovered the recommended way to use it with SSR.
import { transformHtmlTemplate } from '@unhead/vue/server'
const rendered = await render(url)
const html = await transformHtmlTemplate(
rendered.head,
template.replace(`<!--app-html-->`, rendered.html ?? '')
)The function transformHtmlTemplate extracts the current head elements from the template and injects them into the head before re-rendering the HTML and injecting it into the page. This ensures that the head is correctly sorted before sending it to the client.
It looked like a good solution to fix the head sorting issue. This led me to create a PR to Vite SSG to improve the integration with Unhead.
refactor: uses unhead/server to optimize head
In order to quickly use this fix in my project, I patched Vite SSG locally. This allowed me to continue developing my project without waiting for my PR to be merged and then for a release.
But not everything went as planned.
The head was, nearly, well-ordered, but after that fix, the FOUC was still there. Sad.
It means that the problem isn't related to the head sorting.
Deeper Investigation
The Vite SSG patch isn't useless, as it improves the head sorting. However, it didn't solve the FOUC issue. I had to dig deeper.
I continued to look for the root cause. I tried to analyze the application performance to identify any bottlenecks, race conditions, or other issues that could be causing the FOUC. I didn't find anything.
I inspected the CSS and found that the styles from the prose were correctly applied, but not the ones from Nuxt UI. If you look closely at the video, you'll see the title color change from black to white, but the text underneath is already white. The CSS variables for Nuxt UI aren't available on the first render but are applied when the JavaScript loads.
To confirm this suspicion, I checked the Nuxt UI source code where I found a Vue plugin named colors.ts. The same that caused the build issue at the beginning.
Everything was leading to this plugin, so I took the time to understand it, and I found the root cause of both the FOUC and the build issue:
const headData: UseHeadInput = {
style: [{
innerHTML: () => root.value,
tagPriority: -2,
id: 'nuxt-ui-colors'
}]
}
if (import.meta.client && nuxtApp.isHydrating && !nuxtApp.payload.serverRendered) {
const style = document.createElement('style')
style.innerHTML = root.value
style.setAttribute('data-nuxt-ui-colors', '')
document.head.appendChild(style)
headData.script = [{
innerHTML: 'document.head.removeChild(document.querySelector(\'[data-nuxt-ui-colors]\'))'
}]
}
useHead(headData)There are multiple issues at play here.
The first seven lines, along with the last, are used to inject the Nuxt UI CSS variables into the head, at both build time and runtime. By disabling the plugin, I removed this behavior, so the FOUC inevitably occurs.
The if block creates a style element and must only be executed on the client side, during hydration, if the content is not server-rendered. My app is using Vite SSG to be server-rendered, so this should not happen.
So, what is this nuxtApp? Let's continue to investigate.
At first, Nuxt UI is made for Nuxt, but thanks to the work of Daniel Roe and a lot of stubs, Nuxt UI is also usable outside of Nuxt. One of the stubs is the useNuxtApp composable:
export function useNuxtApp() {
return {
isHydrating: true,
payload: { serverRendered: false },
hooks,
hook: hooks.hook
}
}Here it is. The serverRendered property is set to false, always. So even when Vite SSG is used, the plugin thinks it's in a browser and tries to create the style element. Obviously, this doesn't work.
The patch is really simple. Instead of always returning false, we need to return import.meta.env.SSR || false to properly detect server-side rendering.
fix(vue): check import.meta.env.SSR to support vite-ssg
Then, I re-enabled the Nuxt UI plugin, the build passed, and the FOUC was gone! Love it. A small boolean change made a big difference.
Making a Better Head
The build was passing and the FOUC was gone. I continued to check the head of the generated file to ensure everything was in order.
It wasn't the case.
I could stop there, but I was on a roll of pull requests, plunged in Unhead, Nuxt UI and Vite SSG.
I discovered two things.
- Nuxt UI sets a
tagPriorityof-2to the style element when injecting it. This means it will be one of the first elements in the head instead of being automatically placed. - Unhead was incorrectly handling the order of
inlinedandmodulescripts.
For the first, I only wrote a comment to understand why it was set. It could be a requirement for the Nuxt integration.
However, for the second point, knowing it was a bug, I opened two pull requests.
While doing these pull requests, I discovered that the CI wasn't working, some tests were failing, and the typecheck was broken, so I created 3 PRs to fix all of them.
I was just fixing a simple bug in the way Unhead handled script ordering and I finished fixing the tests and the integration.
When you care about the details, that's often how it ends up, and by making clean and working pull requests, you increase your chances of being accepted.
I didn't even have time to write a blog post about them before they merged.
Open-source not by Choice
I just wanted to build my website to document my infrastructure. I ended up fixing bugs in three tools, and opening 7 pull requests.
Honestly, I would love to never have to fix them, and I would love to never have to spend my time on fixing these bugs. But that's not the open-source implicit contract, nor its mindset. It's how I see it. Not everyone does.
People offer you, for free, and often as is, their code for your own usage. It saves you time and money and allows you to build faster and better. Yes, I spend time fixing bugs, but how many times have I saved by using open-source software? How often have I been able to contribute to open-source software to build the experience I wanted within my projects? It's not even worth starting to count. Giving back, a little of your knowledge and time, seems to be a fair deal.
And that's normal that software has bugs, missing features or simply unplanned use cases. It's inherent to software and neither closed-source software nor open-source software is immune to this.
However, with open-source, you can change the situation by having this freedom to contribute to the software you're using.
Just build stuff, anything you want, but bring your dreams to life, on internet, don't stop to localhost.
Along the journey, you'll encounter bugs, missing features, or simply things that could be improved. When you do, don't hesitate to dive into your stack, to understand it, and try to fix it.
That's how you contribute to open-source. Not the other way around. That's also one of the best ways to learn.
Cultivate the open-source mindset and opportunities will naturally arise.
Thanks for reading! My name is Estéban, and I love to write about web development and the human journey around it.
I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.
If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.
I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below—it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!
Discussions
Add a Comment
You need to be logged in to access this feature.