Refine: Admin Panel Framework That Actually Scales Beyond CRUD

I've built five admin panels in the last three years. Used React Admin for two, AdminJS for one, and raw Next.js for two. Each one started fine and became a mess at scale. Refine is different because it's headless — no UI opinions forced on you. You bring your own components (I use Shadcn/UI), and Refine handles the data layer, auth, and access control. After two production apps with it, here's the real breakdown.

Why Not AdminJS or React Admin

AdminJS generates your admin from your database schema. Great for day one. Terrible on day 30 when you need custom business logic. React Admin is better — it has a data provider pattern — but the Material UI dependency is heavy and hard to customize. Both frameworks fight you when you need something that isn't a standard CRUD table.

Refine's headless approach means:

Setting Up a Custom Data Provider

My backend is a custom REST API (not Supabase, not Hasura). So I needed a custom data provider. Here's the implementation:

// src/providers/data-provider.ts
import { DataProvider } from "@refinedev/core"

const API_URL = process.env.NEXT_PUBLIC_API_URL!

export const dataProvider: DataProvider = {
  // Get a list of records with pagination, sorting, filtering
  getList: async ({ resource, pagination, sorters, filters }) => {
    const params = new URLSearchParams()

    // Pagination
    if (pagination) {
      params.set("_page", String(pagination.current))
      params.set("_limit", String(pagination.pageSize))
    }

    // Sorting
    if (sorters && sorters.length > 0) {
      const sorter = sorters[0]
      params.set("_sort", sorter.field)
      params.set("_order", sorter.order)
    }

    // Filters — map Refine filter format to my API's format
    if (filters) {
      filters.forEach((filter) => {
        if (filter.operator === "eq") {
          params.set(filter.field, String(filter.value))
        } else if (filter.operator === "contains") {
          params.set(`${filter.field}_like`, String(filter.value))
        }
      })
    }

    const res = await fetch(`${API_URL}/${resource}?${params}`, {
      headers: { Authorization: `Bearer ${getToken()}` },
    })

    const data = await res.json()
    const total = parseInt(res.headers.get("X-Total-Count") || "0")

    return { data, total }
  },

  // Get a single record
  getOne: async ({ resource, id }) => {
    const res = await fetch(`${API_URL}/${resource}/${id}`, {
      headers: { Authorization: `Bearer ${getToken()}` },
    })
    const data = await res.json()
    return { data }
  },

  // Create a record
  create: async ({ resource, variables }) => {
    const res = await fetch(`${API_URL}/${resource}`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${getToken()}`,
      },
      body: JSON.stringify(variables),
    })
    const data = await res.json()
    return { data }
  },

  // Update a record
  update: async ({ resource, id, variables }) => {
    const res = await fetch(`${API_URL}/${resource}/${id}`, {
      method: "PATCH",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${getToken()}`,
      },
      body: JSON.stringify(variables),
    })
    const data = await res.json()
    return { data }
  },

  // Delete a record
  deleteOne: async ({ resource, id }) => {
    const res = await fetch(`${API_URL}/${resource}/${id}`, {
      method: "DELETE",
      headers: { Authorization: `Bearer ${getToken()}` },
    })
    const data = await res.json()
    return { data }
  },
}

function getToken() {
  if (typeof window !== "undefined") {
    return localStorage.getItem("token") || ""
  }
  return ""
}

Problem

My auth provider issues JWT tokens that expire after 15 minutes. When a token expired during a user session, Refine's auth provider didn't automatically refresh it. The user saw a blank screen or got redirected to login. No error message, no retry.

What I Tried

First I tried wrapping every API call in a token check. That was repetitive and error-prone. Then I tried using React Query's retry mechanism — it kind of worked but the UX was bad (users saw loading states during refresh).

Actual Fix

Implemented token refresh in the auth provider's check method. Refine calls check before every authenticated request, which is the right place to handle refresh:

// src/providers/auth-provider.ts
import { AuthProvider } from "@refinedev/core"

export const authProvider: AuthProvider = {
  login: async ({ email, password }) => {
    const res = await fetch("/api/auth/login", {
      method: "POST",
      body: JSON.stringify({ email, password }),
    })
    if (!res.ok) return { success: false }
    const { token, refreshToken } = await res.json()
    localStorage.setItem("token", token)
    localStorage.setItem("refreshToken", refreshToken)
    return { success: true, redirectTo: "/dashboard" }
  },

  check: async () => {
    const token = localStorage.getItem("token")
    if (!token) return { authenticated: false, redirectTo: "/login" }

    // Check if token is expired
    const payload = JSON.parse(atob(token.split(".")[1]))
    const isExpired = payload.exp * 1000 < Date.now()

    if (isExpired) {
      // Try to refresh
      const refreshToken = localStorage.getItem("refreshToken")
      if (!refreshToken) {
        return { authenticated: false, redirectTo: "/login" }
      }

      const res = await fetch("/api/auth/refresh", {
        method: "POST",
        body: JSON.stringify({ refreshToken }),
      })

      if (!res.ok) {
        localStorage.removeItem("token")
        localStorage.removeItem("refreshToken")
        return { authenticated: false, redirectTo: "/login" }
      }

      const { token: newToken } = await res.json()
      localStorage.setItem("token", newToken)
    }

    return { authenticated: true }
  },

  logout: async () => {
    localStorage.removeItem("token")
    localStorage.removeItem("refreshToken")
    return { success: true, redirectTo: "/login" }
  },

  getIdentity: async () => {
    const token = localStorage.getItem("token")
    if (!token) return null
    const payload = JSON.parse(atob(token.split(".")[1]))
    return { id: payload.sub, name: payload.name, role: payload.role }
  },
}

