An Internal Refactor for the Future Growth of Our Blog

Part of the series Create a Blog with VitePress and Vue.js from Scratch

Our blog was established, with the primary focus on content and functioning well.

But we aimed to do more. We considered adding a comment system. Once you have an API, numerous possibilities arise. Additionally, we've learned a lot since starting this blog, and we want to apply this knowledge to make it more maintainable and scalable, especially for future growth, although this internal refactor may not seem to improve it immediately.

This refactor focuses on two main points:

  1. Add Tailwind Variants to facilitate the reuse of components
  2. Transition from a type-based folder structure to a feature-based folder structure

Let me explain why we want to do this.

Why Use Tailwind Variants?

First, let's understand the problem we're facing. Currently, our codebase has two buttons:

  • ButtonPrimary.vue: a primary button
  • ButtonSecondary.vue: a secondary button

The issue is that our site is simple and has only these two buttons. But what if we want to add a tertiary button, a button with a border instead of a background color, or a button with a different size? Or worse, what if we want our Button to be a real button instead of just an a tag? Updating each component individually would be a nightmare to maintain, and small changes would require so much time, leading to loss of productivity—often the reason projects don't succeed.

To resolve this, we can consolidate all buttons into a single component. This is easier than it seems. Consider the ButtonPrimary.vue and ButtonSecondary.vue components:

vue
<script lang="ts" setup>
defineProps<{
  href: string
}>()
</script>

<template>
  <a
    :href
    class="border-4 border-black bg-[var(--color-yellow)] px-8 py-4 shadow-[4px_4px_0_black] transition duration-150 ease-linear hover:bg-[#ffdf1b] hover:shadow-[6px_6px_0_black] hover:-translate-x-[0.125rem] hover:-translate-y-[0.125rem]"
  >
    <slot />
  </a>
</template>
vue
<script lang="ts" setup>
defineProps<{
  href: string
}>()
</script>

<template>
  <a
    :href
    class="inline-block border-4 border-black bg-[var(--color-purple)] px-8 py-4 text-white shadow-[4px_4px_0_black] transition duration-150 ease-linear hover:bg-[#828cd2] hover:shadow-[6px_6px_0_black] hover:-translate-x-[0.125rem] hover:-translate-y-[0.125rem]"
  >
    <slot />
  </a>
</template>

