How to Build a React FAQ Component (With API Integration)
Build a production-ready React FAQ component with accordion UI, search, and API-powered content. Includes TypeScript, accessibility, and SEO markup.
Why Build a React FAQ Component?
Every product needs a FAQ page. Hard-coding questions and answers directly into JSX works for five questions — but it breaks down fast. Content changes require code deploys, there's no search, and you lose SEO benefits like FAQ schema markup.
A better approach: build a reusable React FAQ component that pulls content from an API. Content updates happen instantly, your FAQ grows without code changes, and you get structured data for Google rich results.
This tutorial walks you through building a production-ready FAQ component in React with TypeScript — from a basic accordion to a searchable, API-powered FAQ page.
Step 1: Basic FAQ Accordion
Start with a simple accordion component that accepts an array of questions and answers:
// components/FAQItem.tsx
import { useState } from "react";
interface FAQItemProps {
question: string;
answer: string;
defaultOpen?: boolean;
}
export function FAQItem({ question, answer, defaultOpen = false }: FAQItemProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="faq-item">
<button
className="faq-question"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
>
<span>{question}</span>
<span className="faq-icon">{isOpen ? "−" : "+"}</span>
</button>
{isOpen && (
<div className="faq-answer" role="region">
<p>{answer}</p>
</div>
)}
</div>
);
}
// components/FAQ.tsx
import { FAQItem } from "./FAQItem";
interface FAQEntry {
question: string;
answer: string;
}
interface FAQProps {
items: FAQEntry[];
title?: string;
}
export function FAQ({ items, title = "Frequently Asked Questions" }: FAQProps) {
return (
<section className="faq-section">
<h2>{title}</h2>
<div className="faq-list">
{items.map((item, index) => (
<FAQItem key={index} question={item.question} answer={item.answer} />
))}
</div>
</section>
);
}
Usage:
const faqs = [
{ question: "What is your refund policy?", answer: "Full refund within 30 days." },
{ question: "How do I cancel?", answer: "Go to Settings → Billing → Cancel." },
];
<FAQ items={faqs} />
This works, but the content is hard-coded. Let's fix that.
Step 2: Fetch FAQ Content From an API
Instead of embedding content in your codebase, pull it from an API. This lets your team update FAQ content without deploying code.
Here's how to connect to the thefaq.app API using the @faqapp/core SDK:
npm install @faqapp/core
// hooks/useFAQ.ts
import { useState, useEffect } from "react";
import { FAQClient } from "@faqapp/core";
const client = new FAQClient({
apiKey: import.meta.env.VITE_FAQ_API_KEY,
organizationSlug: "your-org",
});
interface FAQQuestion {
id: string;
slug: string;
question: string;
answer: string;
category?: string;
}
export function useFAQ(category?: string) {
const [questions, setQuestions] = useState<FAQQuestion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchFAQs() {
try {
setLoading(true);
const response = await client.questions.list({
category,
limit: 50,
});
setQuestions(response.data);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to load FAQs"));
} finally {
setLoading(false);
}
}
fetchFAQs();
}, [category]);
return { questions, loading, error };
}
Now update the FAQ component to use this hook:
// components/FAQ.tsx
import { FAQItem } from "./FAQItem";
import { useFAQ } from "../hooks/useFAQ";
interface FAQProps {
category?: string;
title?: string;
}
export function FAQ({ category, title = "Frequently Asked Questions" }: FAQProps) {
const { questions, loading, error } = useFAQ(category);
if (loading) return <div className="faq-loading">Loading FAQ...</div>;
if (error) return <div className="faq-error">Unable to load FAQ content.</div>;
if (questions.length === 0) return null;
return (
<section className="faq-section">
<h2>{title}</h2>
<div className="faq-list">
{questions.map((item) => (
<FAQItem key={item.id} question={item.question} answer={item.answer} />
))}
</div>
</section>
);
}
Now your FAQ component is powered by an API. Update content in your thefaq.app dashboard and it appears instantly — no redeploy needed.
Step 3: Add Search
For FAQ pages with more than 10 questions, search is essential. The thefaq.app API includes full-text search:
// components/FAQWithSearch.tsx
import { useState, useEffect } from "react";
import { FAQClient } from "@faqapp/core";
import { FAQItem } from "./FAQItem";
const client = new FAQClient({
apiKey: import.meta.env.VITE_FAQ_API_KEY,
organizationSlug: "your-org",
});
export function FAQWithSearch() {
const [query, setQuery] = useState("");
const [questions, setQuestions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
async function fetchQuestions() {
setLoading(true);
try {
if (query.trim()) {
const results = await client.search.query({ q: query, limit: 20 });
setQuestions(results.data.results ?? []);
} else {
const response = await client.questions.list({ limit: 50 });
setQuestions(response.data);
}
} catch {
// Ignore aborted requests
} finally {
setLoading(false);
}
}
const debounce = setTimeout(fetchQuestions, 300);
return () => {
clearTimeout(debounce);
controller.abort();
};
}, [query]);
return (
<section className="faq-section">
<h2>Frequently Asked Questions</h2>
<input
type="search"
placeholder="Search FAQ..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="faq-search"
aria-label="Search frequently asked questions"
/>
{loading ? (
<div className="faq-loading">Searching...</div>
) : questions.length === 0 ? (
<p className="faq-empty">No results found. Try a different search term.</p>
) : (
<div className="faq-list">
{questions.map((item) => (
<FAQItem
key={item.id || item.slug}
question={item.question}
answer={item.answer}
/>
))}
</div>
)}
</section>
);
}
The 300ms debounce prevents excessive API calls while the user types. For more on implementing FAQ search, see our FAQ search implementation guide.
Step 4: Add FAQ Schema Markup (JSON-LD)
Google displays FAQ rich results when your page includes JSON-LD FAQ schema. This can significantly improve click-through rates from search results.
Add a component that generates the schema markup:
// components/FAQSchema.tsx
interface FAQSchemaProps {
items: Array<{ question: string; answer: string }>;
}
export function FAQSchema({ items }: FAQSchemaProps) {
const schema = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: items.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
Use it alongside your FAQ component:
import { FAQ } from "./components/FAQ";
import { FAQSchema } from "./components/FAQSchema";
function HelpPage() {
const faqs = useFAQ("billing");
return (
<>
<FAQSchema items={faqs.questions} />
<FAQ category="billing" title="Billing FAQ" />
</>
);
}
If you use thefaq.app's hosted FAQ pages, JSON-LD schema markup is generated automatically on every page — no code required.
Step 5: Accessibility
A good FAQ component is keyboard-navigable and screen-reader friendly. Update FAQItem with proper ARIA attributes:
// components/FAQItem.tsx (accessible version)
import { useState, useId } from "react";
interface FAQItemProps {
question: string;
answer: string;
defaultOpen?: boolean;
}
export function FAQItem({ question, answer, defaultOpen = false }: FAQItemProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const id = useId();
const answerId = `faq-answer-${id}`;
return (
<div className="faq-item">
<h3>
<button
className="faq-question"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-controls={answerId}
>
<span>{question}</span>
<span className="faq-icon" aria-hidden="true">
{isOpen ? "−" : "+"}
</span>
</button>
</h3>
<div
id={answerId}
className="faq-answer"
role="region"
aria-labelledby={`faq-question-${id}`}
hidden={!isOpen}
>
<div dangerouslySetInnerHTML={{ __html: answer }} />
</div>
</div>
);
}
Key accessibility features:
aria-expandedtells screen readers whether the answer is visiblearia-controlslinks the button to the answer panelrole="region"marks the answer as a distinct content regionhiddenattribute properly hides collapsed answers from assistive technology- The question button is wrapped in an
<h3>for proper heading hierarchy
Step 6: Styling
Here's minimal CSS that gives you a clean, professional FAQ layout:
/* styles/faq.css */
.faq-section {
max-width: 720px;
margin: 0 auto;
padding: 2rem 1rem;
}
.faq-section h2 {
font-size: 1.75rem;
margin-bottom: 1.5rem;
}
.faq-search {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
margin-bottom: 1.5rem;
}
.faq-search:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
border-color: transparent;
}
.faq-item {
border-bottom: 1px solid #e2e8f0;
}
.faq-question {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 1rem 0;
background: none;
border: none;
cursor: pointer;
font-size: 1.05rem;
font-weight: 500;
text-align: left;
color: inherit;
}
.faq-question:hover {
color: #3b82f6;
}
.faq-icon {
font-size: 1.25rem;
flex-shrink: 0;
margin-left: 1rem;
}
.faq-answer {
padding: 0 0 1rem;
color: #4a5568;
line-height: 1.7;
}
.faq-loading,
.faq-error,
.faq-empty {
text-align: center;
padding: 2rem;
color: #718096;
}
The Fast Path: Skip the Build
Building a FAQ component from scratch gives you full control, but it takes time. If you want a FAQ page running in minutes instead of hours:
Option 1: Embed the widget. Add a single script tag and get a searchable, themed FAQ widget on any page:
<script
src="https://www.thefaq.app/widget/embed.js"
data-org="your-org"
data-theme="light"
async
></script>
Option 2: Use the SDK. Install @faqapp/core and build exactly the UI you want — with content managed through the dashboard instead of your codebase.
Option 3: Hosted pages. Get a fully SEO-optimized FAQ page at your-org.thefaq.app with zero code. Connect a custom domain like faq.yourcompany.com for a branded experience.
All three options include automatic FAQ schema markup, search, and category filtering — built in.
Summary
| Approach | Best For | Content Updates | SEO | Setup Time |
|---|---|---|---|---|
| Hard-coded JSX | 3-5 static questions | Requires deploy | Manual schema | Minutes |
| Custom + API | Full control over UI | Instant via API | Manual schema | Hours |
| thefaq.app widget | Quick integration | Instant via dashboard | Automatic | Minutes |
| thefaq.app hosted | Zero-code FAQ page | Instant via dashboard | Automatic | Minutes |
For most teams, the sweet spot is using the SDK with a custom React component — you get full UI control with API-managed content. As your FAQ grows, add search and categories through the API instead of rebuilding your component.
Ready to stop hard-coding FAQ content? Start free with thefaq.app — API access included on every plan, including the free tier.
Related
- Add FAQ to a Next.js App — Next.js-specific integration with server components
- How to Build a FAQ Page with an API — Framework-agnostic API tutorial
- Embed a FAQ Widget Anywhere — Zero-code FAQ integration
- FAQ Page Examples and Templates — Layout inspiration
- FAQ SEO Best Practices — Schema markup and ranking tips
- FAQ Search Implementation Guide — Deep dive on search
TheFAQApp Team
We build the API-first FAQ platform for developer teams. Our mission is to make FAQ management as easy as managing code.
Ready to build your FAQ?
Create searchable, API-powered FAQ pages in minutes. Free to start — no credit card required.
Continue reading
Get developer updates
API changelog, new features, and FAQ best practices. No spam.