Build an infinite FAQ
Keep the familiar FAQ and add an inline question field backed by published answers.
Updated 2026-10-04
An infinite FAQ keeps the questions visitors expect and adds one final row: Ask anything else. The interaction is inspired by Gustavo’s original demo. This implementation is independent, not an endorsement or partnership.
What this version does
The answers endpoint returns a published answer, unchanged, when one matches. It tries word matching first. On PRO and above it also matches a reworded question to the published question with the same meaning (mode: "stored").
If nothing published matches, the response is not_covered. On PRO and above you can opt in to composed answers by setting answerMode to grounded with PATCH /api/v1/{organizationSlug}/organization. The endpoint then writes a short answer from the facts learned from your own site (mode: "composed"). Every sentence is checked against the facts it cites, unsupported sentences are dropped, and each answer lists the page URL and exact quote it relied on. If no fact is close enough or the check cannot run, the response is still not_covered. It never searches the open web.
The homepage example runs the same selection logic locally against its displayed product questions. It is not a live customer API call. The URL-intake preview searches the generated draft candidates; those drafts still need review before publication.
1. Prepare your content and Next.js app
Generate candidates from a URL or create questions in the dashboard. Review the wording, publish the questions you approve, then open Integrations → Visitor preview. The visitor preview uses your organization’s published content, not the homepage sample.
This example uses an existing Next.js App Router application with TypeScript and Tailwind CSS, with app/ at the project root. If your app uses src/, place both app/ and components/ below src/; the relative imports stay the same.
Install the icons and server-only guard. Initialize shadcn if you have not already done so, then add the four primitives:
npm install lucide-react server-only
npx shadcn@latest init
npx shadcn@latest add accordion button input label
Save the downloadable React source as components/infinite-faq.tsx. It exports InfiniteFaq, FaqEntry, and FaqAnswer. Its four relative ./ui/... imports expect your generated primitives in components/ui/; adapt those imports if your project uses a different directory. Keep the Tailwind and theme CSS configured by shadcn. @faq/ui is our private internal workspace package, not a package you need to install.
Create .env.local at the Next.js project root, replacing these placeholders with your organization slug and a read-scoped API key:
FAQAPP_ORG_SLUG=your-organization-slug
FAQAPP_API_KEY=your-read-scoped-api-key
Keep .env.local out of version control. Configure the same variables as server-side secrets in your deployment platform and restart your development server after changing them. Never use a NEXT_PUBLIC_ prefix or pass the key as a component prop.
2. Add a server endpoint
Call POST /api/v1/{organizationSlug}/answers from your backend with a read-scoped API key. Do not put your API key into client JavaScript, a public environment variable, or an HTML attribute.
Request:
{
"question": "What is included in the free plan?"
}
An optional lang selects the content language. The answer and sources are scoped to that organization and published content.
Response shape:
interface AnswerResult {
data: {
status: "answered" | "not_covered";
mode?: "stored" | "composed";
answer: string | null;
sources: Array<{
id: string;
question: string;
answer: string;
}>;
citations?: Array<{
url: string;
quote: string;
}>;
};
}
These are response types, not fabricated API results. A stored answer lists the published question in sources. A composed answer has empty sources and at least one entry in citations. On not_covered, answer is null and sources is empty.
The @faqapp/core SDK (v1.3.0+) exports faq.answers.ask({ question, lang }). If your project already uses @faqapp/core, you can call faq.answers.ask({ question: body.question.trim() }) directly. This recipe demonstrates the self-contained HTTP handler using Next.js server-only:
Create app/api/faq-answer/route.ts to expose POST /api/faq-answer on your own site. The 500-character limit matches this component; the upstream answers API accepts up to 2,000 characters. The handler rejects malformed JSON and non-object payloads before fetching:
import "server-only";
export async function POST(request: Request) {
const body: unknown = await request.json().catch(() => null);
if (typeof body !== "object" || body === null || Array.isArray(body)) {
return Response.json({ error: "Send a JSON object with a question." }, { status: 400 });
}
if (!("question" in body) || typeof body.question !== "string" || !body.question.trim() || body.question.length > 500) {
return Response.json({ error: "Enter a question of at most 500 characters." }, { status: 400 });
}
const organizationSlug = process.env.FAQAPP_ORG_SLUG;
const apiKey = process.env.FAQAPP_API_KEY;
if (!organizationSlug || !apiKey) {
return Response.json({ error: "FAQ service is not configured." }, { status: 500 });
}
const response = await fetch(
`https://api.thefaq.app/api/v1/${encodeURIComponent(organizationSlug)}/answers`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ question: body.question.trim() })
}
);
return new Response(response.body, {
status: response.status,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
});
}
Before exposing your handler publicly, add your own per-visitor rate limit and request-body size limit at your hosting edge or in middleware before parsing. The question-length check is not a body-size limit. Keep the organization fixed server-side. Your visitors must not be able to choose a different organization or upstream URL. The upstream API authenticates and meters calls against your organization; that does not replace abuse controls on your public proxy. A secret key does not prevent visitors from consuming your quota through this route.
3. Add the client wrapper
Create app/faq/faq-client.tsx. The callback belongs in a client component; a server component cannot pass an ordinary function across that boundary. This wrapper imports the downloaded component and sends only the question to your own server route.
"use client";
import { InfiniteFaq, type FaqAnswer, type FaqEntry } from "../../components/infinite-faq";
export default function FaqClient({ questions }: { questions: FaqEntry[] }) {
return (
<InfiniteFaq
questions={questions}
onAsk={async question => {
const response = await fetch("/api/faq-answer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question })
});
if (!response.ok) throw new Error("Could not load an answer");
const result: { data: FaqAnswer } = await response.json();
return result.data;
}}
/>
);
}
4. Load published questions on the server
Create app/faq/page.tsx. GET /api/v1/{organizationSlug}/questions?status=published&view=full returns the question array directly in data, with pagination in meta.pagination—not data.questions. Each full question includes id, question, and answer, as well as fields such as published, slug, and timestamps. The type below describes only the fields this example reads.
This page follows meta.pagination.pages to load every page of published questions, then passes only display fields to the client. Each page fetch consumes an API request. cache: "no-store" keeps the example fresh on each page visit; for a high-traffic site, add a deliberate server-side cache and publication invalidation policy.
import "server-only";
import type { FaqEntry } from "../../components/infinite-faq";
import FaqClient from "./faq-client";
type QuestionsResponse = {
data: FaqEntry[];
meta: {
pagination: {
page: number;
limit: number;
total: number;
pages: number;
};
};
};
export default async function FaqPage() {
const organizationSlug = process.env.FAQAPP_ORG_SLUG;
const apiKey = process.env.FAQAPP_API_KEY;
if (!organizationSlug || !apiKey) throw new Error("FAQ service is not configured.");
const questions: FaqEntry[] = [];
let page = 1;
let pages = 1;
do {
const url = new URL(`https://api.thefaq.app/api/v1/${encodeURIComponent(organizationSlug)}/questions`);
url.searchParams.set("status", "published");
url.searchParams.set("view", "full");
url.searchParams.set("limit", "100");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
cache: "no-store"
});
if (!response.ok) throw new Error("Could not load published questions");
const result: QuestionsResponse = await response.json();
questions.push(...result.data.map(({ id, question, answer }) => ({ id, question, answer })));
pages = result.meta.pagination.pages;
page += 1;
} while (page <= pages);
return (
<main className="mx-auto max-w-2xl px-4 py-12">
<h1 className="mb-6 text-3xl font-semibold">Frequently asked questions</h1>
<FaqClient questions={questions} />
</main>
);
}
Run your Next.js development server and open /faq. The initial questions load on the server; submitting the inline form calls /api/faq-answer. All upstream requests use server-only credentials. An upstream outage fails the page load rather than silently pretending the organization has no published questions; use your app’s error boundary for that state.
The component renders answers as text, not raw HTML. It shows the source question below a stored answer, and the quote and page for each citation below a composed answer. If your stored answers contain HTML markup, it will appear as literal text. Start with plain-text content for this recipe; do not switch to raw HTML injection to hide the markup.
States worth keeping
- Loading: show real request activity, not simulated document-reading steps.
- Answered: show the answer with its source question, or with its quotes and pages when composed.
- Not covered: say so and provide your own contact route. Never substitute an unrelated answer.
- Request failed: retain the question and allow retry. An error is not a missing answer.
- Question changed: clear the prior answer; ignore a stale response from a previous request.
Keep the input labelled, allow Enter to submit, announce results with a polite live region, and respect reduced-motion settings. Test mobile and both color themes. Do not collect raw visitor questions in analytics by default.
Usage and limits
An answers request consumes the normal API request allowance. A composed answer also counts as one AI operation against your monthly AI limit; when that limit is spent, the endpoint returns not_covered instead of composing. Stored answers have no AI charge. URL intake and AI translation are different features with their own limits. “Infinite” describes an open question field, not unlimited requests or unlimited knowledge.