Documentation
supastarter for Nuxtsupastarter for NuxtRecipes

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:

  1. Database Schema: Prisma model to store feedback data
  2. Database Queries: Dedicated query functions for feedback operations
  3. API Endpoint: oRPC procedure to handle feedback submissions with session integration
  4. Frontend Component: Vue component with form and UI
  5. 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:

apps/saas/modules/shared/components/FeedbackWidget.vue
<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:

apps/saas/layouts/app.vue
<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.