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:
- Bring your own UI. Shadcn/UI, Ant Design, MUI, or raw HTML — Refine doesn't care.
- Data provider pattern. Connect to REST, GraphQL, Supabase, Appwrite, or write your own.
- Auth provider pattern. Same abstraction for auth — swap Firebase for Auth0 without changing components.
- Access control built-in. Role-based or ABAC via Casbin integration.
- No lock-in. It's React hooks and context. Standard stuff.
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
- Start with the data provider. Getting the data layer right first means the rest of the admin panel falls into place. Custom data providers aren't hard to write.
- Auth provider is worth investing time in. Handle token refresh in
check, not in your components. - Headless wins at scale. When your admin panel has 50+ pages with custom business logic, being tied to a specific UI library becomes a liability.
- Refine + Shadcn/UI is the sweet spot in 2026. Refine handles data/auth, Shadcn handles the look. No overlap.
- Access control needs to be planned from day one. Retrofitting RBAC into an admin panel is painful. Set up the access control provider early.
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.