Customizing Generated Code

Custom Components

Integrate advanced UI components like image uploads, rich text editors, and multi-step forms

Extend your forms with advanced UI components for richer user experiences.

Add Image Upload

Add file upload capabilities to your forms with preview and custom storage integration.

<script setup lang="ts">
const uploadingImage = ref(false)

const handleImageUpload = async (file: File) => {
  uploadingImage.value = true

  // Upload to your storage (Cloudinary, S3, etc.)
  const formData = new FormData()
  formData.append('file', file)

  const { url } = await $fetch('/api/upload', {
    method: 'POST',
    body: formData
  })

  state.value.imageUrl = url
  uploadingImage.value = false
}
</script>

<template>
  <UFormField label="Product Image" name="imageUrl">
    <img v-if="state.imageUrl" :src="state.imageUrl" class="w-32 h-32" />

    <input
      ref="fileInput"
      type="file"
      accept="image/*"
      class="hidden"
      @change="handleImageUpload($event.target.files[0])"
    />

    <UButton @click="$refs.fileInput.click()" :loading="uploadingImage">
      Upload Image
    </UButton>
  </UFormField>
</template>

With Image Preview

Add a preview before upload:

<script setup lang="ts">
const imagePreview = ref<string | null>(null)
const uploadingImage = ref(false)

const handleImageSelect = (event: Event) => {
  const file = (event.target as HTMLInputElement).files?.[0]
  if (!file) return

  // Show preview
  const reader = new FileReader()
  reader.onload = (e) => {
    imagePreview.value = e.target?.result as string
  }
  reader.readAsDataURL(file)

  // Upload
  uploadImage(file)
}

const uploadImage = async (file: File) => {
  uploadingImage.value = true
  const formData = new FormData()
  formData.append('file', file)

  const { url } = await $fetch('/api/upload', {
    method: 'POST',
    body: formData
  })

  state.value.imageUrl = url
  uploadingImage.value = false
}
</script>

<template>
  <UFormField label="Product Image" name="imageUrl">
    <div v-if="imagePreview" class="mb-4">
      <img :src="imagePreview" class="w-full max-w-md rounded-lg" />
    </div>

    <input
      ref="fileInput"
      type="file"
      accept="image/*"
      class="hidden"
      @change="handleImageSelect"
    />

    <UButton @click="$refs.fileInput.click()" :loading="uploadingImage">
      {{ imagePreview ? 'Change Image' : 'Upload Image' }}
    </UButton>
  </UFormField>
</template>

Multiple Images

Handle multiple image uploads:

<script setup lang="ts">
const images = ref<string[]>([])
const uploadingImages = ref(false)

const handleMultipleImages = async (event: Event) => {
  const files = Array.from((event.target as HTMLInputElement).files || [])
  uploadingImages.value = true

  for (const file of files) {
    const formData = new FormData()
    formData.append('file', file)

    const { url } = await $fetch('/api/upload', {
      method: 'POST',
      body: formData
    })

    images.value.push(url)
  }

  state.value.images = images.value
  uploadingImages.value = false
}

const removeImage = (index: number) => {
  images.value.splice(index, 1)
  state.value.images = images.value
}
</script>

<template>
  <UFormField label="Product Images" name="images">
    <div v-if="images.length" class="grid grid-cols-3 gap-4 mb-4">
      <div v-for="(image, index) in images" :key="index" class="relative">
        <img :src="image" class="w-full h-32 object-cover rounded" />
        <UButton
          size="xs"
          color="red"
          class="absolute top-1 right-1"
          @click="removeImage(index)"
        >
          Remove
        </UButton>
      </div>
    </div>

    <input
      ref="fileInput"
      type="file"
      accept="image/*"
      multiple
      class="hidden"
      @change="handleMultipleImages"
    />

    <UButton @click="$refs.fileInput.click()" :loading="uploadingImages">
      Add Images
    </UButton>
  </UFormField>
</template>

Add Rich Text Editor

Nuxt Crouton provides an optional rich text editor layer powered by TipTap, perfect for blog posts, content management, and text-heavy forms.

