fj-vue
Personal conventions for building Vue 3 + TypeScript frontends with a consistent architecture across projects.
Works with
Agent Skills format with YAML frontmatter. Claude Code reads it as-is.
---
name: "fj-vue"
description: "Personal conventions for building Vue 3 + TypeScript frontends with a consistent architecture across projects."
license: "MIT"
---
# FJ's Vue Frontend Conventions
Personal conventions for building Vue 3 + TypeScript frontends with a consistent architecture across projects.
This skill documents the patterns I use in all my Vue applications. Project-specific skills
(e.g., `samac-frontend`) further specialize these conventions — when a project-specific skill exists,
it wins in any conflict.
---
## 0. Base Skills
This skill layers on top of `vue-best-practices`. Load that too
and follow its guidance **except** where this skill explicitly overrides.
**Precedence chain:** Project-specific skill > `fj-vue` > `vue-best-practices`
Key overrides from this skill:
- Pinia stores use the **Options Store** pattern (not Setup Stores)
- Scoped `<style scoped>` blocks for component-specific styling; global CSS in `assets/css/`
- 2-tab indentation instead of default formatting
- API calls always go through a custom static `ApiService` class, never raw Axios
---
## 1. Universal Stack
Every Vue project uses this foundation:
| Layer | Technology |
|---|---|
| Framework | Vue 3 (Composition API) |
| Language | TypeScript (strict where possible) |
| Build | Vite |
| Styling | Scoped `<style scoped>` + global CSS in `assets/css/` |
| State management | Pinia — **Options Store** pattern |
| Routing | Vue Router |
| HTTP | Custom `ApiService` static class (Axios wrapper) |
| Persistence | `js-cookie` for auth state |
| Env config | Static `Env` class or `getEnv()` helper |
| SEO | Static `SEOService` class |
Libraries that **vary by project** (see Section 12 — Decision Matrix):
- UI component library (Shadcn-vue, custom, etc.)
- Form validation (VeeValidate, FormKit, manual)
- Icons (@remixicon/vue, custom SVG components)
- Data models (class-transformer, plain classes, interfaces)
- Utilities (VueUse, date-fns, etc.)
---
## 2. Project Structure
All Vue projects follow this directory layout:
```
src/
├── App.vue — Root component, initializes auth/stores
├── main.ts — Entry point, plugin registration
├── env.ts — Environment configuration
├── assets/
│ ├── css/ — Global/shared CSS (component styles use <style scoped>)
│ │ └── main.css — Master import file for global styles
│ └── img/ — Images (.webp), vectors (.svg)
├── components/
│ ├── ui/ — Base UI components (library or custom)
│ └── app/ — Custom project-specific components
│ └── layout/ — Layout shell components
├── helpers/ — Pure utility functions (or lib/helpers/)
├── router/
│ └── index.ts — Route definitions + guards
├── services/
│ ├── ApiService.ts — HTTP wrapper (ALL API calls)
│ └── SEOService.ts — Meta tag management
├── stores/
│ ├── models/ — Data models / DTOs
│ ├── userstore.ts — Auth/user state
│ └── [feature]store.ts — Feature-scoped stores
└── views/ — Route-level page components
└── [feature]/ — Feature folder
```
**Placement rules:**
- New shared types → `helpers/types.ts` or `lib/types/`
- New utility functions → `helpers/` and re-export from a barrel file
- New composables (if the project uses them) → `lib/composables/`
- New pages → `views/[feature]/`
- New reusable components → `components/app/[feature]/`
---
## 3. Code Style
These rules are **mandatory** across all projects:
- **Indentation:** 2 tab characters (not spaces, not 1 tab, not 4 spaces)
- **Semicolons:** every statement ends with `;`
- **Naming:**
- Vue components, classes, Pinia stores → `PascalCase`
- Other `.ts` files (helpers, composables, services) → `kebab-case`
- Asset and image files → `snake_case`
- **No `var`** — always `let` or `const`
- **Minimize `any`** — find or create a proper type
- **Use `<style scoped>`** for component-specific styles; global CSS belongs in `assets/css/`
- **No pixels in CSS** — use `rem`, `em`, `%`, `vh`, `vw`, `dvh`, `dvw`
- **Images:** `.webp` for photos, `.svg` for vector graphics
- **Use enums** over string literals where applicable
---
## 4. SFC Pattern
```vue
<script lang="ts" setup>
import { ref, computed } from "vue";
import { useUserStore } from "@/stores/userstore";
import MyChild from "@/components/app/MyChild.vue";
import type { MyType } from "@/helpers/types";
const props = defineProps<{ title: string; }>();
const emit = defineEmits<{ save: [value: string]; }>();
const userStore = useUserStore();
const count = ref(0);
const doubled = computed(() => count.value * 2);
</script>
<template>
<MyChild :title="props.title" @save="emit('save', 'value')" />
</template>
<style scoped>
.my-component {
padding: 1rem;
}
</style>
```
**Import order:** Vue/libs → stores → components → types. Always use `@/` alias, never `../../`.
---
## 5. ApiService
A static class wrapping Axios that handles auth headers, response parsing, and 401 expiration.
The class name varies (`ApiService` or `BackendApi`) but the shape is identical across projects.
### Core Shape
```typescript
import type { AxiosResponse, RawAxiosRequestHeaders } from "axios";
import { Axios } from "axios";
import { Result } from "@/helpers/result";
import { Env } from "@/env";
import { useUserStore } from "@/stores/userstore";
import type { Router } from "vue-router";
const axios = new Axios({});
export class ApiService {
static router: Router; // Set from App.vue (useRouter() only works in setup)
static authToken: string = '';
static setAuthToken(token: string) { ApiService.authToken = token; }
static async _request(
endpoint: string,
method: "GET" | "POST" | "DELETE" | "PUT" | "PATCH",
params?: any,
data?: object,
isFileUpload: boolean = false,
) {
const headers: RawAxiosRequestHeaders = isFileUpload
? { 'Content-Type': 'multipart/form-data', 'Accept': 'application/json' }
: { 'Content-Type': 'application/json', 'Accept': 'application/json' };
if (ApiService.authToken) headers.Authorization = 'Bearer ' + ApiService.authToken;
const resp: AxiosResponse = await axios.request({
url: endpoint, baseURL: Env.BACKEND_URL, method, params,
data: isFileUpload ? data : JSON.stringify(data), headers,
});
try {
if (resp.status == 401) {
const userStore = useUserStore();
await userStore.logout(false);
return new Result(false, 'Unauthorized', null, 401);
}
const parsed: any = JSON.parse(resp.data);
if (parsed?.success !== undefined) {
return new Result(parsed.success, parsed.msg ?? parsed.message ?? '', parsed.data, resp.status);
}
return new Result(false, parsed.msg ?? parsed.message ?? '', parsed, resp.status);
} catch (e) {
return new Result(false, 'Invalid response from server', resp.data, resp.status);
}
}
}
```
### Result Class
```typescript
export class Result {
constructor(
public success: boolean,
public msg: string = '',
public data: any = null,
public statusCode: number = 0,
) {}
}
```
### Usage
```typescript
// In a store action:
const result = await ApiService._request('/users/search', 'POST', null, payload);
if (result.success && result.data?.users) {
this.users = result.data.users;
} else {
this.error = result.msg || 'Failed to fetch users';
}
// File upload — pass isFileUpload: true with FormData:
const formData = new FormData();
formData.append('file', file, file.name);
await ApiService._request('/documents/upload', 'POST', null, formData, true);
```
---
## 6. Pinia Stores (Options Store)
All stores use the **Options Store** format — do not use Setup Stores.
```typescript
import { defineStore, acceptHMRUpdate } from "pinia";
import { ApiService } from "@/services/apiservice";
export const useMyFeatureStore = defineStore("my-feature", {
state: (): { items: MyModel[]; isLoading: boolean; error: string | null } => ({
items: [],
isLoading: false,
error: null,
}),
getters: {
itemCount(): number { return this.items.length; },
},
actions: {
async fetchItems() {
this.isLoading = true;
this.error = null;
try {
const result = await ApiService._request('/my-feature', 'GET');
if (result.success && result.data?.items) {
this.items = result.data.items;
} else {
this.error = result.msg || 'Failed to fetch items';
}
} catch (err) {
this.error = (err as Error).message;
} finally {
this.isLoading = false;
}
},
},
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useMyFeatureStore, import.meta.hot));
}
```
**Patterns:**
- Every store has `isLoading` + `error` state fields
- Every async action uses try/catch/finally with `this.isLoading` in `finally`
- Always check `result.success` before reading `result.data`
- Always include `acceptHMRUpdate` at the end
- Views are thin composition surfaces — business logic belongs in stores
- **Per-action loading states:** for mutations that run while the list stays visible (e.g., marking
one item as read), add a dedicated flag like `isMarkingRead: boolean` rather than reusing the
global `isLoading` — prevents the whole UI from freezing during a single-item operation
---
## 7. Auth & Cookie Persistence
Auth state lives in `js-cookie` with secure settings. The user store rehydrates on creation:
```typescript
import Cookies from "js-cookie";
state: () => {
const storedData = Cookies.get('userStore');
const parsedData = storedData ? JSON.parse(storedData) : null;
return {
loggedIn: !!storedData,
authToken: parsedData?.authToken || '',
currentUser: parsedData?.currentUser || null,
};
},
actions: {
saveToCookie() {
Cookies.set('userStore', JSON.stringify({
authToken: this.authToken, currentUser: this.currentUser,
}), { secure: true, sameSite: "strict", expires: 1, path: '/' });
},
clearCookie() {
Cookies.remove('userStore', { secure: true, sameSite: "strict", path: '/' });
},
async logout(sendApiRequest: boolean = true) {
if (sendApiRequest) await ApiService._request('/profile/logout', 'POST');
this.loggedIn = false;
this.authToken = '';
ApiService.setAuthToken('');
this.currentUser = null;
this.clearCookie();
},
}
```
**App startup** (`App.vue` setup): set `ApiService.setAuthToken(userStore.authToken)` and
`ApiService.router = router`.
**Anti-pattern:** Never import `js-cookie` directly in a component. Cookie reads/writes belong in
the user store's `saveToCookie()` / `clearCookie()` actions — views call those methods.
---
## 8. Router Conventions
```typescript
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: "/login", name: "login", component: () => import("@/views/public/LoginView.vue"),
meta: { title: "Login", requiresAuth: false } },
{ path: "/dashboard", name: "dashboard", component: () => import("@/views/DashboardView.vue"),
meta: { title: "Dashboard", requiresAuth: true } },
],
});
// Auth guard
router.beforeEach((to, from, next) => {
const userStore = useUserStore();
to.meta.requiresAuth && !userStore.loggedIn ? next({ name: "login" }) : next();
});
// SEO
router.afterEach((to) => {
if (to.meta.title) SEOService.setMetaTags({ title: to.meta.title as string });
});
```
Every route must have `meta.requiresAuth`. Use lazy imports for all page components.
---
## 9. Form Field Components
All projects build custom wrapper components for form inputs. The shape is consistent regardless
of validation library — props for `label`, `error`, `required`, `helperText` plus `defineModel`:
```vue
<script lang="ts" setup>
defineOptions({ inheritAttrs: false });
const props = defineProps<{ label: string; error?: string; required?: boolean; }>();
const model = defineModel<string>();
</script>
<template>
<div class="form-field">
<label class="form-field__label">{{ label }}<span v-if="required" class="form-field__required"> *</span></label>
<input v-model="model" v-bind="$attrs" class="form-field__input" />
<p v-if="error" class="form-field__error">{{ error }}</p>
</div>
</template>
<style scoped>
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field__label {
font-size: 0.875rem;
font-weight: 500;
}
.form-field__required {
color: var(--color-error, red);
}
.form-field__input {
border-radius: 0.25rem;
border: 1px solid var(--color-border, #ccc);
padding: 0.5rem 0.75rem;
}
.form-field__error {
font-size: 0.875rem;
color: var(--color-error, red);
}
</style>
```
Standard set: `TextField`, `SelectField`, `CheckboxField`, `TextAreaField`, `DateField`.
---
## 10. Env & Configuration
Environment values are exposed via a static class:
```typescript
export class Env {
static DEBUG: boolean = import.meta.env.DEV;
static BACKEND_URL: string = import.meta.env.APP_BACKEND_URL ?? '';
static MAX_STORAGE_TIME: number = 7;
}
```
Or via a typed helper (newer pattern):
```typescript
export function getEnv(key: string): string {
return import.meta.env[key] ?? '';
}
```
**Vite env prefix:** Use `APP_` (e.g., `APP_BACKEND_URL`) so Vite exposes them to the client.
**Required env files:**
- `.env` — local defaults
- `.env.production` — production overrides
- `.env.staging` — staging overrides (if applicable)
---
## 11. SEO Service
```typescript
export interface SEOAttributes { title: string; description?: string; }
export class SEOService {
static fallbackTitle: string = 'My App';
static setMetaTags(attrs: SEOAttributes) {
document.title = attrs.title ? `${attrs.title} | ${SEOService.fallbackTitle}` : SEOService.fallbackTitle;
if (attrs.description) {
document.querySelector('meta[name="description"]')?.setAttribute('content', attrs.description);
}
}
}
```
---
## 12. Decision Matrix
These aspects vary between projects. Resolve them at project kickoff:
| Decision | Option A | Option B | Option C | New project default |
|---|---|---|---|---|
| **UI Components** | Shadcn-vue | Custom components | — | Shadcn-vue |
| **Form Validation** | VeeValidate | FormKit | Manual | VeeValidate |
| **Icons** | @remixicon/vue (Line) | Custom SVG components | — | @remixicon/vue |
| **Data Models** | class-transformer | Plain classes | Interfaces only | Per project complexity |
| **Composables** | Heavy reuse (useCrud) | Minimal (logic in stores) | — | As needed |
| **Analytics** | Google Tag Manager | None | — | GTM if client requires |
| **Build splitting** | Manual chunks | Default Vite | — | Default Vite |
### Guidance
- **Shadcn-vue** for data-heavy apps (tables, dialogs, sheets). **Custom** for unique designs.
- **VeeValidate** for complex multi-form apps. **FormKit** when forms are the core product. **Manual** for simple apps.
- **@remixicon/vue** (`Line` variants) for speed. **Custom SVG** when brand requires unique icons.
- **class-transformer** for enterprise apps with complex models. **Plain classes** for moderate apps. **Interfaces** for simple API layers.
---
## 13. New Project Scaffolding
1. **Create:** `npm create vue@latest` → select TypeScript, Vue Router, Pinia (skip ESLint)
2. **Core deps:** `npm install axios js-cookie && npm install -D @types/js-cookie`
4. **Decide** (Section 12): UI library, form library, icons, model approach — then install only those
- Shadcn-vue: `npx shadcn-vue@latest init` (this handles Reka-UI/Radix internally — do NOT install `radix-vue` separately)
- VeeValidate: `npm install vee-validate @vee-validate/rules` (does NOT need `yup`)
- @remixicon/vue: `npm install @remixicon/vue`
5. **Create core files** using templates from this skill:
- `src/env.ts`, `src/helpers/result.ts`, `src/services/apiservice.ts`
- `src/services/seoservice.ts`, `src/stores/userstore.ts`, `src/assets/css/main.css`
6. **Wire up:** Register plugins in `main.ts`, set `ApiService.router` + rehydrate token in
`App.vue`, add auth guard + SEO hook to router
---
## 14. Verification Checklist
Before finishing any Vue frontend task, verify:
- [ ] `<script lang="ts" setup>` — Composition API, no Options API in components
- [ ] Indentation: 2 tab characters
- [ ] All statements end with `;`
- [ ] `<style scoped>` used for component styles; global CSS in `assets/css/`
- [ ] No `var` — only `let` / `const`
- [ ] No `any` types without justification
- [ ] No pixel units in CSS — use rem, em, %, etc.
- [ ] Import aliases used (`@/`, `@models/` if configured)
- [ ] API calls go through `ApiService`, not raw Axios
- [ ] Stores use Options Store pattern (`state`, `getters`, `actions`)
- [ ] Auth state persisted via `js-cookie`
- [ ] New stores include `acceptHMRUpdate` at bottom
- [ ] Result checked with `if (result.success)` before using `.data`
- [ ] Route meta includes `requiresAuth` for authenticated pages
- [ ] Files named in correct case convention
- [ ] Images in `.webp` / `.svg` format
- [ ] No Tailwind — use scoped CSS or global CSSMore Frontend Frameworks skills
frontend-design
anthropics/skills
Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.
design-taste-frontend
leonxlnx/taste-skill
Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check.
hyperframes-creative
heygen-com/hyperframes
Non-animation creative direction for HyperFrames videos. Use for design spec (frame.md / design.md) handling, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns, and brand / style decisions. For atomic motion patterns and scene blueprints, use hyperframes-animation.

