Rex Automaton
All posts
Reporting & AnalyticsSeptember 1, 20269 min read

How We Built a Content Ops Dashboard for RZM Media

Single-pane Next.js dashboard on Vercel with Supabase: one screen for pipeline, tasks, outreach, and status. Fewer tabs, cleaner handoffs, and safer ops.

By Jacky Lei

We designed and shipped a single-pane operations dashboard for RZM Media: one place where Ricky's team sees outreach pipeline, production status, tasks, and next actions without living in ten tabs. It is built for creative agencies that want fewer miscues and faster daily standups. This post shows the mechanics, what we learned, and how to replicate it.

Definition: a content ops dashboard is a single web app that aggregates metrics, tasks, and status from the tools you already use into one action-first screen with safe, role-based controls.

The problem it solves

The answer: creative teams lose time context switching and reconciling data across spreadsheets, DMs, email, and task boards. A single pane reduces misses and speeds decisions.

Content operations at a boutique agency run across outreach, intake, creative, and delivery. Before the dashboard, status checks meant hunting in Sheets, Instagram DMs, Slack, and the editing queue. Small gaps created expensive misses: a warm reply not seen until tomorrow, a draft stuck for approval, or an asset request buried in a thread. The work itself did not need reinvention. The visibility layer did.

Workflow elementManual: beforeAutomated: after
Daily standup20 to 30 minutes of tab-hopping and verbal updatesOne screen with Today's Focus and blockers listed, 8 to 12 minutes
Outreach statusDM threads and ad hoc notesReply counts, flagged threads, and follow-up queue in one list
Production flowEditor asks status in chat, PM updates a SheetCards move across stages with auto timestamps and owner
ApprovalsDM or email based, often missedApprovals tab with stale timers and a clear next step
ReportingFriday recap done by handSaved snapshots for week-over-week review

How the automation works

The answer: normalize inputs to a warehouse table, compute stable KPIs, then surface an action-first UI. We keep write actions deliberate to avoid accidental sends.

  • Sources: we ingest from the systems RZM Media already uses. That included CSV or Google Sheets for prospect lists, a light CRM view for contact state, and a manual outreach log feed. We did not automate DMs or sending in this build by design.
  • Store and compute: Supabase Postgres holds normalized tables. A daily job computes KPIs like replies today, drafts awaiting review, assets requested, and jobs stuck longer than a threshold.
  • App server: Next.js on Vercel serves a read-first dashboard. We keep server-only secrets and use static regeneration for fast loads with safe freshness.
  • UI patterns: Today's Focus, Outreach, Production, Approvals, and a lightweight Activity log. Each row links back to the system of record.
  • Safety rails: no auto-sends. Any action that could touch an external audience is out of scope or routed to a human step.

RZM Media operations dashboard: sources flow into a compute layer, the Next.js app is the single pane, and outcomes are faster standups and fewer misses

Step-by-step: how to build it

1) Model the data in Supabase

We keep the schema boring and explicit: prospects, projects, assets, activities, and kpi_snapshots. RLS is on by default.

-- prospects: outreach and follow-up
create table if not exists prospects (
  id uuid primary key default gen_random_uuid(),
  handle text not null,
  source text not null,
  status text not null check (status in ('new','contacted','replied','qualified','closed')),
  last_touch timestamptz,
  notes text,
  inserted_at timestamptz default now()
);
 
-- production cards: draft to delivered
create table if not exists projects (
  id uuid primary key default gen_random_uuid(),
  title text not null,
  client text not null,
  stage text not null check (stage in ('brief','editing','qa','awaiting-approval','scheduled','delivered')),
  owner text,
  updated_at timestamptz default now()
);
 
-- simple activity log for audit trails
create table if not exists activities (
  id bigint generated always as identity primary key,
  entity_type text not null,
  entity_id uuid not null,
  action text not null,
  actor text,
  at timestamptz default now()
);

Gotcha: decide early which fields you compute vs store. We store timestamps and compute durations in SQL views to avoid drift.

2) Build a safe ingest path

We used a CSV or Google Sheets exporter for prospects and production items. The server normalizes rows and upserts by a natural key.

// app/api/ingest/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
 
export async function POST(req: Request) {
  const body = await req.json(); // { table: 'prospects', rows: [...] }
  const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!);
  const { table, rows } = body;
  const { error } = await supabase.from(table).upsert(rows, { onConflict: "handle" });
  if (error) return NextResponse.json({ ok: false, error: error.message }, { status: 400 });
  return NextResponse.json({ ok: true });
}

Gotcha: never trust client input for table selection in production. In a real deployment, map allowed sources to fixed handlers.

3) Compute stable KPIs on a schedule

A daily job writes a kpi snapshot so week-over-week charts are fast and consistent.

insert into kpi_snapshots (as_of, replies_today, awaiting_approval, stuck_cards)
select now(),
  (select count(*) from prospects where status = 'replied' and last_touch::date = now()::date),
  (select count(*) from projects where stage = 'awaiting-approval'),
  (select count(*) from projects where stage in ('editing','qa') and now() - updated_at > interval '48 hours');

Gotcha: timezones matter. Store UTC, render in the team's local timezone, and anchor snapshots on the team's start of day.

