Building an Admin Dashboard with Ant Design: Forms, Tables and Theming

Key takeaways

How Ant Design's Layout, Form, Table and token-based theming fit together for admin dashboards, built up to a user-management screen, with the warnings and behaviors that surprise people in production.

What this post covers

Ant Design (antd) is a React component library built around the needs of internal tools and admin consoles: dense data tables, long forms with validation, side-navigation layouts and consistent feedback components. This post builds up to a user-management screen and explains the parts of antd v5 that are easy to use wrongly: how Form owns field state, how Table identifies rows, how token-based theming replaced Less variables, and why the static message API does not follow your theme.

When Ant Design is a good fit

Antd’s strength is breadth in the components admin UIs actually need. Table has sorting, filtering, pagination, row selection, expandable rows, fixed columns and virtual scrolling built in. Form handles validation rules, dependent fields and dynamic lists. DatePicker, TreeSelect, Transfer, Cascader and Upload cover input types that you would otherwise assemble from several libraries. Having them share one visual language saves real time on a back-office product.

The trade-off is that antd has a strong look of its own. Tokens let you change colors, radii, spacing and fonts, but a layout built from antd components still reads as antd. For a customer-facing product with a distinctive brand, a headless or copy-in approach (Radix, shadcn/ui) or a library designed around theming often fits better. Antd is also relatively large; imports are tree-shaken in modern bundlers, but a page that uses Table, Form and DatePicker pulls in a lot of code by nature.


Installation

npm install antd @ant-design/icons

Version 5 styles components with CSS-in-JS generated at runtime, so there is no global CSS file to import and no Less toolchain to configure. The earlier approach of changing Less variables at build time is gone; theming happens through tokens, covered below. Date components use Day.js rather than Moment.js, so date values you pass to DatePicker should be Day.js objects.

import { Button, Space } from 'antd';

export default function App() {
  return (
    <Space>
      <Button type="primary">Primary</Button>
      <Button>Default</Button>
      <Button type="dashed">Dashed</Button>
      <Button type="link">Link</Button>
    </Space>
  );
}

Layout

import { Layout, Menu } from 'antd';

const { Header, Sider, Content, Footer } = Layout;

export default function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    <Layout style={{ minHeight: '100vh' }}>
      <Sider collapsible breakpoint="lg">
        <Menu
          theme="dark"
          mode="inline"
          defaultSelectedKeys={['dashboard']}
          items={[
            { key: 'dashboard', label: 'Dashboard' },
            { key: 'users', label: 'Users' },
            { key: 'settings', label: 'Settings' },
          ]}
        />
      </Sider>
      <Layout>
        <Header style={{ color: 'white' }}>My App</Header>
        <Content style={{ padding: 24 }}>{children}</Content>
        <Footer style={{ textAlign: 'center' }}>My App</Footer>
      </Layout>
    </Layout>
  );
}

Layout components are thin flexbox wrappers; the value is in Sider, which supports collapsing and a breakpoint that collapses it automatically on narrow screens. Use meaningful menu keys (route names, not '1', '2') so you can derive selectedKeys from the current URL instead of tracking the active item separately. With defaultSelectedKeys the menu manages its own selection and will drift from the URL on back/forward navigation; in a routed app, pass selectedKeys computed from the location.


Forms

import { Form, Input, Button, Checkbox } from 'antd';

interface LoginValues {
  email: string;
  password: string;
  remember: boolean;
}

export default function LoginForm() {
  const [form] = Form.useForm<LoginValues>();

  const onFinish = (values: LoginValues) => {
    console.log('Submitted:', values);
  };

  return (
    <Form
      form={form}
      name="login"
      layout="vertical"
      initialValues={{ remember: true }}
      onFinish={onFinish}
      style={{ maxWidth: 400 }}
    >
      <Form.Item
        label="Email"
        name="email"
        rules={[
          { required: true, message: 'Please enter your email.' },
          { type: 'email', message: 'Please enter a valid email.' },
        ]}
      >
        <Input />
      </Form.Item>
      <Form.Item
        label="Password"
        name="password"
        rules={[{ required: true, message: 'Please enter your password.' }]}
      >
        <Input.Password />
      </Form.Item>
      <Form.Item name="remember" valuePropName="checked">
        <Checkbox>Remember me</Checkbox>
      </Form.Item>
      <Button type="primary" htmlType="submit" block>
        Sign in
      </Button>
    </Form>
  );
}

The key idea is that Form owns the field values, not your component. A Form.Item with a name injects value and onChange into its single child. That is why you do not write useState for each input, and it is also why a few things behave unexpectedly:

  • Setting value on an <Input> inside a named Form.Item has no effect, because the item overrides it. Change values through form.setFieldsValue() instead.
  • initialValues is read once when the field mounts. Changing it later does not update the form; call form.resetFields() or setFieldsValue() after new data arrives.
  • A Form.Item with a name expects exactly one control as its child. Wrapping the input in a <div> means the item injects props into the div, and the input stops being controlled.
  • Checkboxes and switches expose checked, not value, which is what valuePropName="checked" is for.