Quick Setup

1. Install the editor package:

pnpm add @friendlyinternet/nuxt-crouton-editor @nuxt/icon

2. Configure Nuxt:

nuxt.config.ts
export default defineNuxtConfig({
  extends: [
    '@friendlyinternet/nuxt-crouton',
    '@friendlyinternet/nuxt-crouton-editor'  // Add this layer
  ]
})

3. Use in Forms:

<script setup lang="ts">
const state = ref({
  title: '',
  content: '<p></p>'
})
</script>

<template>
  <UFormField label="Content" name="content">
    <EditorSimple v-model="state.content" />
  </UFormField>
</template>

Generator Integration

Automatically use the editor for specific fields by marking them in your schema:

schemas/post-schema.json
{
  "title": {
    "type": "string",
    "meta": {
      "required": true,
      "label": "Post Title"
    }
  },
  "content": {
    "type": "text",
    "meta": {
      "component": "EditorSimple",
      "label": "Post Content"
    }
  }
}

When you generate this collection, the content field will automatically use EditorSimple.

Features Included

The editor includes:

  • Text Formatting: Bold, italic, strikethrough
  • Headings: H1, H2, H3
  • Lists: Bullet points and numbered lists
  • Code Blocks: Inline code and code blocks
  • Blockquotes: Quote formatting
  • Text Colors: Custom text coloring
  • Floating Toolbar: Appears on text selection
  • Dark Mode: Automatic dark mode support
  • Keyboard Shortcuts: Standard shortcuts (Cmd+B for bold, etc.)

Database Storage

The editor outputs HTML. Store it in a TEXT field:

// Drizzle schema
export const blogPosts = sqliteTable('blog_posts', {
  id: text('id').primaryKey(),
  title: text('title').notNull(),
  content: text('content').notNull(),  // HTML from editor
  createdAt: integer('createdAt', { mode: 'timestamp' })
})

Display Rendered Content

Query Examples: For complete useCollectionQuery patterns, see Querying Data.

Render the HTML safely on your pages:

<script setup lang="ts">
const { items: posts } = await useCollectionQuery('blogPosts')
</script>

