Back to Blog
When Your Internal Tools Need to Be More Than Just CRUD: Embracing Refine's Flexibility
8 min readSep 6, 20261 views

When Your Internal Tools Need to Be More Than Just CRUD: Embracing Refine's Flexibility

Building internal tools often feels like a race to slap a UI on a database. You start with basic CRUD, but then requirements spiral. Suddenly, you need complex workflows, custom data transformations, and specific UI interactions that generic admin panels just can't handle. Refine steps in here, offe

FrontendFull-StackToolingReactSoftware Design
Share

by Sunil Band

The Internal Tooling Trap

We've all been there: a simple request for an admin panel to manage some data quickly balloons into a complex application. What starts as basic CRUD (Create, Read, Update, Delete) for a users table soon needs custom filtering, approval workflows, multi-step forms, and integrations with half a dozen other services. Generic admin generators or off-the-shelf dashboards quickly hit their limits, forcing you into awkward workarounds or, worse, rebuilding from scratch. This is where the tension lies: you need to move fast, but you also need to build something robust and tailored.

The problem isn't the desire for speed; it's the lack of an opinionated yet flexible framework that understands the unique demands of internal applications. Internal tools often have complex business logic hidden behind seemingly simple data structures, and their UIs need to be highly functional, not just pretty. This is precisely the space Refine aims to conquer. It's not just another UI library; it's a React framework designed specifically for building these kinds of applications, offering a structured approach while giving you escape hatches for deep customization.

Why Refine Isn't Just Another Admin Panel

At its core, Refine provides a set of hooks and components that abstract away the common patterns of data-driven applications. Think of it as a set of highly optimized primitives for tasks like data fetching, form handling, authentication, authorization, routing, and notifications. Crucially, it's headless by default, meaning it doesn't dictate your UI components. You bring your own UI library (Ant Design, Material UI, Chakra UI, or even custom components), and Refine hooks manage the logic.

This headless nature is Refine's superpower. It means you can leverage your team's existing UI expertise and design system, rather than fighting a pre-baked one. Want to use Ant Design for most of it, but need a custom D3 chart for a specific dashboard view? No problem. Refine handles the data layer, leaving you free to render it however you see fit. This is a significant departure from many "admin generators" that lock you into a specific look and feel.

Getting Started with a Refine App

Let's walk through a simple example of building a basic product management interface using Refine with Ant Design. We'll set up a data provider, define a resource, and create a list page.

First, we'll scaffold a new Refine project. You can choose your preferred UI framework during setup. For this example, I'm using Ant Design, which is a common choice for internal tools due to its rich component library.

bash
npx create-refine-app@latest my-refine-app
# Select 'antd' for the UI Framework and 'JSONPlaceholder' for the Data Provider (for simplicity)
cd my-refine-app
npm install
npm run dev

Now, let's define a products resource. Refine uses the concept of resources to map to your API endpoints and define how your application interacts with them. In src/App.tsx, we'll add a new resource:

plaintext
import { Refine } from '@refinedev/core';
import { AntdLayout, notificationProvider, ErrorComponent } from '@refinedev/antd';
import dataProvider from '@refinedev/simple-rest';
import routerProvider from '@refinedev/react-router-v6';
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom';

import { ProductList } from './pages/products/list';
import { ProductCreate } from './pages/products/create';
import { ProductEdit } from './pages/products/edit';
import { ProductShow } from './pages/products/show';

function App() {
  return (
    <BrowserRouter>
      <Refine
        dataProvider={dataProvider("https://api.fake-rest.refine.dev")}
        routerProvider={routerProvider}
        notificationProvider={notificationProvider}
        resources={[
          {
            name: 'products',
            list: '/products',
            create: '/products/create',
            edit: '/products/edit/:id',
            show: '/products/show/:id',
            meta: { icon: '📦' } // A little icon for the sidebar
          },
        ]}
        options={{
          syncWithLocation: true,
          warnWhenUnsavedChanges: true,
          use    : [ // Example of adding a custom hook globally
            // { 
            //   resource: 'products',
            //   action: 'list',
            //   hook: () => useSomeCustomLogicForProductsList() 
            // } 
          ]
        }}
      >
        <Routes>
          <Route
            element={
              <AntdLayout>
                <Outlet />
              </AntdLayout>
            }
          >
            <Route index element={<ProductList />} />
            <Route path="/products">
              <Route index element={<ProductList />} />
              <Route path="create" element={<ProductCreate />} />
              <Route path="edit/:id" element={<ProductEdit />} />
              <Route path="show/:id" element={<ProductShow />} />
            </Route>
            <Route path="*" element={<ErrorComponent />} />
          </Route>
        </Routes>
      </Refine>
    </BrowserRouter>
  );
}

export default App;

Next, let's create our ProductList component in src/pages/products/list.tsx. This is where Refine's hooks shine. We'll use useTable for data fetching, pagination, sorting, and filtering, and Ant Design's Table component for rendering.

plaintext
import { IResourceComponentsProps, useTable, useNavigation } from '@refinedev/core';
import { Table, Space, EditButton, ShowButton, DeleteButton, Button } from 'antd';

interface IProduct {
  id: number;
  name: string;
  material: string;
  price: string; // Typically number, but using string from fake API for simplicity
}

