← All posts

7 min read

Building an Admin Dashboard for Your Mobile App

How to build an admin dashboard for your mobile app: low-code vs custom web apps, roles and permissions, audit logs, a shared API, safe destructive actions and E2E tests.

  • Web
  • Admin Dashboard
  • Backend
Cover illustration for Building an Admin Dashboard for Your Mobile App

Every mobile app has a second, invisible app behind it. Someone has to approve new sellers, refund an order, ban a spammer, fix a typo in a product name, or look up why a customer says they were charged twice.

In the first brief, that someone is usually “we’ll just do it in the database.” A month after launch, it’s a founder running SQL by hand on production at midnight. That works until it really, really doesn’t.

So I plan for an admin dashboard on almost every project, even if version one is tiny. Here’s how I think about building one.

First, list the jobs

Before choosing tools, I write down what the admin team actually does, ranked by how often:

  • Look up a user or order by email, phone or ID
  • Edit content: products, categories, banners, copy
  • Approve or reject things: sellers, listings, reports
  • Handle money: refunds, credits, subscription fixes
  • Moderate: suspend accounts, remove content
  • See basic numbers: sign-ups, orders, revenue

The first item is almost always the most important. A good search box is worth more than ten charts. Most admin work starts with “find this person.”

This list also tells you which actions are risky. Viewing data is low risk. Refunding money and deleting accounts are not, and those get extra care later.

Option 1: the Firebase console (and why it runs out)

If your app runs on Firebase, the console is tempting as an admin tool. It’s already there.

It’s fine for developers. It’s not fine for a support person. Anyone with edit access can change any field in any document, there’s no concept of “support can refund but not delete,” there’s no audit trail of business actions, and one wrong click on a document can break the app for a user. It also can’t run business logic. Refunding an order means calling Stripe, updating the order and notifying the user, not just editing a field.

I treat the console as a developer tool and an emergency fallback, never as the admin panel.

Option 2: low-code tools like Retool or Appsmith

Low-code internal tool builders let you drag tables, forms and buttons onto a page and wire them to your database or API. For many apps, this is the right first admin panel.

Good for:

  • Getting a working internal tool in days rather than weeks
  • Read-heavy work: search, tables, detail views
  • Small teams where the admins are also the founders

Watch out for:

  • Connecting them directly to your production database. You skip all your business rules. Point them at your API instead whenever possible.
  • Logic living in two places: half in your backend, half in JavaScript snippets inside the tool.
  • Pricing per user, which can matter once your support team grows.
  • Version control and testing, which are weaker than in a normal codebase.

If your admin needs are mostly “look things up and edit a few fields,” start here.

Option 3: a custom web app

When the admin side grows its own workflows, like multi-step approvals, bulk operations, reports or a support team with different roles, I build a custom web app. My usual stack is Vue.js or Next.js on the front end and a .NET API behind it, often the same API the mobile app already uses.

It costs more up front, but you get proper permissions, real tests, your own design and no per-seat fees. And it lives in your repo, reviewed like everything else.

A rough guide: start with low-code, and move to custom when you notice you’re fighting the tool more than using it.

Share the API with the mobile app

Whichever front end you choose, the most important decision is that admin actions go through your backend, not around it.

If refunding an order in the app triggers a Stripe refund, updates the order status and sends a push notification, then refunding from the admin panel must do exactly the same thing. The simplest way to guarantee that is to have one service that does the refund, and two front ends that call it.

I usually put admin endpoints in the same API project, under their own route prefix and authorization policies. In ASP.NET Core that looks something like:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", p => p.RequireRole("Admin"));
    options.AddPolicy("CanRefund", p => p.RequireRole("Admin", "Support"));
});

app.MapDelete("/admin/users/{id}", async (Guid id, UserService users) =>
{
    await users.SoftDeleteAsync(id);
    return Results.NoContent();
}).RequireAuthorization("AdminOnly");