onFinish only runs if all rules pass; otherwise onFinishFailed receives the errors, and the form scrolls to nothing unless you set scrollToFirstError. For fields that depend on each other (confirm password, “end date after start date”), use the dependencies prop together with a validator function in rules, so the dependent field re-validates when the other one changes.


Tables

import { Table } from 'antd';
import type { TableColumnsType } from 'antd';

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

const columns: TableColumnsType<User> = [
  {
    title: 'Name',
    dataIndex: 'name',
    key: 'name',
    sorter: (a, b) => a.name.localeCompare(b.name),
  },
  { title: 'Email', dataIndex: 'email', key: 'email' },
  {
    title: 'Age',
    dataIndex: 'age',
    key: 'age',
    sorter: (a, b) => a.age - b.age,
  },
];

const data: User[] = [
  { id: 1, name: 'John', email: '[email protected]', age: 30 },
  { id: 2, name: 'Jane', email: '[email protected]', age: 25 },
];

export default function UserTable() {
  return <Table<User> columns={columns} dataSource={data} rowKey="id" />;
}

Typing the columns with TableColumnsType<User> means dataIndex values and the a/b arguments in sorters are checked against User, so a renamed field shows up as a compile error instead of an empty column.

rowKey is the detail that bites most often. Table looks for a key property on each record by default. Data from an API usually has id, so without rowKey React logs:

Warning: Each child in a list should have a unique "key" prop.

and, more importantly, row selection and expanded rows are tracked by a key that is undefined for every row. Selecting one row can then appear to select several, or selection is lost after a refresh.

Client-side vs server-side sorting and paging

A sorter function and the default pagination operate only on the dataSource array you passed in. That is fine for a few hundred rows, but on a real admin screen the server usually holds thousands of records and returns one page at a time. In that case, set sorter: true on sortable columns, control pagination yourself, and fetch in onChange:

<Table<User>
  columns={columns}
  dataSource={pageData}
  rowKey="id"
  loading={loading}
  pagination={{ current: page, pageSize, total }}
  onChange={(pagination, filters, sorter) => {
    // pagination.current, pagination.pageSize, and sorter.field / sorter.order
    // become query parameters for the next request
  }}
/>

Forgetting total is the usual reason the pager shows only one page. And if the column definitions are declared inside the component body, they are recreated on every render; that is harmless for small tables, but for large ones move them outside the component or wrap them in useMemo, and consider the virtual prop with a fixed scroll.y for long lists.


Feedback: message, notification, Modal

import { App as AntdApp, Button } from 'antd';

function SaveButton() {
  const { message, notification } = AntdApp.useApp();

  return (
    <Button
      onClick={() => {
        message.success('Saved');
        notification.info({
          message: 'Sync started',
          description: 'Changes will appear on other devices shortly.',
        });
      }}
    >
      Save
    </Button>
  );
}

export default function Root() {
  return (
    <AntdApp>
      <SaveButton />
    </AntdApp>
  );
}

Antd still exports static functions (import { message } from 'antd'; message.success(...)), and many tutorials use them. They work, but they render into a separate React root outside your component tree, so they cannot read ConfigProvider context: your custom primary color, dark mode, locale and prefix are ignored. In development antd warns about this:

Warning: [antd: message] Static function can not consume context like dynamic theme. Please use 'App' component instead.

Wrapping the app in antd’s App component and using App.useApp() gives context-aware versions of message, notification and modal. I tend to set this up on the first day of a project because retrofitting it means touching every call site, and the symptom (a toast in default blue on an otherwise themed, dark-mode app) usually shows up only after someone enables a theme.


Theming with design tokens

import { ConfigProvider, theme } from 'antd';

export default function ThemedApp({ children, dark }: { children: React.ReactNode; dark: boolean }) {
  return (
    <ConfigProvider
      theme={{
        algorithm: dark ? theme.darkAlgorithm : theme.defaultAlgorithm,
        token: {
          colorPrimary: '#3498db',
          borderRadius: 8,
          fontFamily: 'Inter, system-ui, sans-serif',
        },
        components: {
          Table: { headerBg: '#f5f7fa' },
        },
      }}
    >
      {children}
    </ConfigProvider>
  );
}

Tokens come in layers. You set a few seed tokens such as colorPrimary and borderRadius, and the chosen algorithm derives the rest (hover and active shades, borders, backgrounds) from them. That is why changing colorPrimary alone produces a coherent set of button, link and focus colors. components overrides tokens for one component type only. Nested ConfigProviders merge with their parent, so a section of the page can use a compact or dark variant without affecting the rest. One catch with per-component tokens: a hard-coded light value like the headerBg above will not adapt when you switch to darkAlgorithm, so switch those values together with the algorithm.

Overriding .ant-btn or other internal class names in a global stylesheet still works, but those names and the DOM structure behind them are implementation details. Token overrides survive upgrades; deep CSS selectors are the first thing to break on a minor version bump.


Putting it together: user management