<template>
  <div v-for="post in posts" :key="post.id">
    <h1>{{ post.title }}</h1>
    <!-- Render HTML (ensure it's sanitized on the backend!) -->
    <div class="prose" v-html="post.content" />
  </div>
</template>

Security: Always sanitize HTML on the backend before saving to prevent XSS attacks. Use a library like sanitize-html.

Best Practices

✅ DO:

  • Sanitize HTML on the backend before storing
  • Use the prose class (Tailwind Typography) for consistent rendering
  • Mark editor fields in your schema for automatic generation
  • Store editor content in a TEXT database field

❌ DON'T:

  • Render unsanitized HTML (XSS risk)
  • Store editor content in a VARCHAR (may truncate)
  • Forget to add @nuxt/icon dependency

Add Multi-Step Form

Break complex forms into multiple steps for better UX.

<script setup lang="ts">
const currentStep = ref(1)
const totalSteps = 3

const nextStep = () => {
  if (currentStep.value < totalSteps) {
    currentStep.value++
  }
}

const prevStep = () => {
  if (currentStep.value > 1) {
    currentStep.value--
  }
}

const handleSubmit = async () => {
  if (currentStep.value < totalSteps) {
    nextStep()
  } else {
    // Final submit
    await create(state.value)
    close()
  }
}
</script>

<template>
  <UForm @submit="handleSubmit">
    <!-- Step indicator -->
    <div class="flex justify-between mb-4">
      <div v-for="step in totalSteps" :key="step"
           :class="{ 'font-bold': step === currentStep }">
        Step {{ step }}
      </div>
    </div>

    <!-- Step 1: Basic info -->
    <div v-if="currentStep === 1">
      <UFormField label="Name" name="name">
        <UInput v-model="state.name" />
      </UFormField>
    </div>

    <!-- Step 2: Details -->
    <div v-if="currentStep === 2">
      <UFormField label="Description" name="description">
        <UTextarea v-model="state.description" />
      </UFormField>
    </div>

    <!-- Step 3: Pricing -->
    <div v-if="currentStep === 3">
      <UFormField label="Price" name="price">
        <UInput v-model.number="state.price" type="number" />
      </UFormField>
    </div>

    <!-- Navigation -->
    <div class="flex justify-between">
      <UButton v-if="currentStep > 1" @click="prevStep" variant="ghost">
        Back
      </UButton>
      <UButton type="submit">
        {{ currentStep < totalSteps ? 'Next' : 'Submit' }}
      </UButton>
    </div>
  </UForm>
</template>

With Progress Bar

Add visual progress indication:

<template>
  <UForm @submit="handleSubmit">
    <!-- Progress bar -->
    <div class="mb-6">
      <div class="flex justify-between mb-2">
        <span class="text-sm font-medium">Progress</span>
        <span class="text-sm text-gray-500">{{ currentStep }}/{{ totalSteps }}</span>
      </div>
      <div class="w-full bg-gray-200 rounded-full h-2">
        <div
          class="bg-primary-500 h-2 rounded-full transition-all"
          :style="{ width: `${(currentStep / totalSteps) * 100}%` }"
        />
      </div>
    </div>

    <!-- Step content -->
    <div v-if="currentStep === 1">
      <!-- Step 1 fields -->
    </div>

    <!-- Navigation -->
    <div class="flex justify-between mt-6">
      <UButton v-if="currentStep > 1" @click="prevStep" variant="ghost">
        Back
      </UButton>
      <UButton type="submit" :loading="loading">
        {{ currentStep < totalSteps ? 'Next' : 'Submit' }}
      </UButton>
    </div>
  </UForm>
</template>

With Validation Per Step

Validate each step before proceeding:

<script setup lang="ts">
import { z } from 'zod'

const currentStep = ref(1)

// Define validation for each step
const step1Schema = z.object({
  name: z.string().min(1, 'Name is required'),
  sku: z.string().min(1, 'SKU is required')
})

const step2Schema = z.object({
  description: z.string().min(10, 'Description must be at least 10 characters')
})

const step3Schema = z.object({
  price: z.number().min(0, 'Price must be positive')
})

const validateCurrentStep = async () => {
  try {
    if (currentStep.value === 1) {
      await step1Schema.parseAsync(state.value)
    } else if (currentStep.value === 2) {
      await step2Schema.parseAsync(state.value)
    } else if (currentStep.value === 3) {
      await step3Schema.parseAsync(state.value)
    }
    return true
  } catch (error) {
    // Show validation errors
    console.error(error)
    return false
  }
}

const handleSubmit = async () => {
  const isValid = await validateCurrentStep()
  if (!isValid) return

  if (currentStep.value < totalSteps) {
    nextStep()
  } else {
    await create(state.value)
    close()
  }
}
</script>

Best Practices

Component Organization

Keep custom components reusable:

layers/shop/components/
  ├── products/
  │   ├── Form.vue
  │   └── List.vue
  └── shared/
      ├── ImageUpload.vue      # Reusable component
      ├── RichTextEditor.vue   # Reusable component
      └── MultiStepForm.vue    # Reusable component

Error Handling

Always handle errors in custom components:

<script setup lang="ts">
const uploadImage = async (file: File) => {
  uploadingImage.value = true

  try {
    const formData = new FormData()
    formData.append('file', file)

    const { url } = await $fetch('/api/upload', {
      method: 'POST',
      body: formData
    })

    state.value.imageUrl = url
  } catch (error) {
    console.error('Upload failed:', error)
    // Show error toast
    toast.add({
      title: 'Upload Failed',
      description: 'Failed to upload image. Please try again.',
      color: 'red'
    })
  } finally {
    uploadingImage.value = false
  }
}
</script>

Performance Considerations

  • Debounce rich text editor updates
  • Lazy load heavy components
  • Optimize image uploads (compress, resize)
  • Cache editor instances when possible