ERP System Development (Web Interface)
An ERP system for mid-market business is dozens of interconnected modules: warehouse, procurement, sales, production, finance, HR. The web interface is only part of it, but a critical one: this is where employees spend eight hours a day, and poor UX directly costs money in the form of errors and slow operation.
Architectural choice: SPA vs SSR vs hybrid
For ERP, the choice is clear — SPA (Single Page Application). Reasons:
Intensive interaction: forms with dozens of fields, modal windows, drag-and-drop tables, inline editing. Server-side rendering of each change is not an option.
Personalization: each user sees their own set of modules, their own workspace.
Offline mode: warehouse at production facility may have unstable internet — PWA with IndexedDB allows work and synchronization later.
Stack for serious ERP interface
Frontend:
- React 18+ (Concurrent Features for heavy tables)
- TypeScript (strict, no any in business logic)
- TanStack Table v8 (virtualization, 100k+ rows)
- TanStack Query (server state, cache, optimistic updates)
- React Hook Form + Zod (complex forms with nested objects)
- Zustand (global UI state: open panels, filters)
Backend (for web client):
- REST API or tRPC
- GraphQL justified if modules are independently developed by different teams
Component library:
- Radix UI + Tailwind (customization without CSS conflicts)
or Ant Design / Mantine (quick start, rich components)
Key technical tasks
1. Working with large tables
A table with 50,000 rows is a typical task for inventory accounting or reporting. Without virtualization, the browser freezes.
// VirtualizedTable.tsx
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>
{/* Empty space above */}
{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.cell, cell.getContext())}
</td>
))}
</tr>
);
})}
{/* Empty space below */}
{virtualItems.length > 0 && (
<tr style={{ height: totalSize - virtualItems[virtualItems.length - 1].end }}>
<td colSpan={columns.length} />
</tr>
)}
</tbody>
</table>
</div>
);
}
2. Complex forms with dependent fields
An ERP order creation form can include: contractor selection → loading their contracts → contract selection → auto-fill payment terms → add line items → recalculate amounts.
// OrderForm.tsx (fragment)
import { useForm, useFieldArray, useWatch } from 'react-hook-form';
import { useQuery } from '@tanstack/react-query';
function OrderForm() {
const { control, register, setValue, watch } = useForm<OrderFormData>({
resolver: zodResolver(orderSchema),
defaultValues: {
lines: [{ productId: '', qty: 1, price: 0, discount: 0 }],
},
});
const { fields, append, remove } = useFieldArray({ control, name: 'lines' });
const contractorId = watch('contractorId');
// When contractor changes, load their contracts
const { data: contracts } = useQuery({
queryKey: ['contracts', contractorId],
queryFn: () => fetchContracts(contractorId),
enabled: !!contractorId,
});
// Auto-fill terms from contract
function handleContractSelect(contractId: string) {
const contract = contracts?.find(c => c.id === contractId);
if (contract) {
setValue('paymentTermsDays', contract.paymentTermsDays);
setValue('currencyCode', contract.currencyCode);
setValue('vatRate', contract.vatRate);
}
}
// Recalculate totals when any line changes
const lines = useWatch({ control, name: 'lines' });
const totals = useMemo(() => {
return lines.reduce((acc, line) => {
const subtotal = line.qty * line.price * (1 - (line.discount ?? 0) / 100);
return {
subtotal: acc.subtotal + subtotal,
vat: acc.vat + subtotal * (line.vatRate ?? 0.2),
};
}, { subtotal: 0, vat: 0 });
}, [lines]);
// ... JSX
}
3. Optimistic updates for response speed
User changes order status — the interface should respond immediately, not wait for server response:
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 }) => {
// Cancel current requests for this order
await queryClient.cancelQueries({ queryKey: ['orders', orderId] });
// Save current state for rollback
const prev = queryClient.getQueryData(['orders', orderId]);
// Optimistically update
queryClient.setQueryData(['orders', orderId], (old: Order) => ({
...old, status,
}));
return { prev };
},
onError: (_err, { orderId }, context) => {
// Rollback on error
queryClient.setQueryData(['orders', orderId], context?.prev);
toast.error('Failed to change status');
},
onSettled: (_, __, { orderId }) => {
queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
},
});
4. Access control to modules
// PermissionGuard.tsx
import { useAuth } from '@/stores/auth';
interface PermissionGuardProps {
permission: string; // 'orders:create', 'inventory:write'
fallback?: ReactNode;
children: ReactNode;
}
export function PermissionGuard({ permission, fallback, children }: PermissionGuardProps) {
const { user } = useAuth();
const hasPermission = user?.permissions.includes(permission)
|| user?.roles.some(role => ROLE_PERMISSIONS[role]?.includes(permission));
if (!hasPermission) {
return fallback ? <>{fallback}</> : null;
}
return <>{children}</>;
}
// Usage
<PermissionGuard permission="orders:create" fallback={<ReadOnlyBadge />}>
<CreateOrderButton />
</PermissionGuard>
Performance of ERP interface
Several mandatory optimizations:
Code splitting by modules — warehouse user doesn't load HR module:
const routes = [
{
path: '/warehouse/*',
element: React.lazy(() => import('@/modules/warehouse')),
permission: 'warehouse:view',
},
{
path: '/hr/*',
element: React.lazy(() => import('@/modules/hr')),
permission: 'hr:view',
},
];
Debouncing for search and filters — don't send request after each keystroke.
Memoization of heavy computations — reports with aggregation in browser (not always possible on server) via useMemo.
Timeline
ERP interface is not developed "from scratch in three months". Realistic timeframes:
MVP with four-five key modules (orders, warehouse, references, reporting, users) — six-eight months for a team of three-four developers.
Full-fledged system with 15–20 modules — from one-and-a-half to two years with the same team.
Attempting to do everything at once without iterative approach is a guaranteed failure. The right strategy: launch with minimal working set of modules, constant feedback from real users, iterative expansion.







