Build a feature – a complete guide from database to UI
Learn how to build a complete feature (with the example of a feedback widget) in supastarter.
In this guide, we'll take you through the process of implementing a feature in supastarter from start to finish. This will cover a lot of the things from the previous guides and documentation, but we'll be doing it in a more complete way with a concrete example of creating a feedback widget for your app.
Overview
The feedback widget consists of:
- Database Schema: Prisma model to store feedback data
- Database Queries: Dedicated query functions for feedback operations
- API Endpoint: oRPC procedure to handle feedback submissions with session integration
- Frontend Component: Vue component with form and UI
- Translations: Internationalization support for the widget
Step 1: Database Schema
First, add a Feedback model to the Prisma schema:
// packages/database/prisma/schema.prisma
model Feedback {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
email String?
name String?
message String
type String
ipAddress String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("feedback")
}Also add the relation to the User model:
model User {
// ... existing fields ...
feedbacks Feedback[]
}Step 2: Database Queries
Create dedicated query functions for feedback operations:
// packages/database/prisma/queries/feedback.ts
import { db } from "../client";
export async function createFeedback({
message,
type,
email,
name,
ipAddress,
userId,
}: {
message: string;
type: string;
email?: string;
name?: string;
ipAddress?: string;
userId?: string;
}) {
return await db.feedback.create({
data: { message, type, email, name, ipAddress, userId },
});
}Step 3: API Endpoint
Create the feedback oRPC procedure with validation and session integration:
// packages/api/modules/feedback/types.ts
import { z } from "zod";
export const feedbackSchema = z.object({
message: z.string().min(10).max(1000),
type: z.enum(["bug", "feature", "general"]).default("general"),
email: z.string().email().optional(),
name: z.string().min(2).max(100).optional(),
});// packages/api/modules/feedback/procedures/create.ts
import { ORPCError } from "@orpc/server";
import { auth } from "@repo/auth";
import { createFeedback } from "@repo/database";
import { logger } from "@repo/logs";
import { z } from "zod";
import { publicProcedure } from "../../../orpc/procedures";
import { feedbackSchema } from "../types";
export const createFeedbackProcedure = publicProcedure
.route({
method: "POST",
path: "/feedback",
tags: ["Feedback"],
summary: "Submit user feedback",
})
.input(feedbackSchema)
.output(z.object({ id: z.string(), message: z.string() }))
.handler(async ({ input, context }) => {
try {
const session = await auth.api.getSession({
headers: context.headers,
});
const ipAddress = context.headers.get("x-forwarded-for") || undefined;
const feedback = await createFeedback({
message: input.message,
type: input.type,
email: input.email,
name: input.name,
ipAddress,
userId: session?.user.id,
});
return { id: feedback.id, message: "Feedback submitted successfully" };
} catch (error) {
logger.error("Failed to submit feedback:", error);
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Could not submit feedback",
});
}
});Mount the router in the main API router:
// packages/api/orpc/router.ts
import { feedbackRouter } from "../modules/feedback/router";
export const router = publicProcedure
.prefix("/api")
.router({
// ... other routers
feedback: feedbackRouter,
});Step 4: Frontend Component
Create a Vue component with form validation, session integration, and internationalization:
<script setup lang="ts">
import { z } from "zod";
const { user } = useSession();
const { $orpcClient } = useNuxtApp();
const { t } = useTranslations();
const isOpen = ref(false);
const isPending = ref(false);
const feedbackSchema = z.object({
message: z.string().min(10).max(1000),
type: z.enum(["bug", "feature", "general"]).default("general"),
email: z.string().email().optional(),
name: z.string().min(2).max(100).optional(),
});
type FeedbackForm = z.infer<typeof feedbackSchema>;
const state = reactive<FeedbackForm>({
message: "",
type: "general",
email: "",
name: "",
});
const onSubmit = async () => {
isPending.value = true;
try {
await $orpcClient.feedback.create(state);
isOpen.value = false;
// Reset form
Object.assign(state, { message: "", type: "general", email: "", name: "" });
} catch (error) {
console.error("Error submitting feedback:", error);
} finally {
isPending.value = false;
}
};
</script>
<template>
<UModal v-model="isOpen">
<UButton @click="isOpen = true" variant="outline" size="sm" class="fixed bottom-4 right-4 z-50">
{{ t("feedback.button") }}
</UButton>
<template #content>
<UForm :schema="feedbackSchema" :state="state" @submit="onSubmit">
<UFormField :label="t('feedback.form.type.label')" name="type">
<USelect v-model="state.type" :options="['general', 'bug', 'feature']" />
</UFormField>
<template v-if="!user">
<UFormField :label="t('feedback.form.name.label')" name="name">
<UInput v-model="state.name" />
</UFormField>
<UFormField :label="t('feedback.form.email.label')" name="email">
<UInput v-model="state.email" type="email" />
</UFormField>
</template>
<UFormField :label="t('feedback.form.message.label')" name="message">
<UTextarea v-model="state.message" />
</UFormField>
<UButton type="submit" :loading="isPending" class="w-full">
{{ t("feedback.form.submit") }}
</UButton>
</UForm>
</template>
</UModal>
</template>Step 5: Translations
Add translation keys for the feedback widget in packages/i18n/translations/en/saas.json:
{
"feedback": {
"button": "Feedback",
"title": "Send Feedback",
"form": {
"type": { "label": "Feedback Type" },
"name": { "label": "Name" },
"email": { "label": "Email" },
"message": { "label": "Message" },
"submit": "Send Feedback"
}
}
}Step 6: Integration
Add the feedback widget to your layout:
<template>
<div>
<slot />
<FeedbackWidget />
</div>
</template>Key Takeaways
- Start with database schema and work your way up to the UI
- Use TypeScript for type safety across the entire stack
- Implement proper validation and error handling
- Consider user experience and accessibility
- Follow the established patterns in your codebase
This pattern can be applied to build other features like contact forms, support tickets, or any user input system.