Bulk SKU entry, CSV import, and a GraphQL batch mutation for recurring bulk orders
B2B buyers who already know exactly which SKUs they need, often pulled straight from an ERP export or a purchasing spreadsheet, do not want to click through category pages and product detail pages one item at a time. A quick order form with free-form SKU and quantity entry, optional CSV import, and a GraphQL batch mutation behind the scenes shortens the path from purchasing list to cart considerably, backed by real-time validation against actual stock.
Table of Contents
- 1. Where Quick Order Fits in B2B, and What It Is Not
- 2. An Alpine.js Component for Dynamic SKU Row Entry
- 3. CSV Import as an Alternative to Manual Row Entry
- 4. A GraphQL Batch Mutation for Adding Multiple Products
- 5. Real-Time Stock Validation: Debounce and a Stock Query
- 6. Per-Row Error Feedback: SKU Not Found and Stock Conflicts
- 7. Order Lists for Recurring Bulk Orders
- 8. Performance With Large SKU Lists: Chunking, Debounce, and Progress
- 9. Interaction With Company Accounts and Approval Workflows
- 10. Summary
- 11. FAQ
1. Where Quick Order Fits in B2B, and What It Is Not
B2B purchasing follows a recognizable pattern: a facility manager, a buyer, or an assistant already knows the exact SKUs they need, usually copied from an ERP export or an internal spreadsheet, and wants to get them into the cart without navigating search, category filters, or individual product pages. That is exactly the use case a quick order form addresses, as a separate ordering path alongside the regular PDP flow.
It is worth drawing a clear line against the reorder feature. Reorder replays an existing, already placed order from order history with the exact same SKU and quantity combination it originally contained. Quick order carries no such binding to a past order. The customer types or pastes any SKUs and quantities, regardless of whether that exact combination was ever ordered before. The two features complement each other but solve different problems, and both deserve their own visible entry points in the frontend.
This article also stays clear of the broader topic of company accounts and B2B features in Hyvä. It focuses solely on the quick order form itself: the entry, the validation, and the path the data takes into the cart. Company accounts, roles, and approval workflows are only touched on briefly at the end, in terms of how they interact with this form, not how to build them.
2. An Alpine.js Component for Dynamic SKU Row Entry
The core of the quick order form is an Alpine.js component that manages a list of rows, each with a field for the SKU and a field for the quantity. New rows can be added with a click, empty or unwanted rows removed just as easily. Since Hyvä consistently relies on Alpine.js instead of Knockout.js, the component stays lightweight and works directly in the template without any extra build dependency.
Each row also carries an internal status, ranging from idle through checking to available or an error message. That status gets populated by an actual GraphQL query in the next section, here the focus is purely on the form structure, using x-for over the rows array and x-model for two-way binding of SKU and quantity.
<div x-data="quickOrderForm()" class="not-prose">
<template x-for="(row, index) in rows" :key="row.id">
<div class="grid grid-cols-12 gap-2 items-center mb-2">
<input
type="text"
x-model="row.sku"
@input.debounce.400ms="validateRow(index)"
placeholder="SKU"
class="col-span-5 border rounded px-2 py-1"
/>
<input
type="number"
min="1"
x-model.number="row.qty"
@input.debounce.400ms="validateRow(index)"
class="col-span-3 border rounded px-2 py-1"
/>
<span class="col-span-3 text-sm" x-text="row.status"></span>
<button type="button" @click="removeRow(index)" class="col-span-1 text-red-600">×</button>
</div>
</template>
<button type="button" @click="addRow()" class="mt-2 text-sm font-medium">+ Add row</button>
</div>
<script>
function quickOrderForm() {
return {
rows: [{ id: 1, sku: '', qty: 1, status: '' }],
nextId: 2,
addRow() {
this.rows.push({ id: this.nextId++, sku: '', qty: 1, status: '' });
},
removeRow(index) {
this.rows.splice(index, 1);
},
async validateRow(index) {
const row = this.rows[index];
if (!row.sku) return;
row.status = 'checking...';
// The real GraphQL stock check is added in section 5
}
};
}
</script>
3. CSV Import as an Alternative to Manual Row Entry
For bulk orders with dozens or hundreds of line items, typing everything by hand is not realistic. A CSV import, usually exported straight from the customer's ERP system or a spreadsheet, replaces manual entry with a single file upload. Two columns are typically enough per line: SKU and quantity, separated by a comma or semicolon.
Parsing raises a fundamental choice between client side and server side. Client side parsing in the browser gives instant feedback with no round trip to the server, but runs into trouble with very large files and encoding pitfalls such as Windows-1252 exports from Excel. Server side parsing through a dedicated upload endpoint normalizes delimiters and character encoding more reliably and scales better for very large lists, at the cost of an extra request and a bit more implementation effort.
In practice a combination works well: small files up to a few hundred rows get parsed client side and dropped straight into the row table, larger files go to a server endpoint that handles validation and returns a structured result including a list of errors.
function parseCsv(text) {
const lines = text.trim().split(/\r?\n/);
const separator = lines[0].includes(';') ? ';' : ',';
return lines
.map((line) => {
const [sku, qty] = line.split(separator).map((v) => v.trim());
return { sku, qty: Number(qty) || 1 };
})
.filter((row) => row.sku && row.sku.toLowerCase() !== 'sku');
}
document.getElementById('csvUpload').addEventListener('change', async (event) => {
const file = event.target.files[0];
if (!file) return;
const text = await file.text();
const parsedRows = parseCsv(text);
if (parsedRows.length > 500) {
// Large file: hand off to a server endpoint for validation
await uploadCsvToServer(file);
return;
}
Alpine.store('quickOrder').rows = parsedRows;
});
4. A GraphQL Batch Mutation for Adding Multiple Products
Once the row list is ready, every line item should move into the cart in a single step rather than a chain of individual addProductToCart calls. Magento 2.4.8 provides the addProductsToCart mutation for exactly this, accepting a cart ID plus an array of SKU and quantity pairs and processing all of them in one request.
The user_errors field in the response is particularly valuable. It returns a distinct error code for every failed line item along with a path pointing at the specific row in the submitted array. That makes it possible to pinpoint exactly which row failed in the frontend, without the entire request failing as a whole.
A single mutation for the whole list cuts the number of requests dramatically compared to a loop of individual calls. Very long lists still hit practical limits with this approach, which is covered in the performance section below.
mutation AddProductsToCart($cartId: String!, $items: [CartItemInput!]!) {
addProductsToCart(cartId: $cartId, cartItems: $items) {
cart {
id
total_quantity
}
user_errors {
code
message
path
}
}
}
# Example variables
# {
# "cartId": "abc123",
# "items": [
# { "sku": "24-MB01", "quantity": 5 },
# { "sku": "24-MB02", "quantity": 12 }
# ]
# }
5. Real-Time Stock Validation: Debounce and a Stock Query
Before a row is even submitted to the cart, immediate feedback on stock availability right inside the form is valuable. Every change to the SKU or quantity field triggers a GraphQL query against the products field, filtered by the entered SKU, requesting stock_status and, where multi source inventory is active, the available quantity via the only_x_left_in_stock field.
Without debouncing, every single keystroke would fire its own request, needlessly loading both frontend and server. A debounce of roughly 300 to 500 milliseconds per row, implemented with setTimeout and clearTimeout or Alpine's built in .debounce modifier, ensures a request only goes out after a short pause in typing.
Switching quickly between rows can cause older responses to arrive after newer ones. A simple safeguard is a request counter or an AbortController per row that discards stale responses, so the displayed status always matches the most recently entered value.
let debounceTimer;
function onRowInput(index) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => checkStock.call(this, index), 400);
}
async function checkStock(index) {
const row = this.rows[index];
row.status = 'checking...';
const query = `
query CheckStock($skus: [String!]!) {
products(filter: { sku: { in: $skus } }) {
items {
sku
stock_status
only_x_left_in_stock
}
}
}
`;
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables: { skus: [row.sku] } }),
});
const { data } = await response.json();
const found = data.products.items[0];
if (!found) {
row.status = 'SKU not found';
} else if (found.stock_status !== 'IN_STOCK') {
row.status = 'out of stock';
} else if (found.only_x_left_in_stock && row.qty > found.only_x_left_in_stock) {
row.status = `only ${found.only_x_left_in_stock} available`;
} else {
row.status = 'available';
}
}
6. Per-Row Error Feedback: SKU Not Found and Stock Conflicts
For usability, every row needs its own clearly visible status rather than a single combined error message at the bottom of the form. A small icon next to the quantity field, paired with a short label underneath, works well: green for available, amber for limited availability, red for a hard error.
The two most common error types deserve different treatment. A SKU that returns no match in the product query is a hard error, that row cannot move into the cart until the SKU is corrected. A known SKU with insufficient stock is a soft error instead, here it helps to show the maximum available quantity as a clickable suggestion, so the customer can adjust the quantity with one click instead of doing the math themselves.
Above the row list, a compact summary helps, showing how many rows still have open errors. The submit button stays disabled or shows a warning until either every error is resolved or the customer deliberately chooses to proceed with only the error free rows.
7. Order Lists for Recurring Bulk Orders
Many B2B customers do not order just once, they place the same or a very similar base set of items on a regular cadence, for instance a monthly restock of consumables. A saved order list that loads into the quick order form with a single click saves far more time than even the best CSV import.
Adobe Commerce B2B ships a full native module for this called requisition lists, complete with integration into company accounts and approval workflows. That feature is exclusive to Adobe Commerce and Adobe Commerce Cloud, however, and is not available in Magento Open Source.
For Open Source projects a leaner custom approach works: a dedicated entity that ties SKU and quantity pairs, along with a list name, to the customer_id. The quick order form can load such a saved list, prefill the rows, and run it through the same real-time validation as a freshly typed list. The feature set stays deliberately narrower than requisition lists, but it covers most bulk ordering scenarios perfectly well.
8. Performance With Large SKU Lists: Chunking, Debounce, and Progress
Past a certain row count, often somewhere between 100 and 300 line items in practice, a single addProductsToCart mutation becomes a risk. Response time climbs, and server side timeouts or memory limits can fail the entire request even though the overwhelming majority of rows would have processed fine.
Chunking is the fix: the full list is split into smaller blocks, say 50 items each, sent to the server as separate mutations one after another. Each block returns its own user_errors result, so errors still map precisely to a row, even across multiple chunks.
A progress indicator belongs on the interface too, whether as a percentage or a count of processed against total items. Since each chunk request runs asynchronously with await inside a loop, the interface stays responsive throughout, and the customer can watch a large bulk order progress live.
async function submitRowsInChunks(rows, chunkSize = 50) {
const chunks = [];
for (let i = 0; i < rows.length; i += chunkSize) {
chunks.push(rows.slice(i, i + chunkSize));
}
let processed = 0;
for (const chunk of chunks) {
await addProductsToCart(chunk);
processed += chunk.length;
this.progress = Math.round((processed / rows.length) * 100);
}
}
9. Interaction With Company Accounts and Approval Workflows
In environments with company account structures enabled, submitting the quick order form can trigger an additional step. If a purchase order approval workflow is configured for the given user, the order first sits pending with one or more approvers before it is actually finalized.
For the quick order form itself, practically nothing changes. Entry, CSV import, stock validation, and the batch mutation run exactly the same way. Only the final checkout step differs depending on the company configuration, between placing the order directly and queuing it for approval.
How company accounts, roles, and approval rules are built in Hyvä is deliberately outside the scope of this article, that belongs to the broader discussion of B2B features in the theme. The point worth taking away here is that quick order works as an entry path independent of whichever approval model is in place.
| Method | Input Effort | Performance at Scale | Error Handling | Availability |
|---|---|---|---|---|
Manual SKU rows |
low to moderate, copy paste works | good up to roughly 50 rows | per row, visible immediately | always, pure frontend component |
Client side CSV import |
very low, file upload | good up to roughly 500 rows | summarized after parsing | always, no server endpoint needed |
Server side CSV import |
very low, file upload | good even with several thousand rows | detailed per row feedback | requires a dedicated REST or GraphQL endpoint |
GraphQL batch mutation |
no extra input, just submission | good with chunking, otherwise timeout risk | user_errors per line item |
Magento 2.4.8 GraphQL API, every edition |
Requisition lists |
low, reuse saved lists | optimized by the vendor | built into the approval workflow natively | Adobe Commerce B2B only, not Open Source |
Custom order lists |
low, reuse saved lists | depends on the custom implementation | as good as what you build | Open Source and Commerce alike |
Mironsoft
Hyvä theme development and Luma migration
Still running Luma, or a Hyvä theme that just doesn't feel right?
We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.
Luma-to-Hyvä Migration
Move an existing shop to Hyvä in a structured way, without losing functionality.
Custom Theme Development
Build a custom Hyvä theme from scratch based on your design.
Performance Optimization
Improve Core Web Vitals and load times in the Hyvä frontend with purpose.
10. Summary
Quick Order in Hyvä
SKU Rows & CSV Import
Dynamic Alpine rows for SKU and quantity, extended with client side or server side CSV import for larger bulk orders.
GraphQL Batch Mutation
addProductsToCart accepts multiple line items in one request and returns user_errors per row for targeted feedback.
Real-Time Stock Check
A debounced stock query per row catches missing SKUs and tight stock levels before the order is submitted.
Order Lists & Company Accounts
Custom order lists stand in for Adobe Commerce requisition lists in Open Source, purchase order approvals apply unchanged at checkout.