How to build a social media dashboard with Postproxy and React

Tutorial for building a custom publishing UI on top of Postproxy's API. Covers post creation, account selection, status tracking, and publish logs in a React frontend.

How to build a social media dashboard with Postproxy and React

Why build your own dashboard

Postproxy gives you a publishing API. One endpoint, multiple platforms, media handling included. But the dashboard you use to interact with that API doesn’t have to be ours.

Maybe you want publishing embedded in a tool your team already uses. Maybe you need an approval flow that doesn’t exist in any off-the-shelf product. Maybe your content pipeline has opinions about how posts should be created, reviewed, and tracked — and you want the UI to reflect those opinions.

This tutorial walks through building a React dashboard on top of Postproxy’s API. Not a toy. A functional publishing interface with account selection, post creation, status tracking, and a publish log. The kind of thing you could hand to a social media manager and they’d actually use.

What we’re building

Four screens:

  1. Accounts — connected social profiles, their status, and a way to reconnect expired ones
  2. Compose — create a post, pick platforms, optionally attach media, publish or save as draft
  3. Post detail — per-platform publish status for a single post
  4. Publish log — a feed of all posts with their outcomes

The backend is a thin proxy — a few API routes that forward requests to Postproxy with your API key. The frontend is React with straightforward state management. No framework magic. No abstractions you’d have to undo later.

Project setup

Start with a React app and a minimal Express backend. The backend exists to keep your API key out of the browser.

Terminal window
mkdir social-dashboard && cd social-dashboard
mkdir client server
cd server && npm init -y && npm install express cors node-fetch
cd ../client && npx create-react-app . --template typescript

Your server needs three environment variables:

server/.env
POSTPROXY_API_KEY=your_api_key_here
POSTPROXY_BASE_URL=https://api.postproxy.dev
PORT=3001

The backend proxy

The server is intentionally thin. It adds the authorization header and forwards requests. That’s it.

server/index.js
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json());
const API_KEY = process.env.POSTPROXY_API_KEY;
const BASE_URL = process.env.POSTPROXY_BASE_URL || 'https://api.postproxy.dev';
async function proxyGet(path) {
const res = await fetch(`${BASE_URL}${path}`, {
headers: { 'Authorization': `Bearer ${API_KEY}` },
});
return res.json();
}
async function proxyPost(path, body) {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
return res.json();
}
// List connected profiles
app.get('/api/profiles', async (req, res) => {
const data = await proxyGet('/api/profiles');
res.json(data);
});
// List all posts
app.get('/api/posts', async (req, res) => {
const data = await proxyGet('/api/posts');
res.json(data);
});
// Get single post
app.get('/api/posts/:id', async (req, res) => {
const data = await proxyGet(`/api/posts/${req.params.id}`);
res.json(data);
});
// Create post
app.post('/api/posts', async (req, res) => {
const data = await proxyPost('/api/posts', req.body);
res.json(data);
});
// Publish a draft
app.post('/api/posts/:id/publish', async (req, res) => {
const data = await proxyPost(`/api/posts/${req.params.id}/publish`, {});
res.json(data);
});
app.listen(process.env.PORT || 3001, () => {
console.log('Proxy server running on :3001');
});

This gives your React app a same-origin API that mirrors Postproxy’s endpoints. No API key in the browser. No CORS issues. Add error handling and rate limiting before you ship this to production — but for building the dashboard, this is enough.

Screen 1: Connected accounts

The accounts screen is the first thing a user sees. It answers one question: “Where can I publish?”

Fetch profiles on mount and render them as a list. Each profile has a platform, a display name, and a status.

client/src/components/Accounts.tsx
import { useEffect, useState } from 'react';
interface Profile {
id: string;
name: string;
platform: string;
status: string;
expires_at: string | null;
}
export function Accounts() {
const [profiles, setProfiles] = useState<Profile[]>([]);
useEffect(() => {
fetch('/api/profiles')
.then(res => res.json())
.then(data => setProfiles(data));
}, []);
return (
<div>
<h2>Connected Accounts</h2>
{profiles.map(profile => (
<div key={profile.id} className="profile-card">
<span className="platform-badge">{profile.platform}</span>
<span className="profile-name">{profile.name}</span>
<span className={`status status-${profile.status}`}>
{profile.status}
</span>
{profile.status === 'expired' && (
<button onClick={() => handleReconnect(profile.platform)}>
Reconnect
</button>
)}
</div>
))}
</div>
);
}

The status field does the heavy lifting. An active profile is ready to publish. An expired profile needs reconnection — the user’s OAuth token has lapsed. Show this clearly. A “Reconnect” button that triggers a new Initialize Connection flow is the right UX. Don’t hide expired accounts. Don’t silently skip them during publishing. Make the state visible.

For reconnection, your backend calls Postproxy’s Initialize Connection endpoint and returns the OAuth URL. The frontend redirects the user there. After authentication, they land back on your callback URL with a refreshed token.

Screen 2: Compose

This is where posts are created. The compose screen needs three things: a text input, a profile selector, and a publish action.

client/src/components/Compose.tsx
import { useEffect, useState } from 'react';
interface Profile {
id: string;
name: string;
platform: string;
status: string;
}
export function Compose() {
const [body, setBody] = useState('');
const [profiles, setProfiles] = useState<Profile[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const [mediaUrl, setMediaUrl] = useState('');
const [publishing, setPublishing] = useState(false);
const [result, setResult] = useState<any>(null);
useEffect(() => {
fetch('/api/profiles')
.then(res => res.json())
.then(data => setProfiles(data.filter((p: Profile) => p.status === 'active')));
}, []);
function toggleProfile(id: string) {
setSelected(prev =>
prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
);
}
async function handlePublish(draft: boolean) {
setPublishing(true);
const payload: any = {
post: { body, draft },
profiles: selected,
};
if (mediaUrl) {
payload.media = [mediaUrl];
}
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
setResult(data);
setPublishing(false);
}
return (
<div>
<h2>Compose</h2>
<textarea
value={body}
onChange={e => setBody(e.target.value)}
placeholder="What do you want to publish?"
rows={5}
/>
<input
type="text"
value={mediaUrl}
onChange={e => setMediaUrl(e.target.value)}
placeholder="Media URL (optional)"
/>
<div className="profile-selector">
<h3>Publish to:</h3>
{profiles.map(profile => (
<label key={profile.id}>
<input
type="checkbox"
checked={selected.includes(profile.id)}
onChange={() => toggleProfile(profile.id)}
/>
{profile.platform}{profile.name}
</label>
))}
</div>
<div className="actions">
<button
onClick={() => handlePublish(false)}
disabled={publishing || !body || selected.length === 0}
>
Publish Now
</button>
<button
onClick={() => handlePublish(true)}
disabled={publishing || !body || selected.length === 0}
>
Save as Draft
</button>
</div>
{result && (
<div className="result">
<p>Post created: {result.id}</p>
</div>
)}
</div>
);
}

A few design decisions worth noting:

Only show active profiles in the selector. Expired profiles shouldn’t appear as options. The user doesn’t need to wonder why publishing to their LinkedIn failed — it shouldn’t be selectable if the token is expired.

Draft vs. publish is a single flag. The same creation flow, the same payload, one boolean difference. This maps cleanly to “Save as Draft” and “Publish Now” buttons. No separate forms, no separate endpoints.

Media as a URL. For a v1 dashboard, accepting a media URL is simpler than building a file upload flow. Postproxy accepts both — you can add file uploads later by switching to multipart form data on the backend proxy.

Screen 3: Post detail

After a post is created, you need to show what happened. Postproxy returns per-platform results, and this screen renders them.

client/src/components/PostDetail.tsx
import { useEffect, useState } from 'react';
interface PlatformResult {
platform: string;
profile_name: string;
status: string;
error?: string;
published_url?: string;
}
interface Post {
id: string;
body: string;
created_at: string;
draft: boolean;
platform_statuses: PlatformResult[];
}
export function PostDetail({ postId }: { postId: string }) {
const [post, setPost] = useState<Post | null>(null);
useEffect(() => {
fetch(`/api/posts/${postId}`)
.then(res => res.json())
.then(setPost);
}, [postId]);
if (!post) return <div>Loading...</div>;
return (
<div>
<h2>Post Detail</h2>
<p className="post-body">{post.body}</p>
<p className="post-meta">
Created: {new Date(post.created_at).toLocaleString()}
{post.draft && <span className="draft-badge">Draft</span>}
</p>
<h3>Platform Results</h3>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Account</th>
<th>Status</th>
<th>Link</th>
</tr>
</thead>
<tbody>
{post.platform_statuses.map((ps, i) => (
<tr key={i}>
<td>{ps.platform}</td>
<td>{ps.profile_name}</td>
<td className={`status status-${ps.status}`}>
{ps.status}
{ps.error && <span className="error-detail">{ps.error}</span>}
</td>
<td>
{ps.published_url && (
<a href={ps.published_url} target="_blank" rel="noreferrer">
View
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
{post.draft && (
<button onClick={() => handlePublishDraft(post.id)}>
Publish Draft
</button>
)}
</div>
);
}

This screen matters more than it looks. Social media publishing has partial success as a normal outcome. A post might publish to LinkedIn and Threads but fail on Instagram because the image aspect ratio is wrong. Showing per-platform results — with the specific error when something fails — is the difference between a useful dashboard and a frustrating one.

The “Publish Draft” button appears when viewing a draft post. It calls the /posts/:id/publish endpoint through your proxy, transitioning the post from draft to published across all selected platforms.

Screen 4: Publish log

The publish log is the operational view. Every post, its target platforms, and what happened.

client/src/components/PublishLog.tsx
import { useEffect, useState } from 'react';
interface PostSummary {
id: string;
body: string;
created_at: string;
draft: boolean;
platform_statuses: { platform: string; status: string }[];
}
export function PublishLog({ onSelectPost }: { onSelectPost: (id: string) => void }) {
const [posts, setPosts] = useState<PostSummary[]>([]);
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(setPosts);
}, []);
function statusSummary(statuses: { platform: string; status: string }[]) {
const published = statuses.filter(s => s.status === 'published').length;
const failed = statuses.filter(s => s.status === 'failed').length;
const total = statuses.length;
if (failed === 0) return `${published}/${total} published`;
return `${published}/${total} published, ${failed} failed`;
}
return (
<div>
<h2>Publish Log</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>Content</th>
<th>Status</th>
<th>Platforms</th>
<th></th>
</tr>
</thead>
<tbody>
{posts.map(post => (
<tr key={post.id}>
<td>{new Date(post.created_at).toLocaleDateString()}</td>
<td className="post-preview">
{post.body.substring(0, 80)}{post.body.length > 80 ? '...' : ''}
</td>
<td>
{post.draft ? (
<span className="draft-badge">Draft</span>
) : (
statusSummary(post.platform_statuses)
)}
</td>
<td>
{post.platform_statuses.map(ps => ps.platform).join(', ')}
</td>
<td>
<button onClick={() => onSelectPost(post.id)}>
Details
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

The statusSummary function is doing the important work here. “3/3 published” is a green light. “2/3 published, 1 failed” tells the user something needs attention. Clicking through to the post detail shows exactly which platform failed and why.

For a production dashboard, add polling or a refresh button. Post statuses can change — a post might be processing immediately after creation and transition to published or failed within seconds. A simple setInterval that re-fetches the post list every 30 seconds handles this.

Wiring it together

A minimal router connects the screens:

client/src/App.tsx
import { useState } from 'react';
import { Accounts } from './components/Accounts';
import { Compose } from './components/Compose';
import { PublishLog } from './components/PublishLog';
import { PostDetail } from './components/PostDetail';
type View = 'accounts' | 'compose' | 'log' | 'detail';
export default function App() {
const [view, setView] = useState<View>('compose');
const [selectedPostId, setSelectedPostId] = useState<string | null>(null);
function openDetail(id: string) {
setSelectedPostId(id);
setView('detail');
}
return (
<div className="app">
<nav>
<button onClick={() => setView('accounts')}>Accounts</button>
<button onClick={() => setView('compose')}>Compose</button>
<button onClick={() => setView('log')}>Publish Log</button>
</nav>
<main>
{view === 'accounts' && <Accounts />}
{view === 'compose' && <Compose />}
{view === 'log' && <PublishLog onSelectPost={openDetail} />}
{view === 'detail' && selectedPostId && (
<PostDetail postId={selectedPostId} />
)}
</main>
</div>
);
}

No router library. No global state management. The state is a view name and an optional post ID. This is enough for a dashboard that four screens.

What to add next

This tutorial covers the foundation. Here’s what a production version would need:

Scheduling. Add a datetime picker to the compose screen. Pass scheduled_at in the post payload. Show scheduled posts differently in the publish log — they’re neither drafts nor published, they’re pending.

File uploads. Switch the backend proxy from JSON to multipart form data. Accept file uploads in the compose screen and forward them to Postproxy. This lets users drag and drop images instead of pasting URLs.

Platform-specific parameters. Instagram Reels need the format: "reel" parameter. YouTube needs a title and privacy_status. TikTok needs privacy_status. Add conditional fields in the compose screen based on which platforms are selected, and pass them in the platforms object.

Polling for status updates. After creating a post, poll the post detail endpoint every few seconds until all platform statuses are terminal (published or failed). Show a spinner for platforms that are still processing.

Error recovery. When a platform fails, show the error message and offer a “Retry” option — create a new post targeting only the failed platforms with the same content.

The point

Postproxy handles the hard parts — OAuth, media processing, platform-specific upload protocols, token lifecycle, per-platform error handling. Your dashboard handles the parts that matter to your team — how content is composed, who reviews it, what the publish log looks like, and how failures surface.

You’re not building a social media publishing platform. You’re building a UI for your publishing workflow. Postproxy is the infrastructure underneath.

Start with the Postproxy API and build the dashboard your team actually wants.

Ready to get started?

Start with our free plan and scale as your needs grow. No credit card required.