4) Expose a read-first API for the dashboard

We keep reads simple and cached. Heavy joins live in SQL views.

// app/api/overview/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
 
export async function GET() {
  const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!);
  const [{ data: kpi }, { data: queue }] = await Promise.all([
    supabase.from('kpi_latest_view').select('*').single(),
    supabase.from('production_queue_view').select('*').limit(25)
  ]);
  return NextResponse.json({ kpi, queue }, { headers: { 'Cache-Control': 's-maxage=60' } });
}

Gotcha: serve secrets server-side only. The client fetches your API, not the data store directly.

5) Build the single-pane UI

Tabs are anti-patterns for daily cadence. We used an action-first layout: Today's Focus on top, then Outreach, Production, Approvals, and Activity.

// components/TodayFocus.tsx
export function TodayFocus({ kpi }: { kpi: any }) {
  const items = [
    { label: 'Replies today', value: kpi.replies_today },
    { label: 'Awaiting approval', value: kpi.awaiting_approval },
    { label: 'Stuck > 48h', value: kpi.stuck_cards }
  ];
  return (
    <div className="grid gap-3 grid-cols-3">
      {items.map(i => (
        <div key={i.label} className="rounded-lg border p-4">
          <div className="text-sm text-neutral-500">{i.label}</div>
          <div className="text-2xl font-semibold">{i.value}</div>
        </div>
      ))}
    </div>
  );
}

Gotcha: keep line lengths and copy short. A dashboard is for decisions, not narrative.

6) Add safe write actions where it helps

Some edits make sense in-app: moving a card to Scheduled or tagging a prospect for a Friday follow-up.

// app/api/projects/move/route.ts
export async function POST(req: Request) {
  const { id, stage } = await req.json();
  // validate stage against an allowlist before write
  // update projects set stage = $1, updated_at = now() where id = $2
}

Gotcha: do not trigger any outbound messages from the dashboard unless explicitly approved. In this build, no auto-sends exist by design.

7) Ship with clear ownership and access controls

We shipped with role-based access, audit logs for changes, and a runbook. That ensured anyone on the team could see state without risking accidental edits to the system of record.

-- Example RLS posture: read-all for authenticated, writes gated by role
alter table projects enable row level security;
create policy projects_read on projects for select to authenticated using (true);
create policy projects_write on projects for update using (auth.jwt() ->> 'role' = 'manager');

Gotcha: make the dashboard the source of truth for status while leaving content and messaging in their respective tools. Link out, do not mirror assets.

Where it gets complicated

  • Stale or duplicate sources: prospect spreadsheets and editing boards drift. Deduplicate by a natural key and show conflict badges so the team fixes the source.
  • Time windows and team cadence: creatives work in sprints, outreach runs daily. KPIs must respect different clocks or the board will feel off.
  • Approvals and quality bars: an Approvals tab helps, but the rule set still belongs in the business. Encode timers and owners, not subjective quality.
  • Accidental sends: connecting to messaging tools is tempting. We kept this read-first. Add sends later with a deliberate approval queue.
  • Attribution and replies: DM platforms are hostile to automation. Treat reply counts and warm threads as a human-in-the-loop list, not something to scrape aggressively.

What this actually changes

For a boutique creative agency, the gain is structural: fewer misses at handoff and faster daily alignment. McKinsey estimated knowledge workers spend about 1.8 hours a day searching and gathering information, roughly 19 percent of the workweek (source: McKinsey Global Institute, The social economy, 2012). Centralizing status and next actions cuts that waste. In practice we saw standups shorten and fewer back-and-forth messages about where a piece sat. No vanity graphs. Just the right list, at the right time.

Frequently asked questions

Is this a read-only dashboard or can it change records?

Read-first by design. We expose safe writes for status moves and tags inside the dashboard, but anything that could touch external audiences stays manual. That keeps outreach and brand voice protected.

What tools does it integrate with?

We kept the build vendor-agnostic. Inputs arrive via CSV or Sheets exports and a light CRM view. The store is a Postgres database. The app runs on a modern web stack. If we add more sources, we do it through controlled feeders rather than binding to one vendor.

How fast is the data?

Near real-time where it matters. We cache read endpoints for about a minute. KPIs snapshot daily for consistency on charts. For live triage views, we refresh rows on interaction.

Can this work if my team already lives in a project tool?

Yes. The point is one action-first pane. We link out to the system of record for assets and comments. The dashboard shows status, blockers, and next actions without moving the whole team.

What does it cost monthly to run?

Hosting and the database are inexpensive at the volumes most boutique agencies run. The cost you feel is the initial build. The monthly is typically a few coffees compared to the time saved in daily coordination.

How long does a deployment take?

We ship a working first version in days, then shape the tabs and KPIs to your cadence. Read-first patterns deploy faster and more safely than write-heavy builds.

If you want a single-pane view for your creative or content shop, we have shipped this pattern and can adapt it to your tools. See how we automate distribution in our post on automate LinkedIn content scheduling, explore our workflow automation services, or book a 15-minute call.

Want us to build this for you?

15-minute discovery call. No pitch. We tell you what to automate first.

Book a Discovery Call

Related reading