Documentation is at the heart of the Nuxt developer experience. To continuously improve it, we needed a simple and effective way to collect user feedback directly on each page. Here's how we designed and implemented our feedback widget, drawing inspiration from Plausible's privacy-first approach.
Why a feedback widget?
Currently, users can provide feedback on our documentation by creating GitHub issues or contacting us directly. While these channels are valuable and remain important, they require users to leave their current context and take several steps to share their thoughts.
We wanted something different:
- Contextual: Directly integrated into each documentation page
- Frictionless: Maximum 2 clicks to provide feedback
- Privacy-respecting: No personal tracking, GDPR compliant by design
Technical architecture
Our solution consists of three main components:
1. Frontend with Motion animations
The interface combines Vue 3's Composition API with Motion for Vue to create an engaging user experience. The widget uses layout animations for smooth state transitions and spring physics for natural feedback. The useFeedback
composable handles all state management and automatically resets when users navigate between pages.
Here's the success state animation, for example:
<template>
<!-- ... -->
<motion.div
v-if="isSubmitted"
key="success"
:initial="{ opacity: 0, scale: 0.95 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ duration: 0.3 }"
class="flex items-center gap-3 py-2"
role="status"
aria-live="polite"
aria-label="Feedback submitted successfully"
>
<motion.div
:initial="{ scale: 0 }"
:animate="{ scale: 1 }"
:transition="{ delay: 0.1, type: 'spring', visualDuration: 0.4 }"
class="text-xl"
aria-hidden="true"
>
✨
</motion.div>
<motion.div
:initial="{ opacity: 0, x: 10 }"
:animate="{ opacity: 1, x: 0 }"
:transition="{ delay: 0.2, duration: 0.3 }"
>
<div class="text-sm font-medium text-highlighted">
Thank you for your feedback!
</div>
<div class="text-xs text-muted mt-1">
Your input helps us improve the documentation.
</div>
</motion.div>
</motion.div>
<!-- ... -->
</template>
You can find the source code of the feedback widget here.
2. Plausible-inspired anonymization
The challenge was detecting duplicates (a user changing their mind) while preserving privacy. We took inspiration from Plausible's approach to counting unique visitors without cookies.
export async function generateHash(
today: string,
ip: string,
domain: string,
userAgent: string
): Promise<string> {
const data = `${today}+${domain}+${ip}+${userAgent}`
const buffer = await crypto.subtle.digest(
'SHA-1',
new TextEncoder().encode(data)
)
return [...new Uint8Array(buffer)]
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
This method generates a unique daily identifier by combining:
- IP + User-Agent: Naturally sent with every HTTP request
- Domain: Enables environment isolation
- Current date: Forces daily rotation of identifiers
Why is this secure?
- IP and User-Agent are never stored in the database
- The hash changes daily, preventing long-term tracking
- Very difficult to reverse engineer original data from the hash
- GDPR compliant by design (no persistent personal data)
3. Database persistence with conflict handling
First, we define the schema for the feedback table and add a unique constraint on the path
and fingerprint
columns.
export const feedback = sqliteTable('feedback', {
id: integer('id').primaryKey({ autoIncrement: true }),
rating: text('rating').notNull(),
feedback: text('feedback'),
path: text('path').notNull(),
title: text('title').notNull(),
stem: text('stem').notNull(),
country: text('country').notNull(),
fingerprint: text('fingerprint').notNull(),
createdAt: integer({ mode: 'timestamp' }).notNull(),
updatedAt: integer({ mode: 'timestamp' }).notNull()
}, table => [uniqueIndex('path_fingerprint_idx').on(table.path, table.fingerprint)])
Then, in the server, we use Drizzle with an UPSERT
strategy:
await drizzle.insert(tables.feedback).values({
rating: data.rating,
feedback: data.feedback || null,
path: data.path,
title: data.title,
stem: data.stem,
country: event.context.cf?.country || 'unknown',
fingerprint,
createdAt: new Date(),
updatedAt: new Date()
}).onConflictDoUpdate({
target: [tables.feedback.path, tables.feedback.fingerprint],
set: {
rating: data.rating,
feedback: data.feedback || null,
country,
updatedAt: new Date()
}
})
This approach enables updates if the user changes their mind within the day, creation for new feedback, and automatic deduplication per page and user.
You can find the source code of the server side here.
Shared types for consistency
We use Zod for runtime validation and type generation:
export const FEEDBACK_RATINGS = [
'very-helpful',
'helpful',
'not-helpful',
'confusing'
] as const
export const feedbackSchema = z.object({
rating: z.enum(FEEDBACK_RATINGS),
feedback: z.string().optional(),
path: z.string(),
title: z.string(),
stem: z.string()
})
export type FeedbackInput = z.infer<typeof feedbackSchema>
This approach ensures consistency across frontend, API, and database.
What's next
The widget is now live across all documentation pages. Our next step is building an admin interface within nuxt.com to analyze feedback patterns and identify pages that need improvement. This will help us continuously enhance the documentation quality based on real user feedback.
The complete source code is available on GitHub for inspiration and contributions!