export const ProductList: React.FC<IResourceComponentsProps> = () => {
  const { tableProps } = useTable<IProduct>();
  const { create } = useNavigation();

  return (
    <div>
      <Space style={{ marginBottom: 16 }}>
        <h1>Products</h1>
        <Button onClick={() => create('products')}>Create Product</Button>
      </Space>
      <Table {...tableProps} rowKey="id">
        <Table.Column dataIndex="id" title="ID" />
        <Table.Column dataIndex="name" title="Name" />
        <Table.Column dataIndex="material" title="Material" />
        <Table.Column dataIndex="price" title="Price" />
        <Table.Column
          title="Actions"
          dataIndex="actions"
          render={(_, record: IProduct) => (
            <Space>
              <EditButton hideText size="small" recordItemId={record.id} />
              <ShowButton hideText size="small" recordItemId={record.id} />
              <DeleteButton hideText size="small" recordItemId={record.id} />
            </Space>
          )}
        />
      </Table>
    </div>
  );
};

Notice how useTable provides tableProps which directly hook into Ant Design's Table component. This handles pagination, sorting, and filtering logic automatically. The EditButton, ShowButton, and DeleteButton components are also provided by @refinedev/antd and handle navigation and data mutations for you. This dramatically reduces boilerplate.

For create, edit, and show pages, Refine offers useForm, useModalForm, useDrawerForm, useShow, and other hooks that abstract away common patterns for form submission, data display, and more. You'd build these similarly, using Ant Design components (like Form, Input, Select) and hooking them up to Refine's data-handling hooks.

Custom Data Providers and Authentication

Refine's true power comes from its extensibility. The data provider is a core abstraction. While it ships with providers for common REST APIs, GraphQL, tRPC, Supabase, Airtable, and others, you can easily write your own custom data provider. This is crucial for internal tools that often talk to legacy systems, custom microservices, or even just files on a network drive.

plaintext
// src/customDataProvider.ts (example structure)
import { DataProvider } from '@refinedev/core';
import axios from 'axios';

const customAxiosInstance = axios.create({ baseURL: 'https://my-legacy-api.com/v1' });

const customDataProvider: DataProvider = {
  getOne: async ({ resource, id, meta }) => {
    const { data } = await customAxiosInstance.get(`/${resource}/${id}`);
    return { data };
  },
  getList: async ({ resource, pagination, filters, sorters, meta }) => {
    // Build query params based on Refine's pagination/filter/sort objects
    const queryParams = new URLSearchParams();
    if (pagination) {
      queryParams.append('_limit', String(pagination.pageSize));
      queryParams.append('_start', String((pagination.current - 1) * pagination.pageSize));
    }
    // ... add filter/sorter logic

    const { data, headers } = await customAxiosInstance.get(`/${resource}?${queryParams.toString()}`);
    return {
      data,
      total: parseInt(headers['x-total-count'] || '0', 10), // Important for pagination
    };
  },
  // ... implement create, update, delete methods
  create: async ({ resource, variables }) => {
    const { data } = await customAxiosInstance.post(`/${resource}`, variables);
    return { data };
  },
  update: async ({ resource, id, variables }) => {
    const { data } = await customAxiosInstance.put(`/${resource}/${id}`, variables);
    return { data };
  },
  deleteOne: async ({ resource, id }) => {
    await customAxiosInstance.delete(`/${resource}/${id}`);
    return { data: { id } };
  },
  getApiUrl: () => 'https://my-legacy-api.com/v1' // Needed for things like file uploads if using built-in Refine components
};

export default customDataProvider;

Then, in App.tsx, you'd simply pass customDataProvider to the dataProvider prop. This level of abstraction means your UI components don't care how the data is fetched; they just interact with Refine's hooks. This makes swapping out backends or integrating with complex APIs a breeze.

Similarly, authentication and authorization are handled via dedicated auth providers. You can integrate with JWT, OAuth, or any custom authentication scheme by implementing the login, logout, check, and onError methods. This keeps your security logic separate from your UI and data fetching, making it easier to manage and test.

Trade-offs and Considerations

While Refine offers incredible flexibility, it's not a silver bullet. The initial learning curve can be a bit steeper than a pure UI library, as you're learning a new set of abstractions and hooks. Understanding how resources, data providers, and auth providers fit together takes a moment. However, this investment pays off quickly for complex applications.

Another point is that while it's headless, the default setup with a UI framework like Ant Design will still provide a strong opinion on styling and components. If your design system is extremely custom and deviates significantly from established UI libraries, you might find yourself writing more custom components and styling than you initially anticipated. However, Refine doesn't prevent this; it simply means you'll be leveraging less of the @refinedev/antd or similar packages and more of your own UI code alongside Refine's core data hooks.

Finally, the community, while growing, is not as massive as a general-purpose framework like React itself. This means you might find fewer immediate answers to niche problems on Stack Overflow, but the Refine team is very active on Discord and GitHub, providing excellent support.

Wrapping up

Refine is more than just a quick way to build CRUD interfaces; it's a powerful, flexible framework for building complex data-driven applications. Its headless nature, combined with robust data and auth provider abstractions, makes it ideal for internal tools that need to evolve beyond simple tables and forms. If you find yourself repeatedly building similar data-management UIs, or struggling with the limitations of off-the-shelf admin dashboards, Refine provides a solid foundation that scales with your application's complexity.

My advice: clone the example project we just set up, replace the JSONPlaceholder data provider with a simple-rest provider pointing to your own API (or even a mock API), and try to implement a custom filter or a more complex form field. You'll quickly see how its extensible architecture can save you from a lot of boilerplate while maintaining the flexibility you need for highly specific business requirements.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