
What you will build
Build a reusable React component that fetches public LinkedIn company posts in the browser and displays up to four cards. The component has loading, completed-empty, collection-pending and error states. A retry button waits for the API’s advertised interval rather than repeatedly sending requests.
This approach suits Vite apps and client-rendered websites. It uses a publishable key whose feed scope and allowed origins you control. The key and rendered posts are public. If you need a secret key, use the Next.js server guide instead.
Prerequisites
- Node 22.23.4 or later, npm and basic React knowledge.
- Tested versions: React 19.3.1, Vite 8.3.5 and TypeScript 5.9.3.
- For customer mode: an active subscription, connected feed and
hp_pk_publishable key. - A public company or showcase Page you own or are authorized to use. Headless Profeed is independent of LinkedIn and does not support private content or personal profiles.
1. Install the Vite application
git clone https://github.com/ProFeedWP/headless-profeed-examples.git
cd headless-profeed-examples/react
npm ci
cp .env.example .env
npm run dev
Use Copy-Item .env.example .env in PowerShell. Open the local URL shown by Vite. The template deliberately starts in public demo mode; no customer account is needed for that endpoint.
react/.env.example
# Explicit demo mode. No credentials are needed for the public demo.
VITE_PROFEED_MODE=demo
# For your own feed, set mode=customer and uncomment both settings below.
# VITE_PROFEED_FEED_ID=YOUR_FEED_ID
# VITE_PROFEED_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
# Browser keys must allow the EXACT origin shown by your local server.
2. Configure a publishable key
In your account, connect the authorized Page and create a publishable key scoped to that feed. Allow the exact origin shown by Vite, such as http://localhost:5173. Scheme, hostname and port all matter: http://127.0.0.1:5173 is a different origin. If Vite selects another port, allow that origin too or run your dev server on a fixed available port.
Set VITE_PROFEED_MODE=customer, uncomment and fill both feed ID and publishable key, then restart Vite. Add the production origin before launch. Browser variables are embedded during npm run build; rebuild after changing them. Do not put an hp_sk_ secret in any VITE_ variable. Origin restrictions do not make a publishable key or public feed confidential.
3. Add the feed component
The effect creates an AbortController for each attempt. Cleanup aborts the request and prevents updates after unmounting, including React’s development effect checks. The separate clock controls when retry becomes available; there is no automatic API polling.
react/src/Feed.tsx
import { useEffect, useState } from "react";
import { configuration, readFeed } from "../lib/feed.mjs";
const env = {
mode: import.meta.env.VITE_PROFEED_MODE,
feedId: import.meta.env.VITE_PROFEED_FEED_ID,
key: import.meta.env.VITE_PROFEED_PUBLISHABLE_KEY,
base: import.meta.env.VITE_PROFEED_API_URL,
};
type Post = {
id: string;
text: string;
date?: string;
url?: string;
image?: string;
};
type Result = {
state: string;
posts: Post[];
message?: string;
stale?: boolean;
retryAfter?: number;
};
export function Feed() {
const [result, setResult] = useState<Result>({ state: "loading", posts: [] });
const [attempt, setAttempt] = useState(0);
const [retryAt, setRetryAt] = useState(0);
const [now, setNow] = useState(Date.now());
useEffect(() => {
const controller = new AbortController();
let active = true;
setResult({ state: "loading", posts: [] });
(async () => {
try {
const next = await readFeed(
configuration(env, true),
controller.signal,
);
if (active) {
setResult(next);
setNow(Date.now());
setRetryAt(Date.now() + (next.retryAfter || 0) * 1000);
}
} catch (error) {
if (active && !controller.signal.aborted)
setResult({
state: "error",
posts: [],
message:
error instanceof Error
? error.message
: "Check your configuration.",
});
}
})();
return () => {
active = false;
controller.abort();
};
}, [attempt]);
useEffect(() => {
if (retryAt <= Date.now()) return;
const timer = setInterval(() => {
const time = Date.now();
setNow(time);
if (time >= retryAt) clearInterval(timer);
}, 1000);
return () => clearInterval(timer);
}, [retryAt]);
const wait = Math.max(0, Math.ceil((retryAt - now) / 1000));
return (
<section
aria-label="Company posts"
aria-live="polite"
data-state={result.state}
>
{result.state === "loading" && (
<p className="status">Loading company posts…</p>
)}
{result.message && <p className="status">{result.message}</p>}
{result.stale && <p>Showing cached posts while the source refreshes.</p>}
<div className="feed">
{result.posts.map((post) => (
<article className="post" key={post.id}>
<div className="eyebrow">Company update</div>
{post.date && <time dateTime={post.date}>{post.date}</time>}
<p>{post.text}</p>
{post.image && (
<img
src={post.image}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
/>
)}
{post.url && (
<a href={post.url} target="_blank" rel="noreferrer">
View on LinkedIn ↗
</a>
)}
</article>
))}
</div>
{(result.state === "error" || result.state === "pending") && (
<button disabled={wait > 0} onClick={() => setAttempt((n) => n + 1)}>
{wait > 0 ? "Try again in " + wait + "s" : "Try again"}
</button>
)}
</section>
);
}
Post text uses React text nodes, not injected HTML. The component tolerates missing images, dates and LinkedIn URLs. You can change its markup without changing the authentication boundary.
React describes effect cleanup and request race handling in its useEffect reference. Keep that cleanup when adapting this example to a larger component tree.
4. Add the API helper
The helper rejects secret keys in browser customer mode and validates URLs before they reach the DOM. It checks pending collection before generic HTTP success, handles Retry-After in seconds or HTTP-date format, and normalizes at most four posts.
react/lib/feed.mjs
export const API_URL = "https://api.profeedwp.com/api/headless-profeed/v1";
export function configuration(env, browser = false) {
const mode = env.mode;
if (mode !== "demo" && mode !== "customer")
throw new Error("Set the feed mode to demo or customer. See .env.example.");
const base = (env.base || API_URL).replace(/\/$/, "");
const url = new URL(base);
if (
url.protocol !== "https:" &&
!(url.protocol === "http:" && url.hostname === "127.0.0.1")
)
throw new Error(
"The API URL must use HTTPS (127.0.0.1 is allowed for fixture tests).",
);
if (mode === "demo") return { url: base + "/demo", headers: {} };
if (!env.feedId || !/^[a-zA-Z0-9-]+$/.test(env.feedId))
throw new Error(
"Customer mode requires a feed ID. Copy it from your account.",
);
if (!env.key?.startsWith(browser ? "hp_pk_" : "hp_sk_"))
throw new Error(
browser
? "Customer browser mode requires a publishable key (hp_pk_), never a server key."
: "Customer server mode requires a secret key (hp_sk_). Keep it server-only.",
);
return {
url:
base + "/feeds/" + encodeURIComponent(env.feedId) + "/posts?page_size=4",
headers: { "X-API-Key": env.key },
};
}
export function safeUrl(value, linkedin = false) {
if (typeof value !== "string") return undefined;
try {
const url = new URL(value);
if (linkedin)
return url.protocol === "https:" &&
(url.hostname === "linkedin.com" ||
url.hostname.endsWith(".linkedin.com"))
? url.href
: undefined;
return url.protocol === "https:" ||
(url.protocol === "http:" && url.hostname === "127.0.0.1")
? url.href
: undefined;
} catch {
return undefined;
}
}
export function retrySeconds(header, fallback = 30, now = Date.now()) {
if (!header) return fallback;
const seconds = /^\d+$/.test(header)
? Number(header)
: Math.ceil((Date.parse(header) - now) / 1000);
return Number.isFinite(seconds) ? Math.max(1, seconds) : fallback;
}
// 202 is an HTTP success but collection has not completed yet.
export async function readFeed(config, signal) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const abort = () => controller.abort();
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) controller.abort();
try {
const response = await fetch(config.url, {
headers: config.headers,
cache: "no-store",
credentials: "omit",
signal: controller.signal,
});
const body = await response.json().catch(() => null);
const retryAfter = retrySeconds(
response.headers.get("Retry-After"),
body?.retry_after_seconds || 30,
);
if (response.status === 202)
return {
state: "pending",
posts: [],
retryAfter,
message:
"The first collection is pending. Try again after the retry interval.",
};
if (!response.ok) {
const message =
response.status === 401
? "Invalid API key. Replace it in your environment."
: response.status === 403
? "Access denied. Check your subscription, selected feed and exact allowed origin."
: response.status === 429
? "Request allowance reached. Wait before retrying."
: "The feed is temporarily unavailable. Check the feed status and try again later.";
return {
state: "error",
posts: [],
message,
status: response.status,
retryAfter,
};
}
if (body?.success !== true || !Array.isArray(body.posts))
return {
state: "error",
posts: [],
message: "The API returned an unexpected response.",
retryAfter,
};
const posts = body.posts.slice(0, 4).map((post, index) => {
const date =
typeof post.date_published === "string"
? new Date(post.date_published)
: null;
return {
id: String(post.id ?? index),
text: typeof post.text === "string" ? post.text : "",
date:
date && !Number.isNaN(date.valueOf())
? date.toISOString().slice(0, 10)
: undefined,
url: safeUrl(post.post_url, true),
image:
safeUrl(post.image_url) ||
safeUrl(post.media_items?.find((m) => m.type === "image")?.url),
};
});
return {
state: posts.length ? "ready" : "empty",
posts,
stale: body.refresh?.stale === true,
message: posts.length
? ""
: "No published posts are available for this feed yet.",
};
} catch (error) {
if (signal?.aborted) throw error;
return {
state: "error",
posts: [],
message:
"The feed could not be reached. Check your connection and API configuration.",
retryAfter: 30,
};
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", abort);
}
}
A 403 can represent a missing subscription, feed scope or origin mismatch. The browser supplies Origin itself; do not override it or send cookies. More details are in authentication.
5. Complete the application
The remaining files mount the component, add responsive styling and pin the framework versions. The repository contains the tested lockfile and fixture checks as well.
react/src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Feed } from "./Feed";
import "./style.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<main>
<p className="eyebrow">Headless Profeed / React</p>
<h1>Your company. Your feed.</h1>
<p className="intro">
Public LinkedIn company posts, rendered in your browser with your own
design.
</p>
<Feed />
<footer>Screenshots use authored sample posts.</footer>
<nav className="links">
<a href="https://headless.profeedwp.com/guides/react-linkedin-feed">
Integration guide
</a>
<a href="https://headless.profeedwp.com/docs">API quickstart</a>
</nav>
</main>
</StrictMode>,
);
react/src/style.css
:root {
font-family: Inter, system-ui, sans-serif;
color: #172b3a;
background: #f4f7f9;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
main {
max-width: 1120px;
margin: auto;
padding: 64px 24px;
}
a {
color: #006b9f;
text-underline-offset: 4px;
}
h1 {
font-size: clamp(32px, 5vw, 54px);
letter-spacing: -0.045em;
line-height: 1.08;
margin: 14px 0 20px;
}
h2 {
font-size: 20px;
line-height: 1.4;
}
p {
line-height: 1.65;
}
.eyebrow {
text-transform: uppercase;
font-weight: 700;
font-size: 12px;
letter-spacing: 0.16em;
color: #007bb5;
}
.intro {
max-width: 620px;
color: #526675;
}
.feed {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
margin: 36px 0;
}
.post {
background: white;
border: 1px solid #dbe4e9;
border-radius: 18px;
padding: 26px;
min-width: 0;
}
.post p {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin-top: 12px;
}
.post img {
display: block;
width: 100%;
height: 180px;
object-fit: cover;
border-radius: 10px;
margin: 18px 0;
}
.post time {
color: #607786;
font-size: 13px;
}
.post a {
display: inline-block;
margin-top: 12px;
font-weight: 600;
}
.status {
background: #fff;
border: 1px solid #cadce6;
border-radius: 12px;
padding: 20px;
}
.caption,
footer {
color: #526675;
font-size: 13px;
}
.links {
display: flex;
gap: 20px;
flex-wrap: wrap;
margin-top: 30px;
}
button {
font: inherit;
cursor: pointer;
border: 0;
border-radius: 8px;
padding: 12px 18px;
background: #006b9f;
color: #fff;
}
button:disabled {
opacity: 0.5;
cursor: wait;
}
@media (max-width: 600px) {
main {
padding: 36px 18px;
}
.feed {
grid-template-columns: 1fr;
}
.post {
padding: 22px;
}
}
react/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LinkedIn feed · React example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
react/package.json
{
"name": "@headless-profeed/react",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.23.4"
},
"scripts": {
"test": "node --test test/*.test.mjs",
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/node": "22.20.5",
"vite": "8.3.5",
"@vitejs/plugin-react": "6.1.2"
}
}
react/vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()] });
react/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"allowJs": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src", "lib", "vite.config.ts"]
}
Run npm test and npm run build, then npm run preview to inspect the production bundle. Publish dist/ to a static host, with the final origin already allowed on your key.
Caching and request allowances
Each mounted browser integration reads the API, so visitor volume affects API request usage. The service already caches source posts; fetching from React does not trigger a new LinkedIn collection. On self-service paid plans, active feeds are scheduled every three hours. Enterprise timing is agreed separately.
This example starts with no-store and no application cache, making retry behavior easy to understand. If multiple components need the same feed, share a request or use a client data library with an explicit freshness window and deduplication. Preserve feed scope and invalidation behavior. Do not cache an authorization error or 202 response as usable completed data. Review refresh behavior and current plan allowances when setting intervals.
Troubleshooting
| Symptom | Fix |
|---|---|
| Key configuration error | Use an hp_pk_ publishable key and feed ID in customer mode. Missing credentials never enable demo. |
| Origin error or 403 | Match scheme, hostname and port exactly; select this feed on the key and check your subscription. |
| Old settings after deployment | Vite variables are compiled at build time. Rebuild and redeploy after changing them. |
| Collection pending | Wait for the disabled retry button to become available. HTTP 202 is distinct from an empty feed. |
| Completed empty feed | Confirm the connected Page and public posts; the component intentionally shows an empty message. |
| 401 | Replace an invalid or revoked key and rebuild. |
| 429 or temporary failure | Honor the retry timing, check allowances and avoid mounting duplicate feed fetchers. |
| Missing media or dates | Optional fields are omitted safely; text remains visible. |
Continue with the API quickstart, response fields, or OpenAPI specification. The repository’s root check uses authored fixtures to test these states in a real production browser build.
Tested source and updates
Code and environment templates on this page are imported from the same pinned repository revision as the preview. The examples are MIT licensed by Accomplice AB. Browse the tested revision, including lockfiles and tests. Check newer framework releases before upgrading your own project.