They are almost identical! The only difference is the color: bg-[var(--color-yellow)] hover:bg-[#ffdf1b] for the first, and bg-[var(--color-purple)] hover:bg-[#828cd2] for the second. This can be merged into a single component:

vue
<script lang="ts" setup>
const props = defineProps<{
  href: string
  color: string
}>()

const color = computed(() => {
  switch (props.color) {
    case 'primary':
      return 'bg-[var(--color-yellow)] hover:bg-[#ffdf1b]'
    case 'secondary':
      return 'bg-[var(--color-purple)] hover:bg-[#828cd2]'
    default:
      throw new Error('Unknown color')
  }
})
</script>

<template>
  <a
    :href
    class="inline-block border-4 border-black px-8 py-4 text-white shadow-[4px_4px_0_black] transition duration-150 ease-linear hover:shadow-[6px_6px_0_black] hover:-translate-x-[0.125rem] hover:-translate-y-[0.125rem]" :class="[color]"
  >
    <slot />
  </a>
</template>

This is a good start but still not scalable. Our button could vary in size, color, variant, state, and more. Managing these in multiple computed properties is not ideal. This is where Tailwind Variants helps.

A powerful approach is to decouple the theme, not the style, from the component and framework. With Tailwind Variants, we can define an independent theme for a component adaptable to different contexts in a declarative way:

ts
import { tv } from 'tailwind-variants'

const button = tv({
  base: 'inline-block border-4 border-black px-8 py-4 text-white shadow-[4px_4px_0_black] transition duration-150 ease-linear hover:shadow-[6px_6px_0_black] hover:-translate-x-[0.125rem] hover:-translate-y-[0.125rem]',
  variants: {
    color: {
      primary: 'bg-[var(--color-yellow)] hover:bg-[#ffdf1b]',
      secondary: 'bg-[var(--color-purple)] hover:bg-[#828cd2]',
    },
  },
})

Do you see the power of this approach? The base style, shared among variants, is defined centrally. Then we define variants, such as color. Additional properties like size can also be added or compound variants defined for specific styles, like when a button is primary and small.

Rewriting our Button.vue component:

vue
<script lang="ts">
import type { VariantProps } from 'tailwind-variants'

const button = tv({
  base: 'border-4 border-black px-8 py-4 shadow-[4px_4px_0_black] transition duration-150 ease-linear hover:-translate-x-[0.125rem] hover:-translate-y-[0.125rem] hover:shadow-[6px_6px_0_black]',
  variants: {
    color: {
      primary: 'bg-[var(--color-yellow)] hover:bg-[#ffdf1b]',
      secondary: 'text-white bg-[var(--color-purple)] hover:bg-[#828cd2]',
    },
  },
  defaultVariants: {
    color: 'primary',
  },
})

type ButtonVariants = VariantProps<typeof button>

export interface ButtonProps {
  href: string
  label: string
  color?: ButtonVariants['color']
  class?: any
}
export interface ButtonEmits {}
export interface ButtonSlots {}
</script>

<script lang="ts" setup>
const props = defineProps<ButtonProps>()
defineEmits<ButtonEmits>()
defineSlots<ButtonSlots>()

const ui = computed(() => button({ class: props.class, color: props.color }))
</script>

<template>
  <a :href="props.href" :class="ui">
    {{ props.label }}
  </a>
</template>

One interesting aspect is extracting variants with TypeScript and applying them to the props. The ButtonVariants type includes all Button variants based on the button theme. So any theme change reflects in the Button component automatically.

You're probably asking: How do Tailwind Variants know which variant to use? Look at this code:

ts
const ui = computed(() => button({ class: props.class, color: props.color }))

The button variable is a function that takes an object with variants as arguments. This way, variants are calculated at runtime based on props and the predefined theme.

We can use our Button component like this:

vue
<Button href="https://soubiran.dev" label="Primary Button" />

<Button href="https://soubiran.dev" label="Secondary Button" color="secondary" />

Automatically, the Button component applies the correct style based on the color prop.

Why Move to a Feature-Based Folder Structure?

Currently, our codebase is in .vitepress/theme, split into folders:

    By Type

  • .vitepress/theme

We may have additional folders like directives, utils, queries, or mutations. This structure, based on file types, places all components in one folder regardless of their use, which isn't ideal.

It's hard to identify core blog features from the folder structure. With just one feature, it's manageable, but as the blog grows, locating specific components or pages becomes challenging. Hence, a feature-based folder structure is beneficial.

This approach groups multiple folder-by-type structures by feature. It's easy to find specific components, and when working on a feature, all related files are easily accessible. It's an efficient way to manage and scale a project.

In our case, the major features are the blog and projects. The structure could be like this:

    By Feature

  • .vitepress/theme
  • blog
  • projects

This way, core blog features are clear, and related files are easily found. Adding new features involves creating a new folder and including related files.

A missing folder here is common or shared. It contains files shared between features. For example, the Button component shared by the blog and projects should be placed here.

    By Feature with Common

  • .vitepress/theme
  • blog
  • projects
  • common

Note

I named it common, but you can choose any name that conveys the shared nature of the files not belonging to a specific feature.

Refactoring the Codebase

Aside from the two main points, small codebase changes will enhance readability and reduce code repetition, crucial for maintaining blog style coherence.

First, we'll consolidate everything related to Page. Every page component shares the same wrapper, title, and footer structure. We can easily extract each part into three components:

  1. Page.vue
vue
<script lang="ts">
const page = tv({
  base: 'space-y-16',
})
export interface PageProps {
  class?: any
}
export interface PageEmits {}
export interface PageSlots {
  default: (props?: object) => any
}
</script>

<script lang="ts" setup>
const props = defineProps<PageProps>()
defineEmits<PageEmits>()
defineSlots<PageSlots>()
const ui = computed(() => page({ class: props.class }))
</script>

<template>
  <section :class="ui">
    <slot />
  </section>
</template>
  1. PageTitle.vue
vue
<script lang="ts">
const pageTitle = tv({
  base: 'text-6xl font-semibold',
})
export interface PageTitleProps {
  title: string
  class?: any
}
export interface PageTitleEmits {}
export interface PageTitleSlots {}
</script>

<script lang="ts" setup>
const props = defineProps<PageTitleProps>()
defineEmits<PageTitleEmits>()
defineSlots<PageTitleSlots>()
const ui = computed(() => pageTitle())
</script>

<template>
  <h1 :class="ui">
    {{ props.title }}
  </h1>
</template>
  1. PageFooter.vue
vue
<script lang="ts">
const pageFooter = tv({
  base: 'flex justify-center',
})
export interface PageFooterProps {
  backTo: { href: string, label: string }
  class?: any
}
export interface PageFooterEmits {}
export interface PageFooterSlots {}
</script>

<script lang="ts" setup>
const props = defineProps<PageFooterProps>()
defineEmits<PageFooterEmits>()
defineSlots<PageFooterSlots>()
const ui = computed(() => pageFooter({ class: props.class }))
</script>

<template>
  <div :class="ui">
    <Button :href="props.backTo.href" :label="props.backTo.label" />
  </div>
</template>

These simple components help reduce code repetition and streamline new page creation. Additionally, we can consolidate every prose into a single Prose.vue component, ensuring consistent blog prose style.

Next, we can unify ProjectCard.vue and BlogCard.vue into Card.vue. Their only differences are title size, description, and padding. Tailwind Variants efficiently manage these differences:

vue
<script lang="ts">
import type { VariantProps } from 'tailwind-variants'
const card = tv({
  slots: {
    base: 'group relative border-4 border-black bg-white shadow-[8px_8px_0_black] transition duration-150 ease-linear hover:-translate-x-1 hover:-translate-y-1 hover:shadow-[12px_12px_0_black]',
    title: 'font-medium text-black',
    description: 'text-black',
  },
  variants: {
    size: {
      sm: {
        base: 'space-y-4 p-6',
        title: 'text-2xl',
        description: 'text-lg',
      },
      md: {
        base: 'space-y-6 p-12',
        title: 'text-4xl',
        description: 'text-xl',
      },
    },
  },
  defaultVariants: {
    size: 'md',
  },
})
type CardVariants = VariantProps<typeof card>
export interface CardProps {
  href: string
  title: string
  description?: string
  size: CardVariants['size']
  class?: any
  ui?: Partial<typeof card.slots>
}
export interface CardEmits {}
export interface CardSlots {}
</script>

<script lang="ts" setup>
const props = defineProps<CardProps>()
defineEmits<CardEmits>()
defineSlots<CardSlots>()
const ui = computed(() =>
  card({
    size: props.size,
  }),
)
</script>

<template>
  <article :class="ui.base({ class: [props.class, props.ui?.base] })">
    <h2 :class="ui.title({ class: props.ui?.title })">
      <a :href="props.href">
        {{ props.title }}
        <span class="absolute inset-0" />
      </a>
    </h2>
    <p :class="ui.description({ class: props.ui?.description })">
      {{ props.description }}
    </p>
  </article>
</template>

Finally, move the .env.example file to the src folder since VitePress defines the project root in the src folder.

For the full refactor, refer to commit 26b1dd4 of Garabit as I can't cover all changes in this article.

I hope you found this article insightful and learned something new. The goals were to demonstrate the power of Tailwind Variants with a feature-based folder structure and to show that you can start simply and grow step by step as needed. In our case, this refactor sets the stage for adding the comment system.

Note

All my projects, even the smallest ones, are based on both Tailwind Variants and a feature-based folder structure. This setup enhances my productivity and, because of the consistent components and folder structure, allows me to switch projects seamlessly without wasting time understanding project architecture. Now, creating a component feels more like a reflex than a task.

PP

Thanks for reading! My name is Estéban, and I love to write about web development.

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!

Continue readingMigrating Our VitePress Blog to Tailwind CSS Version 4

Reactions

Discussions

Add a Comment

You need to be logged in to access this feature.

Login with GitHub