
Most agencies lose clients not because of bad results — but because of bad communication. The client doesn't know what's happening, they send an email asking for an update, you scramble to pull a screenshot from Meta Ads, and suddenly they're wondering if they're getting their money's worth. A branded client portal fixes all of that. And with Next.js and Supabase, you can build one for $0/month that looks more polished than software charging $299/seat.
This is exactly what we build at Vixi for our agency clients. Here's how it works.
Why Your Clients Keep Emailing You for Updates
Every "where's my report?" email is a small trust failure. It means the client had a question, didn't have a place to look, and had to interrupt you to get an answer they should have been able to find themselves.
Agencies that use client portals retain clients significantly longer. At Vixi, we've measured that clients with access to a live or weekly performance view churn at roughly half the rate of those receiving reports only by email. The reason is simple: visibility creates confidence. When a client can log in and see their campaign spend, ROAS, leads generated, and invoice history — they feel like they're working with a real operation.
A portal also reduces your team's interruption overhead. Instead of pulling reports manually or answering the same questions on Slack, the data is always there.
What a Proper Client Portal Needs
Before you build anything, define what the portal actually delivers. At minimum, a useful agency client portal covers:
- Campaign performance metrics — spend, ROAS, leads, conversions. Updated weekly or in near-real-time.
- Report history — PDFs or summaries from previous months, accessible anytime.
- Invoice history — paid/unpaid status, downloadable PDFs.
- File storage — creative assets, briefs, deliverables.
- Role-based access — each client sees only their data, not anyone else's.
Optional but high-value additions: a messaging thread per client, task/project status tracking, and custom branding per account so it feels like your agency built this just for them.
The Stack: Next.js + Supabase at $0/Month
You don't need to buy portal software. Here's what we use:
- Next.js (App Router) — React framework with server components, API routes, and TypeScript support out of the box
- Supabase — Postgres database, authentication, file storage, and Row Level Security (RLS) for access control
- Vercel — Free hosting with automatic deploys from GitHub
- Tailwind CSS + shadcn/ui — Fast, clean UI with zero design overhead
The Supabase free tier handles up to 500MB database, 1GB file storage, and 50,000 monthly active users. That covers the vast majority of agencies. Vercel's free tier handles unlimited bandwidth for typical agency workloads. Total cost: $0/month until you scale significantly.
Compare this to tools like Copilot, AgencyAnalytics, or custom WordPress portals. You get more control, better performance, and your own branded domain.
Setting Up the Database Schema
The core schema is straightforward. You need tables for clients, campaigns, reports, invoices, and files — all linked to a client ID.
-- Clients table
create table clients (
id uuid primary key default gen_random_uuid(),
name text not null,
email text unique not null,
company text,
created_at timestamptz default now()
);
-- Campaigns
create table campaigns (
id uuid primary key default gen_random_uuid(),
client_id uuid references clients(id) on delete cascade,
name text not null,
platform text, -- 'meta', 'google', 'email'
status text default 'active',
created_at timestamptz default now()
);
-- Weekly performance snapshots
create table campaign_snapshots (
id uuid primary key default gen_random_uuid(),
campaign_id uuid references campaigns(id) on delete cascade,
client_id uuid references clients(id) on delete cascade,
week_start date not null,
spend numeric(10,2),
revenue numeric(10,2),
roas numeric(5,2),
leads integer,
clicks integer,
impressions integer,
created_at timestamptz default now()
);
-- Invoices
create table invoices (
id uuid primary key default gen_random_uuid(),
client_id uuid references clients(id) on delete cascade,
amount numeric(10,2) not null,
status text default 'pending', -- 'pending', 'paid', 'overdue'
due_date date,
paid_at timestamptz,
invoice_url text,
created_at timestamptz default now()
);
Row Level Security — The Critical Part
Without RLS, any authenticated user can query any row in your database. With RLS enabled, Supabase enforces access policies at the database level, so clients can only see their own data even if your API has a bug.
-- Enable RLS on all client-facing tables
alter table campaigns enable row level security;
alter table campaign_snapshots enable row level security;
alter table invoices enable row level security;
-- Policy: clients can only read their own campaigns
create policy "clients_read_own_campaigns"
on campaigns for select
using (
client_id = (
select id from clients
where email = auth.jwt() ->> 'email'
)
);
-- Same pattern for snapshots and invoices
create policy "clients_read_own_snapshots"
on campaign_snapshots for select
using (
client_id = (
select id from clients
where email = auth.jwt() ->> 'email'
)
);
Your agency admin account bypasses RLS via service role key. Client logins use the anon/user key and hit the policies automatically.
Authentication and Route Protection
Use Supabase Auth with magic link (passwordless) for client logins. It's frictionless — clients just click a link in their email. No password resets to support.
Protect all portal routes with Next.js middleware:
// middleware.ts
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(req: NextRequest) {
const res = NextResponse.next()
const supabase = createMiddlewareClient({ req, res })
const {
data: { session },
} = await supabase.auth.getSession()
// Redirect unauthenticated users to login
if (!session && req.nextUrl.pathname.startsWith('/portal')) {
const redirectUrl = req.nextUrl.clone()
redirectUrl.pathname = '/login'
redirectUrl.searchParams.set('redirectedFrom', req.nextUrl.pathname)
return NextResponse.redirect(redirectUrl)
}
return res
}
export const config = {
matcher: ['/portal/:path*'],
}
All routes under /portal/* require a valid session. The RLS policies ensure that even if someone is authenticated, they can only access their own client data.
Weekly Report Delivery
Automated weekly reports are what turn a portal into a communication system. Here's how we set it up:
- Data pull — An n8n workflow (or a cron job) runs every Monday morning, pulls the previous week's stats from Meta Ads and Google Ads APIs, and writes snapshots to
campaign_snapshots. - Report generation — Optionally generate a PDF summary using a headless browser or a PDF library.
- Email notification — Send the client an email letting them know their report is ready, with a link to the portal.
// app/api/reports/latest/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function GET() {
const supabase = createRouteHandlerClient({ cookies })
const { data: session } = await supabase.auth.getSession()
if (!session.session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get client ID for the logged-in user
const { data: client } = await supabase
.from('clients')
.select('id')
.eq('email', session.session.user.email)
.single()
if (!client) {
return NextResponse.json({ error: 'Client not found' }, { status: 404 })
}
// Get latest snapshots — RLS enforces access automatically
const { data: snapshots } = await supabase
.from('campaign_snapshots')
.select(`
*,
campaigns (name, platform)
`)
.eq('client_id', client.id)
.order('week_start', { ascending: false })
.limit(10)
return NextResponse.json({ snapshots })
}
The beauty of this approach: you never expose raw ad account credentials to clients. You pull the data server-side and store clean snapshots. The client sees exactly what you decide to show them.
The Dashboard Component
The client-facing dashboard should be clean and scannable. Clients don't need every metric — they need to feel like things are working.
// components/portal/CampaignCard.tsx
interface CampaignCardProps {
name: string
platform: string
spend: number
revenue: number
roas: number
leads: number
weekStart: string
}
export function CampaignCard({
name,
platform,
spend,
revenue,
roas,
leads,
weekStart,
}: CampaignCardProps) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-white">{name}</h3>
<span className="text-xs text-slate-400 uppercase tracking-wide">
{platform}
</span>
</div>
<div className="grid grid-cols-2 gap-4">
<Metric label="Spend" value={`$${spend.toLocaleString()}`} />
<Metric label="Revenue" value={`$${revenue.toLocaleString()}`} />
<Metric label="ROAS" value={`${roas}x`} highlight={roas >= 3} />
<Metric label="Leads" value={leads.toString()} />
</div>
<p className="text-xs text-slate-500 mt-4">
Week of {new Date(weekStart).toLocaleDateString()}
</p>
</div>
)
}
function Metric({
label,
value,
highlight,
}: {
label: string
value: string
highlight?: boolean
}) {
return (
<div>
<p className="text-xs text-slate-500 mb-1">{label}</p>
<p className={`text-lg font-bold ${highlight ? 'text-green-400' : 'text-white'}`}>
{value}
</p>
</div>
)
}
Keep the color palette minimal. Dark background, white text, one accent color for positive metrics. Clients opening this on a phone in between meetings need to parse it in 10 seconds.
Making It Look Enterprise: Details That Matter
The difference between a portal that looks like a side project and one that looks enterprise-grade comes down to a few specifics:
Custom domain. Set up portal.youragency.com or clients.youragency.com. A Vercel subdomain or Heroku URL destroys credibility immediately. This takes 10 minutes to configure.
Per-client branding. Store a brand_color and logo_url per client in the database. Inject them into the portal UI. The client sees their own colors and logo in the header. Cost to implement: about 2 hours. Impact: massive.
Consistent email design. Your notification emails should match the portal design. Use a transactional email tool like Resend (free tier is generous) and build a simple HTML template once.
Mobile-first layout. Use Tailwind's responsive utilities from the start. A portal that breaks on iPhone is worse than no portal at all.
Loading states. Add skeleton loaders to every data-fetching component. A portal that shows empty boxes while loading looks broken. A portal that shows animated placeholders looks intentional and polished.
Bonus: Invoice Download
Give clients one-click PDF download for any invoice. Store the PDF URL in Supabase Storage, generate a signed URL on demand, and open it in a new tab. This eliminates another category of "can you send me my invoice from March?" emails.
// Generate a signed URL for invoice download
const { data } = await supabase.storage
.from('invoices')
.createSignedUrl(`${clientId}/${invoiceId}.pdf`, 3600) // 1 hour expiry
if (data?.signedUrl) {
window.open(data.signedUrl, '_blank')
}
What This Costs You to Build
If you're doing this in-house: expect 3-5 days of focused development for a solid v1 with auth, campaign dashboard, invoice history, and weekly report delivery. The recurring infrastructure cost is $0/month on Supabase + Vercel free tiers.
If you want this built for you: at Vixi, we build client portals like this for agencies and B2B businesses as part of our full-stack development service. We've built similar systems for our clients at CQ Marketing and Vetcelerator — internal tools that make the client relationship self-service and the agency look 10x its size.
Ready to Build Your Portal?
If you're running an agency and your reporting workflow still involves exporting CSVs and copying screenshots into Google Slides, this is one of the highest-ROI technical investments you can make. It reduces churn, reduces your team's overhead, and positions you as a serious operator.
We build these for agencies in Dallas and across the US. If you want us to spec out and build your portal — or help you integrate it with your existing ad accounts, CRM, and invoicing system — book a free automation audit at vixi.agency/book-a-call. We'll show you exactly what the build looks like and what it would take to get it live.