import { useState } from 'react';
import { App as AntdApp, Button, Form, Input, Modal, Space, Table } from 'antd';
import type { TableColumnsType } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';

interface User {
  id: number;
  name: string;
  email: string;
}
type UserInput = Omit<User, 'id'>;

export default function UserManagement() {
  const { message } = AntdApp.useApp();
  const [users, setUsers] = useState<User[]>([
    { id: 1, name: 'John', email: '[email protected]' },
    { id: 2, name: 'Jane', email: '[email protected]' },
  ]);
  const [open, setOpen] = useState(false);
  const [editing, setEditing] = useState<User | null>(null);
  const [form] = Form.useForm<UserInput>();

  const openCreate = () => {
    setEditing(null);
    form.resetFields();
    setOpen(true);
  };

  const openEdit = (user: User) => {
    setEditing(user);
    form.setFieldsValue({ name: user.name, email: user.email });
    setOpen(true);
  };

  const close = () => {
    setOpen(false);
    setEditing(null);
  };

  const handleSubmit = (values: UserInput) => {
    if (editing) {
      setUsers(prev => prev.map(u => (u.id === editing.id ? { ...u, ...values } : u)));
      message.success('User updated');
    } else {
      setUsers(prev => [...prev, { id: Date.now(), ...values }]);
      message.success('User created');
    }
    close();
  };

  const columns: TableColumnsType<User> = [
    { title: 'Name', dataIndex: 'name', key: 'name' },
    { title: 'Email', dataIndex: 'email', key: 'email' },
    {
      title: 'Actions',
      key: 'actions',
      render: (_, record) => (
        <Space>
          <Button icon={<EditOutlined />} onClick={() => openEdit(record)}>
            Edit
          </Button>
          <Button
            danger
            icon={<DeleteOutlined />}
            onClick={() => {
              setUsers(prev => prev.filter(u => u.id !== record.id));
              message.success('User deleted');
            }}
          >
            Delete
          </Button>
        </Space>
      ),
    },
  ];

  return (
    <>
      <Button type="primary" icon={<PlusOutlined />} onClick={openCreate} style={{ marginBottom: 16 }}>
        Add user
      </Button>
      <Table<User> columns={columns} dataSource={users} rowKey="id" />
      <Modal
        title={editing ? 'Edit user' : 'Add user'}
        open={open}
        onCancel={close}
        onOk={() => form.submit()}
        okText={editing ? 'Update' : 'Create'}
        forceRender
      >
        <Form form={form} layout="vertical" onFinish={handleSubmit}>
          <Form.Item name="name" label="Name" rules={[{ required: true, message: 'Please enter a name.' }]}>
            <Input />
          </Form.Item>
          <Form.Item
            name="email"
            label="Email"
            rules={[
              { required: true, message: 'Please enter an email.' },
              { type: 'email', message: 'Please enter a valid email.' },
            ]}
          >
            <Input />
          </Form.Item>
        </Form>
      </Modal>
    </>
  );
}

Three details in this example are there because the simpler version breaks.

forceRender on the Modal. Modal does not render its children until it is first opened. Without forceRender, clicking Edit calls form.setFieldsValue while the <Form> does not exist yet. The values are lost, the modal opens empty the first time, and antd logs:

Warning: Instance created by `useForm` is not connected to any Form element. Forget to pass `form` prop?

Rendering the form up front keeps the form instance connected at all times.

Functional state updates. setUsers(prev => ...) instead of setUsers(users.filter(...)). The column render functions close over the users array from the render in which they were created. The functional form does not depend on that closure being current, which starts to matter as soon as the update runs after an await (a real delete request), when other changes may have landed in between and a stale users.filter(...) would put them back.

form.submit() from the modal’s OK button. It runs validation and calls onFinish only when rules pass, so the modal stays open with inline errors instead of closing on invalid input.

In real apps the local useState list becomes a server call, and the delete button should go through Popconfirm or modal.confirm first. I treat an unconfirmed destructive button in a dense table as a bug in its own right: it sits a few pixels from the Edit button that people click all day, and a local list has no undo.


Frequently Asked Questions

Q. How does Ant Design compare with MUI?

A. Both are large, mature React component libraries. Antd’s defaults lean toward dense, data-heavy admin screens, with a very featureful Table and Form included. MUI follows Material Design, puts more of its advanced data grid in a separate package, and is often chosen when that look or its sx styling system fits the product.

Q. Why does message.success ignore my theme?

A. The static message, notification and Modal.confirm functions render outside your React tree and cannot read ConfigProvider context. Wrap the app in antd’s App component and take message from App.useApp().

Q. Is Ant Design only for Chinese-language products?

A. No. Component text defaults to English, and antd ships locale files for many languages that you pass to ConfigProvider’s locale prop, for example import enUS from 'antd/locale/en_US'.

Q. Does Ant Design work with the Next.js App Router?

A. Yes, but its CSS-in-JS styles need to be extracted during server rendering to avoid a flash of unstyled content. Ant Design provides @ant-design/nextjs-registry for this, and components with state or event handlers need a 'use client' boundary.