Vue 3 + Tailwind in Production: Real Lessons From Our 13-Person AI Team
We're a 13-person AI development team. In under two weeks, we were building three frontend projects simultaneously โ a security CDN product site, a corporate website, and a desktop AI assistant. No standards, no consistency, no rules. Then the chaos hit.
This article is the practical version of the rules we eventually wrote. Every line exists because we got burned by something real.
Why Hard Rules Matter
Early on we had no standards. Here's what happened:
One component used <script setup>. Another used Options API. One section of a page used Tailwind classes for spacing; another used inline styles. Colors were hardcoded as hex values by whoever happened to write the component that day.
The worst incident: we redesigned a site from dark to light backgrounds. We updated the main page. We forgot 10+ other pages. The boss opened the site โ half dark blue, half white. He sent a screenshot with no comment. That silence said everything.
Standards aren't restrictions. They're the rope that keeps you from falling.
The Stack: Fixed, No Debate
After one round of discussion, we locked this stack:
| Layer | Tech | Why |
|---|---|---|
| Build | Vite 6.x | Near-instant cold start, seamless HMR |
| Framework | Vue 3.5+ | Composition API is testable; <script setup> is clean |
| Router | Vue Router 4.x | Official, TypeScript-native |
| State | Pinia 2.x | Lighter than Vuex, great DevTools |
| CSS | Tailwind CSS 3.x | Atomic classes map directly to design tokens |
| i18n | vue-i18n 10.x | Essential for our zh/en/zh-TW projects |
| HTTP | ofetch | From the Nuxt team โ lighter than Axios |
| Charts | ECharts 5.x | Handles BGP routing maps and traffic stats well |
| Animation | GSAP + Lottie | One for DOM animation, one for vector animation |
Not the most cutting-edge stack. But at our project scale, stability beats novelty every time.
Project Structure: Convention Over Configuration
Structural chaos is a slow-moving poison. We standardized the directory layout:
project-name/
โโโ public/ # favicon/logo/robots.txt
โโโ src/
โ โโโ assets/ # Images/fonts/SVG
โ โโโ components/
โ โ โโโ layout/ # NavBar/Footer/Sidebar
โ โ โโโ ui/ # Button/Card/Modal
โ โโโ composables/ # useXxx composable functions
โ โโโ i18n/
โ โ โโโ zh.json
โ โ โโโ en.json
โ โ โโโ index.js
โ โโโ pages/ # Route-level components
โ โโโ router/
โ โโโ stores/ # Pinia stores
โ โโโ styles/ # Global CSS + Tailwind config
โ โโโ utils/
The mandatory naming rules:
- Component files: PascalCase โ
ProductCard.vue - Routes and CSS classes: kebab-case โ
product-detail - Composables:
useprefix โuseProducts.js - i18n keys: dot notation โ
product.title, notproductTitle - Any component over 200 lines must be split
Rule 5 is the most important. If a component hits 200 lines, it's doing more than one thing. Splitting it now is faster than splitting it later.
Component Pattern: script setup Only
<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
title: {
type: String,
required: true
},
count: {
type: Number,
default: 0
}
})
const emit = defineEmits(['update', 'close'])
const { t } = useI18n()
const displayTitle = computed(() => t(props.title))
</script>
<template>
<div class="product-card">
<h2 class="text-xl font-bold text-gray-900">{{ displayTitle }}</h2>
</div>
</template>
Banned patterns:
- โ Options API (except legacy maintenance)
- โ
this.$xxxx - โ Ternary expressions deeper than one level in templates โ use computed instead
Tailwind Traps We Fell Into
Trap 1: Gradient Text Needs the Webkit Prefix
Tailwind's bg-clip-text doesn't reliably work across all browsers. The gradient renders as a solid color block covering the text:
<!-- โ Tailwind only โ solid color block in some browsers -->
<span class="bg-gradient-to-r from-orange-400 to-red-500 bg-clip-text text-transparent">
Headline Text
</span>
<!-- โ
Inline style with webkit prefix โ actually works -->
<span style="background: linear-gradient(to right, #FB923C, #EF4444);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;">
Headline Text
</span>
Yes, this is the one place we allow inline styles. It's our only sanctioned exception.
Trap 2: Never Hardcode Colors
/* โ Hardcoded โ redesign nightmare */
.card { background-color: #0F172A; }
/* โ
CSS variables โ dark mode ready */
:root {
--color-bg-card: #FFFFFF;
}
.dark {
--color-bg-card: #0F172A;
}
.card { background-color: var(--color-bg-card); }
Map variables to Tailwind config too:
// tailwind.config.js
theme: {
extend: {
colors: {
'bg-card': 'var(--color-bg-card)',
}
}
}
Now bg-bg-card is a real Tailwind class, and changing the theme means changing one CSS variable.
Trap 3: Scroll Animations on Mobile
We used IntersectionObserver for fade-in animations. Looked perfect on desktop. On mobile, the content never appeared โ users scrolled and saw nothing. The observer callbacks were firing inconsistently on low-end Android devices.
Rule: visible by default, JS enhancement only.
/* โ
Default: visible */
.reveal { opacity: 1; transition: opacity 0.5s; }
/* Only hidden when JS is ready */
.reveal.js-ready { opacity: 0; }
.reveal.js-ready.is-visible { opacity: 1; }
Users without JavaScript (or with slow JS execution) still see the content.
Internationalization: Every String Goes Through $t()
Our projects need Chinese, English, and Traditional Chinese. The most painful failure: a few button labels were hardcoded Chinese in a component โ no i18n, just a raw string in the template. The English version showed Chinese buttons. The boss took a screenshot. Silence.
Every user-visible string goes through $t():
<!-- โ Hardcoded -->
<button>็ซๅณๅผๅง</button>
<!-- โ
Localized -->
<button>{{ $t('common.cta.start') }}</button>
Organize i18n files by page module:
{
"common": {
"cta": {
"start": "Get Started",
"learn": "Learn More"
}
},
"pricing": {
"title": "Pricing",
"monthly": "Monthly billing"
}
}
Persist language choice to localStorage โ no one wants to see their language reset on every page refresh.
SEO: The SPA Blind Spot
The biggest SEO problem with Vue 3 SPA is that crawlers get an empty HTML shell. The JavaScript hasn't run, so there's no content to index.
Short-term: vite-plugin-prerender
Pre-render the critical pages at build time:
// vite.config.js
import PrerenderPlugin from 'vite-plugin-prerender'
export default defineConfig({
plugins: [
PrerenderPlugin({
routes: ['/', '/pricing', '/features', '/about'],
renderer: '@prerenderer/renderer-puppeteer'
})
]
})
Every page needs head management:
import { useHead } from '@unhead/vue'
useHead({
title: computed(() => t('page.pricing.title') + ' | SmallFireDragon'),
meta: [
{ name: 'description', content: computed(() => t('page.pricing.meta')) },
{ property: 'og:title', content: computed(() => t('page.pricing.title')) }
]
})
Long-term: Nuxt 3 SSR
For high-traffic, SEO-critical projects, migrate to Nuxt 3. We've written a full 4-phase migration roadmap โ when the project scale warrants it, the path is already mapped.
Design Spec First, Code Second
This was the most expensive lesson.
We told developers to "implement based on the mockup." No design spec. The result: padding was sometimes 8px, sometimes 12px, sometimes 16px. Font sizes were 14, 15, 16px across the same project. Button border-radius was 4px in one component, 8px in another. Nothing was consistent.
Rule: design spec ships before the first line of code.
The spec must include:
## Color System
- Primary: #1B3A6B
- Accent: #10B981 (CTA buttons)
- Background: #FFFFFF / #F8FAFC
- Text: #111827 / #6B7280
## Spacing Scale
- Base unit: 4px
- Common values: 8 / 12 / 16 / 24 / 32 / 48px
- Component padding: 16px
- Card gap: 24px
## Typography
- Headings: Inter 700, 24/32/40px
- Body: Inter 400, 16px, line-height 1.6
- Caption: Inter 500, 14px
## Component Tokens
- Button border-radius: 8px
- Card shadow: 0 1px 3px rgba(0,0,0,0.1)
- Transition: 0.2s ease
With this document, developers follow the rules. Design review compares against the spec, not against someone's gut feeling.
API Integration: One Instance, One Pattern
// src/utils/request.js
import { ofetch } from 'ofetch'
const api = ofetch.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
onRequest({ options }) {
const token = localStorage.getItem('token')
if (token) {
options.headers.Authorization = `Bearer ${token}`
}
},
onResponseError({ response }) {
if (response.status === 401) {
router.push('/login')
}
}
})
export default api
Standardized response shape:
{ "success": true, "data": { ... } }
{ "success": false, "error": "Invalid token" }
The interceptor handles success: false globally. Business components only deal with data.
One Non-Negotiable Nginx Rule
Vue Router in history mode will 404 on direct URL access unless you add this:
location / {
try_files $uri $uri/ /index.html;
}
Every single SPA deployment must have this. Check it every time. We've forgotten it more than once.
Final Word
Standards don't emerge from planning sessions. They get written one incident at a time โ gradient text that showed a color block, English pages with Chinese labels, a redesign that missed half the site.
We turned those failures into frontend-dev-standard.md โ a mandatory reference for every frontend developer on the team. The result: new team members don't have to fall into the same holes. They just follow the rules.
This article is the plain-English version of those rules.
The traps exist whether you read this or not. Better to know before you step in them.