fj-vue

Personal conventions for building Vue 3 + TypeScript frontends with a consistent architecture across projects.

fjlessing/agent-skills2 installsMITSynced Aug 27

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI

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 CSS

More Frontend Frameworks skills

← All Frontend Frameworks skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY