---
tokens: 6071
title: "Build a Social Media Dashboard with React (2026) | Postproxy"
description: "Build a social media dashboard with React and Postproxy — post creation, account selection, status tracking, and publish logs in a custom publishing UI."
url: "https://postproxy.dev/blog/how-to-build-a-social-media-dashboard-with-postproxy-and-react/"
---
![Build a Social Media Dashboard with React (2026)](/blog/how-to-build-a-social-media-dashboard-with-postproxy-and-react.png)

Quick answer

Build a social media dashboard with React and Postproxy by setting up a thin Express proxy that forwards requests with your API key, then creating four screens: connected accounts (with reconnect for expired profiles), compose (text, media URL, profile checkboxes, draft or publish), post detail (per-platform status), and publish log. Use the \`/api/profiles\`, \`/api/posts\`, and \`/api/posts/:id/publish\` endpoints.

## Why build your own social media 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.

If you came here weighing this against Hootsuite or a similar full-stack tool, [the lightweight Hootsuite API alternative guide](/blog/hootsuite-api-alternative-lightweight-publishing-apis/) compares that path with rolling your own UI on top of a publishing API.

## What does the social media dashboard include?

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.

## How do you set up the React project?

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

```
mkdir social-dashboard && cd social-dashboardmkdir client servercd server && npm init -y && npm install express cors node-fetchcd ../client && npx create-react-app . --template typescript
```

Your server needs three environment variables:

```
POSTPROXY_API_KEY=your_api_key_herePOSTPROXY_BASE_URL=https://api.postproxy.devPORT=3001
```

## How do you build the backend proxy?

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

```
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 profilesapp.get('/api/profiles', async (req, res) => {  const data = await proxyGet('/api/profiles');  res.json(data);});
// List all postsapp.get('/api/posts', async (req, res) => {  const data = await proxyGet('/api/posts');  res.json(data);});
// Get single postapp.get('/api/posts/:id', async (req, res) => {  const data = await proxyGet(`/api/posts/${req.params.id}`);  res.json(data);});
// Create postapp.post('/api/posts', async (req, res) => {  const data = await proxyPost('/api/posts', req.body);  res.json(data);});
// Publish a draftapp.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.

```
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.

```
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.

```
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.

```
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.

## How do you wire the dashboard screens together?

A minimal router connects the screens:

