Skip to content

Best practices ​

Ten rules the codebase already keeps. Following them means your code and the template's stay one codebase.

Change config, not engines ​

A component sets a config value. A watcher in setupConfigWiring calls the engine.

ts
import { useConfig } from '@core/config/useConfig'

useConfig().mergeConfig({ theme: { primary: '#7c3aed' } })

Calling useTheme().setPrimary() from a component works for one frame and then disagrees with what was persisted, because the store never learned about it. One pathway down, and the screen and the saved config cannot drift.

Keep modules independent ​

A module imports from @core, @shared, @app and its own folder. It does not reach into another module's api/, stores/ or pages/. Anything two modules need belongs in shared.

This is why the CASL ability lives in @shared/lib/ability rather than in the auth module: every module needs can(), and importing auth for it would break the rule on the first page.

Every request goes through the shared instance ​

ts
import { api } from '@shared/lib/axios'

Never axios.create again, and never fetch for an API call. The shared instance carries withCredentials, the CSRF header and the 401 refresh queue. A raw call skips all three, and the bug shows up as a random sign-out an hour later.

One place decides what a failure looks like ​

ts
const { handleError } = useApiError()

try {
  await productsApi.create(values)
}
catch (error) {
  const fieldErrors = handleError(error)
  if (fieldErrors)
    serverErrors.value = fieldErrors
}

handleError toasts every status in the reader's language and returns an array only for 422, so field errors land under their fields. Writing toast.error('Failed to save') in a module gives that one screen a different voice from the rest of the app, in English only.

Let the schema do the work ​

Describe an entity once as a FieldDefinition[]. SchemaForm builds the form, schemaToColumns builds the table, and generateZodSchema builds the validation. Add a field in one place and all three follow.

ts
export const productFormSchema: FieldDefinition[] = [
  { key: 'name', type: 'text', label: 'products.fields.name', required: true },
  { key: 'price', type: 'number', label: 'products.fields.price', required: true },
]

Hand-writing a form beside a schema is how the two drift, and the drift is silent.

Labels are translation keys ​

Every user-visible string is a key, resolved where it renders.

ts
{ key: 'name', type: 'text', label: 'products.fields.name' }

uiText returns an unknown key verbatim, which is what lets a literal English label work — and also what makes a typo render as products.fields.nmae with nothing in the console. Run npm run check:i18n before you commit; it is the only check that catches it.

Wrap CRUD in one composable per module ​

ts
export function useProducts() {
  return useCrud<Product>({
    entity: 'product',
    entityLabel: 'products.entity',
    baseUrl: '/products',
    schema: productFormSchema,
    primaryKey: 'id',
    pagination: { perPage: 10 },
  })
}

Pages then read items, loading, pagination and filters and call create, update and remove. Toasts, the activity log, pre-flight validation and optimistic updates come with it.

entity is data, not a label: it reaches the activity log, so it stays 'product' in every language. entityLabel is the key with plural branches that the toasts read.

Brand your identifiers ​

ts
export type ProductId = Brand<string, 'ProductId'>

With plain strings, assignRoleToUser(roleId, userId) type-checks. With branded types it does not. The brand is structural only and does not exist at runtime.

Check permissions in three places, trust none of them ​

A route's meta.permission for navigation, NavItem.permission for the menu, v-permission for a control. All three run in the browser, in a bundle anyone can edit. Your backend authorizes the request; these only stop people being shown what they cannot use.

Design for dark mode and right-to-left as you go ​

Use the theme tokens rather than raw colors, and logical properties rather than left and right.

vue
<template>
  <div class="bg-card text-card-foreground ps-4 border-s">
    <p class="text-muted-foreground">
      Both modes, both directions.
    </p>
  </div>
</template>

ps-4 and border-s follow the reading direction; pl-4 and border-l do not. Switching the app to Arabic is the fastest test of a new screen, and it takes one click in the settings drawer.

Before you commit ​

bash
npm run lint
npm run type-check
npm run check:i18n

Add your own test run to this list once you have one. The zip ships no test runner and no git hooks, so wiring these into a pre-commit hook is up to you.

Where to go next ​