app.MapPost("/admin/orders/{id}/refund", async (Guid id, OrderService orders) =>
{
    await orders.RefundAsync(id);
    return Results.NoContent();
}).RequireAuthorization("CanRefund");

The mobile app never calls /admin/*, and the policies make sure a regular user’s token gets a 403 if it tries. More on structuring that backend in building a .NET API for a Flutter app.

Roles and permissions

Even a three-person team benefits from roles. A typical starting set:

  • Admin: everything, including managing other admins
  • Support: look up users and orders, issue refunds up to the normal flow, add notes
  • Content: edit products, categories and banners
  • Read-only: view everything, change nothing (great for founders who just want to look)

Enforce permissions on the server. Hiding a button in the UI is a convenience, not security. A support agent who opens the browser dev tools shouldn’t be able to call the delete endpoint just because they found the URL.

Give every admin their own login. Shared admin accounts make audit logs useless and make removing someone’s access painful.

Audit logs

Every change made through the admin panel should leave a record: who did it, what they changed, when, and ideally the before and after values.

public record AuditEntry(
    Guid Id,
    string ActorId,
    string Action,       // e.g. "order.refund"
    string EntityType,
    string EntityId,
    string? Before,      // JSON snapshot
    string? After,
    DateTimeOffset At);

Write the audit entry in the same transaction as the change itself, so one never exists without the other. Then show the log in the admin panel on each user and order page.

You’ll use this more than you expect. “Who changed this price?” and “Did someone already refund this?” are daily questions, and the audit log answers them in seconds instead of starting an argument.

Make destructive actions hard to do by accident

Admin panels are where one misclick does real damage. A few patterns I always use:

  • Confirm with specifics. “Refund this order?” is weak. “Refund order #1234 to the customer’s card?” with the amount shown is better. For deletes, make the person type the name or ID.
  • Soft delete by default. Mark records as deleted and hide them, with a restore option, instead of removing them. Hard deletion becomes a separate, rarer action, run when legal requirements like account deletion demand it.
  • Require a reason. Suspensions, refunds and deletes get a short note that goes into the audit log.
  • Limit bulk actions. If you allow bulk operations, show exactly how many records will change before confirming.
  • Separate environments. Staging and production admin panels should look different, with a visible colored banner on production. Everyone who has worked on admin tools has a story about thinking they were in staging.

Test it end to end with Playwright

Admin panels are web apps, which means they’re very testable. I use Playwright for end-to-end tests on the critical flows: logging in with each role, searching for a user, refunding an order, and checking that restricted actions are actually restricted.

import { test, expect } from '@playwright/test';

test('support agent can refund but not delete a user', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.SUPPORT_EMAIL!);
  await page.getByLabel('Password').fill(process.env.SUPPORT_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  await page.getByPlaceholder('Search users').fill('test.customer@example.com');
  await page.getByRole('link', { name: 'test.customer@example.com' }).click();

  await expect(page.getByRole('button', { name: 'Refund' })).toBeVisible();
  await expect(page.getByRole('button', { name: 'Delete user' })).toHaveCount(0);
});

Run these against a seeded test environment in CI on every change. Testing the UI is only half of it, so I also have API tests that call admin endpoints with the wrong role and expect a 403. I wrote more about getting reliable results in Playwright end-to-end testing lessons.

Takeaways

  • Plan an admin dashboard from the start, beginning with a fast search for users and orders.
  • The Firebase console is a developer tool, not an admin panel for a support team.
  • Low-code tools are a good first step; move to a custom Vue.js or Next.js app when the workflows grow.
  • Route every admin action through the same backend services the mobile app uses, with server-side role checks.
  • Keep audit logs and make destructive actions deliberate: specific confirmations, soft deletes and reasons.
  • Cover the critical admin flows and permission boundaries with Playwright and API tests.

Your users will never see the admin panel, but they’ll feel it every time support fixes their problem in a minute instead of a day.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).