Note: When a warehouse report in an ERP system freezes for 10 seconds while loading 50,000 rows, users start using Excel. Developing an ERP web interface isn't about React out of the box—it's about fine-tuning virtualization, state management, and resolving access conflicts. In a multi-user environment, record locking during simultaneous editing is critical. Without proper optimistic concurrency control, users overwrite each other's data. Typical pain points: slow table loading, permission confusion, data loss offline. We solve these with SPA, virtualization, optimistic updates, and a Permission Guard for ERP. We develop interfaces from an MVP with four modules to a full system with 15+ modules. This material covers key architectural decisions and practical techniques to avoid common mistakes and build a scalable interface.
Choosing the Stack for an ERP Interface
The choice of architecture and stack is the foundation that determines performance and maintenance costs. Let's consider two common approaches: SPA and SSR.
| Criteria | SPA | SSR |
|---|---|---|
| Performance with intensive UI | High | Low (every click is a request) |
| Workplace personalization | Flexible | Limited |
| Offline mode (PWA) | Supported | Not possible |
| SEO (not critical for ERP) | Poor | Good |
| Initial load | Slower (bundle) | Faster (HTML) |
For ERP, the choice is clear—SPA. Exception: if SEO is needed for public parts (e.g., product catalog).
Frontend stack: React 18+ with Concurrent Features, TypeScript (strict), TanStack Table for tables, TanStack Query for data, React Hook Form + Zod for forms, Zustand for UI state. Component library—Radix UI + Tailwind (flexibility) or Ant Design (speed).
How to Work with Large Tables?
A table with 50,000 rows is a typical task for warehouse accounting or reporting. Without virtualization, the browser freezes. With virtualization, render time drops to 100 ms even with 100,000 rows—that's 100x faster.
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
interface VirtualizedTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
rowHeight?: number;
}
export function VirtualizedTable<T>({
data,
columns,
rowHeight = 40,
}: VirtualizedTableProps<T>) {
const parentRef = useRef<HTMLDivElement>(null);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const { rows } = table.getRowModel();
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight,
overscan: 20,
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
return (
<div ref={parentRef} className="overflow-auto h-full">
<table className="w-full border-collapse">
<thead className="sticky top-0 bg-white z-10 shadow-sm">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
style={{ width: header.getSize() }}
className="text-left px-3 py-2 text-xs font-semibold text-gray-600 border-b"
>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{virtualItems.length > 0 && (
<tr style={{ height: virtualItems[0].start }}>
<td colSpan={columns.length} />
</tr>
)}
{virtualItems.map(virtualRow => {
const row = rows[virtualRow.index];
return (
<tr
key={row.id}
className="hover:bg-gray-50 border-b border-gray-100"
style={{ height: rowHeight }}
>
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="px-3 py-2 text-sm">
{flexRender(cell.column.columnDef.header, cell.getContext())}
</td>
))}
</tr>
);
})}
{virtualItems.length > 0 && (
<tr style={{ height: totalSize - virtualItems[virtualItems.length - 1].end }}>
<td colSpan={columns.length} />
</tr>
)}
</tbody>
</table>
</div>
);
}
How to Speed Up Interface Response?
Optimistic updates—user changes order status, interface reacts immediately without waiting for server response. On error, a rollback occurs. This cuts perceived response time by 200–500 ms, which is 5x faster than synchronous requests.
const queryClient = useQueryClient();
const updateStatus = useMutation({
mutationFn: (data: { orderId: string; status: OrderStatus }) =>
api.patch(`/orders/${data.orderId}/status`, { status: data.status }),
onMutate: async ({ orderId, status }) => {
await queryClient.cancelQueries({ queryKey: ['orders', orderId] });
const prev = queryClient.getQueryData(['orders', orderId]);
queryClient.setQueryData(['orders', orderId], (old: Order) => ({
...old, status,
}));
return { prev };
},
onError: (_err, { orderId }, context) => {
queryClient.setQueryData(['orders', orderId], context?.prev);
toast.error('Failed to change status');
},
onSettled: (_, __, { orderId }) => {
queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
},
});
Resolving Data Conflicts in Multi-User Access
We use a last-write-wins strategy with entity versioning. Each record has a version field (integer) that increments on change. The client sends the current version, the server checks for a match. If the version is outdated, it returns 409 Conflict; the client fetches current data and prompts the user to resolve the conflict. This is implemented on top of TanStack Query through optimistic updates + rollback.
Access Control with Permission Guard
We create a PermissionGuard component that checks whether the user has a specific permission. If not, you can show a placeholder or nothing. Roles and permissions are stored on the server; the client receives them upon authentication.
type Permission = 'orders:read' | 'orders:write' | 'stock:read';
function PermissionGuard({ permission, children, fallback = null }: {
permission: Permission;
children: ReactNode;
fallback?: ReactNode;
}) {
const user = useUser();
if (user.permissions.includes(permission)) {
return <>{children}</>;
}
return <>{fallback}</>;
}
Performance: Additional Optimizations
- Code splitting by module—warehouse user doesn't load HR module, speeding up load by 40%.
- Debounce for search and filters—send request at most once every 300 ms, reducing server load by 3x.
- Memoization of heavy computations—browser-based reports with aggregation via
useMemo.
We monitor Core Web Vitals—key performance metrics that directly affect UX. MDN Web Docs recommends targeting LCP < 2.5 s, FID < 100 ms, CLS < 0.1. Our team of 10+ years of ERP development experience has delivered over 50 projects in the last 5 years.
Process of Work
Stages of ERP interface development:
| Stage | Duration | Result |
|---|---|---|
| Analytics | 2–4 weeks | Business process description, module specification |
| Design | 2–4 weeks | Architecture, prototypes of key screens |
| Implementation | 4–6 weeks per module | Working functionality with tests |
| Testing | 2–3 weeks | Load test report, bug fixes |
| Deployment | 1–2 weeks | CI/CD, documentation, training |
- Analytics—study business processes, define modules and priorities.
- Design—create architecture, choose stack, prototype key screens.
- Implementation—iterative development with demos every two weeks.
- Testing—unit, integration, e2e tests, load testing.
- Deployment—set up CI/CD, data migration, user training.
At each stage we prepare documentation: technical specification, architectural decisions, API docs, user manuals.
Timelines
- MVP with four to five modules: 6–8 months for a team of three to four developers.
- Full system (15+ modules): 1.5–2 years with the same team.
An iterative approach—launch minimal set, get user feedback, gradually expand. Trying to do everything at once leads to failure.
What Is Included in the Work
- Technical specification and architectural documentation.
- Development and CI/CD setup.
- Code coverage with tests (unit, integration).
- API documentation and user documentation.
- Access to code repository and deployment pipeline.
- Training for administrators and users.
- Warranty support for 6 months after launch.
- Performance benchmarks and load test report.
Contact us for an estimate of your project. Get a consultation on architecture and timelines.