```
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 should you add to the dashboard 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.

Request and response samples for each of these — scheduling, uploads, status polling — are in the [API examples](/getting-started/examples/), with the same calls available through the [official SDKs](/getting-started/sdks/) if you’d rather not hand-roll the fetch layer in your proxy.

## 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 — that positioning is why we describe it as [Supabase for social publishing](/blog/postproxy-is-supabase-for-social-media/).

Start with the [Postproxy API](https://app.postproxy.dev/users/sign_up) and build the dashboard your team actually wants.

![Denis Baranov](/blog/authors/denis-baranov.jpg)

[Denis Baranov](/blog/author/denis-baranov/)

Postproxy Founder

CTO behind Postproxy's infrastructure. Software engineer with over 20 years of building systems that don't break. Previously at Adjust, Denis built and scaled high-throughput integrations with major social ad platforms, processing attribution events at volume. Before that, he worked across fintech and legaltech, where handling sensitive data isn't optional.

tutorialreactsocial-media-publishingapi-integrationdashboardpublishing-api

```json
[
  {
    "@context": "https://schema.org",
    "@type": "Organization",
    "@id": "https://postproxy.dev/#organization",
    "name": "Postproxy",
    "alternateName": [
      "Postproxy API",
      "postproxy.dev"
    ],
    "url": "https://postproxy.dev",
    "logo": "https://postproxy.dev/logo-512.png",
    "description": "Postproxy is a unified social media API for agencies, SaaS products, and AI agents — publishing, comments, DMs, reviews, and analytics across Instagram, TikTok, LinkedIn, X, YouTube, Facebook, Threads, Bluesky, Pinterest, Telegram, and Google Business.",
    "foundingDate": "2026",
    "founder": [
      {
        "@type": "Person",
        "@id": "https://postproxy.dev/#dmitry-sereda",
        "name": "Dmitry Sereda",
        "jobTitle": "CEO",
        "sameAs": [
          "https://www.linkedin.com/in/dmitrysereda/"
        ]
      },
      {
        "@type": "Person",
        "@id": "https://postproxy.dev/#denis-baranov",
        "name": "Denis Baranov",
        "jobTitle": "CTO",
        "sameAs": [
          "https://www.linkedin.com/in/denis--baranov/"
        ]
      }
    ],
    "sameAs": [
      "https://github.com/postproxy",
      "https://www.linkedin.com/company/postproxy/",
      "https://www.youtube.com/@postproxy",
      "https://bsky.app/profile/postproxy.dev",
      "https://www.producthunt.com/products/postproxy",
      "https://www.capterra.com/p/10045650/Postproxy/",
      "https://sourceforge.net/software/product/Postproxy/",
      "https://n8n.io/integrations/postproxy/",
      "https://futuretools.io/tools/postproxy",
      "https://www.npmjs.com/package/postproxy-sdk",
      "https://pypi.org/project/postproxy/",
      "https://rubygems.org/gems/postproxy-sdk"
    ],
    "contactPoint": {
      "@type": "ContactPoint",
      "email": "privacy@postproxy.dev",
      "contactType": "customer service"
    },
    "address": {
      "@type": "PostalAddress",
      "streetAddress": "Kolonnenstr. 8",
      "addressLocality": "Berlin",
      "addressCountry": "DE"
    }
  },
  {
    "@context": "https://schema.org",
    "@type": "WebSite",
    "@id": "https://postproxy.dev/#website",
    "name": "Postproxy",
    "alternateName": "Postproxy — Social Media API",
    "url": "https://postproxy.dev",
    "description": "Unified social media API for agencies, SaaS, and AI agents.",
    "inLanguage": "en-US",
    "publisher": {
      "@id": "https://postproxy.dev/#organization"
    }
  },
  {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    "headline": "Build a Social Media Dashboard with React (2026)",
    "description": "Build a social media dashboard with React and Postproxy — post creation, account selection, status tracking, and publish logs in a custom publishing UI.",
    "datePublished": "2026-02-24T00:00:00.000Z",
    "author": {
      "@type": "Person",
      "name": "Denis Baranov",
      "url": "https://postproxy.dev/blog/author/denis-baranov/",
      "jobTitle": "Postproxy Founder",
      "image": "https://postproxy.dev/blog/authors/denis-baranov.jpg",
      "sameAs": [
        "https://www.linkedin.com/in/denis--baranov/"
      ]
    },
    "image": [
      "https://postproxy.dev/blog/how-to-build-a-social-media-dashboard-with-postproxy-and-react.png"
    ],
    "mainEntityOfPage": {
      "@type": "WebPage",
      "@id": "https://postproxy.dev/blog/how-to-build-a-social-media-dashboard-with-postproxy-and-react/"
    },
    "publisher": {
      "@type": "Organization",
      "@id": "https://postproxy.dev/#organization",
      "name": "Postproxy",
      "logo": {
        "@type": "ImageObject",
        "url": "https://postproxy.dev/logo-512.png"
      }
    }
  },
  {
    "@context": "https://schema.org",
    "@type": "WebPage",
    "@id": "https://postproxy.dev/blog/how-to-build-a-social-media-dashboard-with-postproxy-and-react/",
    "url": "https://postproxy.dev/blog/how-to-build-a-social-media-dashboard-with-postproxy-and-react/",
    "name": "Build a Social Media Dashboard with React (2026) | Postproxy",
    "description": "Build a social media dashboard with React and Postproxy — post creation, account selection, status tracking, and publish logs in a custom publishing UI.",
    "inLanguage": "en",
    "isPartOf": {
      "@id": "https://postproxy.dev/#website"
    }
  },
  {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    "itemListElement": [
      {
        "@type": "ListItem",
        "position": 1,
        "name": "Home",
        "item": "https://postproxy.dev/"
      },
      {
        "@type": "ListItem",
        "position": 2,
        "name": "Blog",
        "item": "https://postproxy.dev/blog/"
      },
      {
        "@type": "ListItem",
        "position": 3,
        "name": "Build a Social Media Dashboard with React (2026)"
      }
    ]
  }
]
```