Authentication That Works with Any Provider

The auth provider abstraction is one of Refine's best features. I've used the same admin panel code with three different auth backends (Firebase Auth, Auth0, and custom JWT) just by swapping the provider. The components don't change.

Building a Complex List View with Filters

Real admin panels need more than a basic table. Here's a list view with server-side filtering, sorting, and search — all connected to Refine's data hooks:

// src/pages/users/list.tsx
"use client"

import { useTable, useNavigation } from "@refinedev/core"
import { useDataGrid } from "@refinedev/mui" // or your UI library
import { Button, Input, Select } from "@/components/ui"

export function UserList() {
  const { tableQuery, filters, setFilters, sorters, setSorters } = useTable({
    resource: "users",
    pagination: { current: 1, pageSize: 20 },
    sorters: { initial: [{ field: "createdAt", order: "desc" }] },
  })

  const { create } = useNavigation()

  const users = tableQuery.data?.data ?? []
  const total = tableQuery.data?.total ?? 0

  return (
    <div className="p-6">
      <div className="flex justify-between mb-4">
        <h1 className="text-2xl font-bold">Users ({total})</h1>
        <Button onClick={() => create("users")}>Add User</Button>
      </div>

      {/* Filter bar */}
      <div className="flex gap-4 mb-4">
        <Input
          placeholder="Search by name..."
          onChange={(e) => setFilters([
            { field: "name", operator: "contains", value: e.target.value }
          ])}
        />
        <Select
          placeholder="Role"
          onChange={(value) => setFilters([
            { field: "role", operator: "eq", value }
          ])}
          options={[
            { label: "All", value: "" },
            { label: "Admin", value: "admin" },
            { label: "User", value: "user" },
          ]}
        />
      </div>

      {/* Table */}
      <table className="w-full">
        <thead>
          <tr>
            <th onClick={() => setSorters([{ field: "name", order: "asc" }])}>Name</th>
            <th>Email</th>
            <th>Role</th>
            <th onClick={() => setSorters([{ field: "createdAt", order: "desc" }])}>Joined</th>
          </tr>
        </thead>
        <tbody>
          {users.map((user) => (
            <tr key={user.id}>
              <td>{user.name}</td>
              <td>{user.email}</td>
              <td>{user.role}</td>
              <td>{new Date(user.createdAt).toLocaleDateString()}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

Problem

Needed fine-grained access control where users can access some resources based on organization membership and role. Admin of Org A can't access Org B's data. Standard RBAC wasn't enough.

What I Tried

Tried implementing custom access control with a simple role check. That failed because it didn't account for resource ownership. Tried adding org_id to every query manually — error-prone.

Actual Fix

Used Refine's access control provider with Casbin for policy-based access:

// src/providers/access-control.ts
import { AccessControlProvider } from "@refinedev/core"
import { newEnforcer } from "casbin"

// Model: sub (user) can act (verb) on resource (obj) in org (org)
// p = sub, org, resource, action
// g = _, _ (grouping)

const model = `
[request_definition]
r = sub, org, obj, act

[policy_definition]
p = sub, org, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && r.org == p.org && r.obj == p.obj && r.act == p.act
`

export const accessControlProvider: AccessControlProvider = {
  can: async ({ resource, action, params }) => {
    const user = params?.identity
    if (!user) return { can: false }

    const enforcer = await newEnforcer(model, policyFile)
    const allowed = await enforcer.enforce(
      user.role,      // sub: "admin" or "user"
      user.orgId,     // org: "org_123"
      resource,       // obj: "users", "orders"
      action,         // act: "list", "create", "delete"
    )

    return { can: allowed }
  },
}

Access Control with Casbin

Refine's access control integrates with the <CanAccess> component to conditionally render UI based on permissions. This means buttons, menu items, and entire pages can be hidden based on the user's role and organization:

import { CanAccess } from "@refinedev/core"

// Hide the "Delete User" button for non-admins
function UserActions({ userId }: { userId: string }) {
  return (
    <CanAccess resource="users" action="delete" params={{ id: userId }}>
      <Button variant="destructive" onClick={() => deleteUser(userId)}>
        Delete User
      </Button>
    </CanAccess>
  )
}

What I Learned

Wrapping Up

Refine hits the sweet spot between "raw React" (too much boilerplate) and "opinionated admin framework" (too rigid). The provider pattern keeps your code decoupled, the headless approach means you pick your own UI, and the access control system handles real enterprise requirements. If you're building admin panels that need to grow beyond basic CRUD, Refine is the right choice.

Related Articles