Skip to content

Slice Structure

Every frontend slice in CleanSlice is a Nuxt Layer -- a self-contained module with its own configuration, components, stores, pages, and translations. This page explains how slices are organized and the conventions that make auto-import and composition work seamlessly.

Why Slices as Layers?

Nuxt layers solve a real organizational problem: as an app grows, a flat components/ or pages/ directory becomes unmanageable. By making each feature a layer, you get:

  • Isolation -- Each slice owns its own code and config
  • Auto-import -- Components, stores, and composables are discovered automatically
  • Independent i18n -- Each slice manages its own translations
  • Easy removal -- Delete the folder and the feature is gone

Anatomy of a Slice

Here is the full structure a feature slice can have. Not every slice needs every folder -- use only what you need:

slices/{feature}/
├── nuxt.config.ts          # Required -- registers the layer
├── components/
│   ├── {entity}/
│   │   ├── Provider.vue    # Data fetching wrapper
│   │   ├── Item.vue        # Display component
│   │   ├── Form.vue        # Create/edit form
│   │   └── Menu.vue        # Navigation menu
│   ├── {entity}List/
│   │   ├── Provider.vue    # List data fetching
│   │   └── Thumb.vue       # List item card
│   └── {entity}Create/
│       ├── Provider.vue    # Create form wrapper
│       └── Form.vue        # Create form
├── pages/
│   ├── index.vue           # Route: /{feature}
│   └── [id].vue            # Route: /{feature}/:id
├── stores/
│   └── {feature}.ts        # Pinia store
├── composables/
│   └── use{Feature}.ts     # Composable functions
├── locales/
│   ├── en.json             # English translations
│   └── fr.json             # French translations
├── domain/                 # Clean Architecture domain layer
│   ├── {feature}.gateway.ts
│   ├── {feature}.service.ts
│   └── {feature}.types.ts
├── data/                   # Clean Architecture data layer
│   ├── {feature}.gateway.ts
│   └── mock.gateway.ts
└── plugins/
    ├── di.ts               # DI registration
    └── menu.ts             # Menu registration

The Slice nuxt.config.ts

Every slice needs a nuxt.config.ts to be recognized as a Nuxt layer. This is the minimum viable config:

typescript
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const currentDir = dirname(fileURLToPath(import.meta.url));

export default defineNuxtConfig({
  alias: {
    '#feature': currentDir,
  },
});

To add translations, register the i18n module:

typescript
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const currentDir = dirname(fileURLToPath(import.meta.url));

export default defineNuxtConfig({
  alias: {
    '#feature': currentDir,
  },
  modules: ['@nuxtjs/i18n'],
  i18n: {
    langDir: './locales',
    locales: [
      { code: 'en', file: 'en.json' },
      { code: 'fr', file: 'fr.json' },
    ],
  },
});

The #feature alias lets other slices import from this slice using a clean path like #feature/domain/types.

Auto-Import Rules

Nuxt auto-imports are one of the framework's best features, and understanding what is and is not auto-imported saves you from unnecessary import statements.

WhatAuto-Imported?Example
Vue APIsYesref(), computed(), watch()
Nuxt composablesYesuseRoute(), useFetch(), useAsyncData()
ComponentsYes<Button />, <AccountProvider />
Pinia storesYesuseAuthStore(), useMenuStore()
API SDK types/servicesNo -- use #apiimport { UserDto } from '#api/data'
External librariesNoimport { useForm } from 'vee-validate'
Alias utilitiesNoimport { cn } from '#theme/utils'

TIP

A good rule of thumb: if it comes from Vue, Nuxt, or a slice's components/, composables/, or stores/ folder, it is auto-imported. Everything else needs an explicit import.

Component Standards

The Provider Pattern

Every component folder has a Provider.vue file. The Provider is the data-fetching wrapper that passes data down to child components via props:

vue
<script lang="ts" setup>
import { AuthService } from '#api/data';

const { data, pending, error, refresh } = useAsyncData('account', () =>
  AuthService.me()
);
</script>

<template>
  <div>
    <AccountForm v-if="!pending" :user="data?.data" @update="refresh" />
    <div class="mb-5"></div>
    <AccountItem :pending="pending" :user="data?.data" />
  </div>
</template>

The child components (AccountItem, AccountForm) are pure display and interaction components -- they receive data as props and emit events, but they do not fetch data themselves.

Item Component

Receives data via props. Handles display only:

vue
<script lang="ts" setup>
import type { UserDto } from '#api/data';

defineProps<{ user: UserDto; pending: boolean }>();
</script>

<template>
  <div class="py-2">
    <div class="font-bold">Name</div>
    <Skeleton v-if="!user && pending" class="h-3 mt-2 w-[150px]" />
    <div v-if="user" class="text-sm text-muted-foreground">
      {{ user.name }}
    </div>
  </div>
</template>

Form Component

Handles user input with validation. Emits update on success so the Provider can refresh:

vue
<script lang="ts" setup>
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import * as z from 'zod';
import { UsersService, type UpdateUserDto, type UserDto } from '#api/data';

const props = defineProps<{ user: UserDto }>();
const emits = defineEmits<{ (e: 'update', value: any): void }>();
const loading = ref(false);
const isOpen = ref(false);

const formSchema = z.object({
  name: z.string().describe('Name').default(props.user.name),
  email: z.string().describe('Email').default(props.user.email),
});

const form = useForm({ validationSchema: toTypedSchema(formSchema) });

const submit = form.handleSubmit(async (values) => {
  loading.value = true;
  try {
    const result = await UsersService.updateUser({
      id: props.user.id,
      requestBody: values as UpdateUserDto,
    });
    isOpen.value = false;
    emits('update', result);
  } finally {
    loading.value = false;
  }
});
</script>

<template>
  <Dialog v-model:open="isOpen">
    <DialogTrigger><Button>Edit Profile</Button></DialogTrigger>
    <DialogScrollContent>
      <DialogHeader><DialogTitle>Edit Account</DialogTitle></DialogHeader>
      <form class="space-y-6" @submit="submit">
        <FormField v-slot="{ componentField }" name="name" :value="user.name">
          <FormItem>
            <FormLabel>Name</FormLabel>
            <FormControl>
              <Input v-bind="componentField" type="text" />
            </FormControl>
            <FormMessage />
          </FormItem>
        </FormField>
        <Button type="submit" :loading="loading">Save</Button>
      </form>
    </DialogScrollContent>
  </Dialog>
</template>

Component Naming Convention

Nuxt auto-imports components using the pattern {FolderName}{FileName} in PascalCase:

File PathAuto-Import Name
account/Provider.vueAccountProvider
account/Item.vueAccountItem
account/Form.vueAccountForm
accountList/Provider.vueAccountListProvider
accountList/Thumb.vueAccountListThumb

WARNING

Component folders are limited to one level of nesting. Use combined names (teamList/) instead of nested folders (team/list/).

Using Components in Pages

Pages are thin -- they set metadata and render a Provider:

vue
<script lang="ts" setup>
definePageMeta({
  layout: 'dashboard',
  auth: { public: false },
});
</script>

<template>
  <AccountProvider />
</template>

Component Type Reference

TypePurpose
Provider.vueData fetching and orchestration. Passes data to children via props.
Item.vueDisplays a single entity. Receives props, no data fetching.
Form.vueCreate/edit forms with validation. Emits update on success.
Thumb.vueList item card, typically clickable.
Menu.vueNavigation sub-menu for the entity.
Dropdown.vueSelector dropdown component.

What's Next?

Built with CleanSlice