Skip to content
thefaqapp

Build an infinite FAQ

Keep the familiar FAQ and add an inline question field backed by published answers.

Updated 2026-09-20

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 retrieves a matching published answer, unchanged. It uses conservative word matching, not generative AI. If there is no confident match, the response is not_covered. It does not invent an answer, search the open web, or promise to understand every paraphrase.

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";
    answer: string | null;
    sources: Array<{
      id: string;
      question: string;
      answer: string;
    }>;
  };
}

These are response types, not fabricated API results. On not_covered, answer is null and sources is empty.

Availability: the answers implementation exists in source; this does not confirm that it is deployed to your API host. This recipe requires a deployment that includes the answers endpoint. The SDK source includes faq.answers.ask({ question, lang }), but older published SDK versions may not export answers. This example uses HTTP directly, without depending on that SDK release.

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, and displays the source question below a retrieved 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 exact published text and its source question.
  • 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. This endpoint does not invoke an LLM and has no separate answer-generation 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.

See pricing · Try the example