Custom Components
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:
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:
{
"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
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
proseclass (Tailwind Typography) for consistent rendering - Mark editor fields in your schema for automatic generation
- Store editor content in a
TEXTdatabase field
❌ DON'T:
- Render unsanitized HTML (XSS risk)
- Store editor content in a
VARCHAR(may truncate) - Forget to add
@nuxt/icondependency
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
Related Topics
- Forms & Modals - Form lifecycle and state management
- Custom Columns - Display custom data in tables