Skip to main content

Build an in-app feedback form in Next.js

Add a native feedback card to a Next.js App Router page, attach signed account context, and verify one real response end to end.

Build this with your agent. Copy a prompt that hands this guide to Claude Code, Codex, Cursor, or any coding agent.

Add a two-question feedback card to a Next.js settings page, rendered as native React controls. It records a rating and a note with the signed-in account attached, and you verify one real response end to end.

The card renders in your page's DOM, not in an iframe. Why iframe form embeds break covers that trade-off.

Install the React renderer

bash
pnpm add @usefillo/react

Import the optional default stylesheet from the root layout:

tsx
// app/layout.tsx
import "@usefillo/react/styles.css";

Create a publishable key in the Fillo project, allow your app's origin, and add the key to .env.local:

dotenv
NEXT_PUBLIC_FILLO_KEY=pk_your_publishable_key

The browser needs this key only because the form below is defined in code. Keep fsk_ project keys on the server.

Add the feedback card

Create a Client Component. Keep the Fillo client outside the component.

tsx
// app/settings/FeedbackCard.tsx
"use client";

import { createClient, Fillo } from "@usefillo/react";

const publishableKey = process.env.NEXT_PUBLIC_FILLO_KEY;
const fillo = publishableKey ? createClient({ key: publishableKey }) : null;

type Respondent = { id: string; email?: string; name?: string; hash?: string };

export function FeedbackCard({ respondent }: { respondent?: Respondent }) {
  if (!fillo) {
    return <p role="status">Add NEXT_PUBLIC_FILLO_KEY to render feedback.</p>;
  }

  return (
    <section aria-labelledby="feedback-title">
      <h2 id="feedback-title">How did this settings page work for you?</h2>
      <Fillo.Form
        client={fillo}
        id="nextjs-settings-feedback"
        title="Settings feedback"
        respondent={respondent}
      >
        <Fillo.Rating id="score" label="Overall experience" max={5} required />
        <Fillo.LongText id="note" label="What should we fix?" />
      </Fillo.Form>
    </section>
  );
}

The field IDs are the durable contract. You can change labels later, and score and note keep naming the response columns, exports, and webhook keys.

In a claimed workspace, the first render stages the form as a draft if the project allows publishable-key sync. Publish it before users see the card. Later schema edits stage again.

Attach account context

Compute the respondent hash on the server with the project's identity secret. Sign the exact profile values you pass to the renderer. Use trimmed values within the documented bounds, and omit empty optional values:

ts
// lib/fillo-identity.ts (server only)
import { createHmac, hkdfSync } from "node:crypto";

export function filloRespondentHash(user: { id: string; email?: string; name?: string }): string {
  const key = Buffer.from(
    hkdfSync("sha256", process.env.FILLO_IDENTITY_SECRET!, "fillo-identity-v2", "respondent-profile", 32),
  );
  return createHmac("sha256", key)
    .update(JSON.stringify(["fillo-identity-v2", user.id, user.email ?? null, user.name ?? null, []]))
    .digest("hex");
}

Pass the result from the Server Component that renders the route:

tsx
// app/settings/page.tsx
import { FeedbackCard } from "./FeedbackCard";
import { filloRespondentHash } from "@/lib/fillo-identity";
import { getCurrentUser } from "@/lib/auth"; // your session helper

export default async function SettingsPage() {
  const user = await getCurrentUser();
  return (
    <FeedbackCard
      respondent={{ id: user.id, email: user.email, name: user.name, hash: filloRespondentHash(user) }}
    />
  );
}

Never give the identity secret a NEXT_PUBLIC_ name. Legacy ID-only hashes retain account identity but discard unsigned profile fields. See respondents and identity for verified-only submissions.

Why the hash has to come from the server

Page code can claim any user ID. An unsigned ID is context, not proof. Once the project holds an identity secret, Fillo records a respondent only when the hash matches, so the secret must never reach the browser.

Place the card in your product

Put the card where the question makes sense, such as after an import, beside a new feature, or at the end of a task.

Your page ownsFillo owns
Route, session, and layoutSchema and field IDs
A visible heading when the form title is hiddenValidation and error messages
What happens after submitThe accepted response and its form version
Product analytics eventsDelivery to webhooks and integrations

Match your design system with theme tokens, slot classes, or custom UI.

Other placements

The same card works at the end of a docs article or help page. Add a hidden path field and pass the current route, so each answer carries the page it's about:

tsx
// app/docs/ArticleFeedback.tsx
"use client";

import { usePathname } from "next/navigation";
import { createClient, Fillo } from "@usefillo/react";

const fillo = createClient({ key: process.env.NEXT_PUBLIC_FILLO_KEY! });

export function ArticleFeedback() {
  const pathname = usePathname();
  return (
    <section aria-labelledby="article-feedback-title">
      <h2 id="article-feedback-title">Was this page helpful?</h2>
      <Fillo.Form
        key={pathname} // a fresh form for each page
        client={fillo}
        id="article-feedback"
        initialData={{ path: pathname }}
        showTitle={false}
      >
        <Fillo.Hidden id="path" label="Page path" />
        <Fillo.Rating id="helpful" label="How useful was this page?" max={5} required />
        <Fillo.LongText id="note" label="What was missing?" />
      </Fillo.Form>
    </section>
  );
}

A visitor can edit a hidden value, so treat path as context, not proof.

Review one real response

  1. Publish the form.
  2. Load the signed-in route from an allowed origin.
  3. Submit without a rating and confirm focus moves to the error.
  4. Submit one response.
  5. Find the rating, note, and respondent in the project's Responses view.
  6. Confirm any webhook or integration received it.

The callback proves the request returned. The saved response proves the workflow continues after the page closes.

Start from the starter

Clone the Next.js product-feedback starter. It includes this App Router page, an environment example, and a clean-install checklist. For a dashboard-managed version, open the customer feedback template.

This page for agents: /guides/nextjs-in-app-feedback-form.md · index at /llms.txt

Updated

Was this page helpful?