What We Are Building in Part 2
Part 1 created the local database and secure PHP foundation. This chapter turns that data into the customer-facing storefront.
Catalog Grid
- Reusable product cards
- Pagination
- Sorting
- Local product images
Collection Filters
- URL query parameters
- Database filtering
- Active filter states
- Shareable filtered URLs
Product Page
- Dynamic product content
- Image gallery
- Color swatches
- Size buttons
Browser Interaction
- Color switching
- Size availability
- Variant selection
- Related products
PHP decides which product and variants exist. JavaScript makes the page feel immediate by switching images, enabling sizes, and tracking the selected variant without reloading the page.
Contents
- How the catalog request works
- Build the catalog query
- Add collection filters
- Add sorting
- Create a reusable product card
- Render the product grid
- Add pagination
- Load the product page
- Resolve product images
- Prepare variant data
- Build the product-page HTML
- Add color switching
- Add size availability
- Track the selected variant
- Load related products
- Style the catalog and product page
- Accessibility and fallback behavior
- Part 2 checklist
- Frequently asked questions
How a Catalog Request Works
The Visitor Opens the Catalog
/index.php?line=Catastrophe+Cats
The URL may contain a collection, sorting option, or page number.
PHP Validates the Filters
PHP accepts only known sort options and treats the collection name as a prepared query value.
MySQL Returns the Matching Products
Only active products for the requested collection and page are loaded.
PHP Builds the Product Grid
Every database row is passed through the same reusable product-card template.
Build the Catalog Query
Start with validated pagination values and a controlled list of sorting options.
<?php
$page = filter_input(
INPUT_GET,
'page',
FILTER_VALIDATE_INT,
[
'options' => [
'default' => 1,
'min_range' => 1,
],
]
);
$perPage = 24;
$offset = ($page - 1) * $perPage;
$line = trim((string) ($_GET['line'] ?? ''));
$sort = (string) ($_GET['sort'] ?? 'newest');
$allowedSorts = [
'newest' => 's.item_number DESC',
'likes' => 's.likes DESC, s.item_number DESC',
'popular' => 's.buy_click_count DESC, s.item_number DESC',
'price-low' => 's.price ASC, s.item_number DESC',
'price-high' => 's.price DESC, s.item_number DESC',
];
$orderBy = $allowedSorts[$sort] ?? $allowedSorts['newest'];
$where = [
's.active = 1',
];
$params = [];
if ($line !== '') {
$where[] = 'TRIM(s.line) = :line';
$params[':line'] = $line;
}
$sql = "
SELECT
s.id,
s.item_number,
s.title,
s.slug,
s.line,
s.category,
s.short_description,
s.price,
s.likes,
s.buy_click_count
FROM shirts AS s
WHERE " . implode(' AND ', $where) . "
ORDER BY {$orderBy}
LIMIT :limit
OFFSET :offset
";
$stmt = $pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue(
$key,
$value,
PDO::PARAM_STR
);
}
$stmt->bindValue(
':limit',
$perPage,
PDO::PARAM_INT
);
$stmt->bindValue(
':offset',
$offset,
PDO::PARAM_INT
);
$stmt->execute();
$shirts = $stmt->fetchAll();
Prepared statements cannot bind SQL keywords or column names. Use a fixed whitelist that maps public sort names to known SQL fragments.
Add Collection Filters
The filter should create a real URL so it remains shareable, bookmarkable, and usable without JavaScript.
<nav class="catalog-filters" aria-label="Product collections">
<a href="/index.php"
class="catalog-filter<?= $line === '' ? ' is-active' : '' ?>">
All Shirts
</a>
<?php foreach ($allLines as $collection): ?>
<?php
$collectionName = $collection['line'];
$isActive = $line === $collectionName;
?>
<a href="/index.php?line=<?= urlencode($collectionName) ?>"
class="catalog-filter<?= $isActive ? ' is-active' : '' ?>">
<?= htmlspecialchars(
$collectionName,
ENT_QUOTES,
'UTF-8'
) ?>
<span>
<?= (int) $collection['cnt'] ?>
</span>
</a>
<?php endforeach; ?>
</nav>
Add Sorting
<form method="get" class="catalog-sort">
<?php if ($line !== ''): ?>
<input type="hidden"
name="line"
value="<?= htmlspecialchars(
$line,
ENT_QUOTES,
'UTF-8'
) ?>">
<?php endif; ?>
<label for="catalog-sort-select">
Sort by
</label>
<select id="catalog-sort-select"
name="sort"
onchange="this.form.submit()">
<option value="newest"
<?= $sort === 'newest' ? 'selected' : '' ?>>
Newest
</option>
<option value="likes"
<?= $sort === 'likes' ? 'selected' : '' ?>>
Most Liked
</option>
<option value="popular"
<?= $sort === 'popular' ? 'selected' : '' ?>>
Most Purchased
</option>
<option value="price-low"
<?= $sort === 'price-low' ? 'selected' : '' ?>>
Price: Low to High
</option>
<option value="price-high"
<?= $sort === 'price-high' ? 'selected' : '' ?>>
Price: High to Low
</option>
</select>
</form>
Create a Reusable Product Card
Keep product-card markup in one include so catalog pages, related products, search results, and collection pages all use the same component.
<?php
/**
* /includes/product-card.php
*
* Expected variable:
* $shirt
*/
$itemNumber = (int) $shirt['item_number'];
$productUrl = '/shirt.php?item=' . $itemNumber;
$imagePath = "/images/{$itemNumber}/Front, Black.jpg";
$imageFile = $_SERVER['DOCUMENT_ROOT'] . $imagePath;
if (!is_file($imageFile)) {
$imagePath = "/images/{$itemNumber}/design_transparent.jpg";
}
if (!is_file($_SERVER['DOCUMENT_ROOT'] . $imagePath)) {
$imagePath = '/imgs/product-placeholder.png';
}
?>
<article class="catalog-card">
<a href="<?= htmlspecialchars(
$productUrl,
ENT_QUOTES,
'UTF-8'
) ?>"
class="catalog-card-image-link">
<img src="<?= htmlspecialchars(
$imagePath,
ENT_QUOTES,
'UTF-8'
) ?>"
alt="<?= htmlspecialchars(
$shirt['title'],
ENT_QUOTES,
'UTF-8'
) ?>"
class="catalog-card-image"
loading="lazy"
decoding="async">
</a>
<div class="catalog-card-body">
<?php if (!empty($shirt['line'])): ?>
<span class="catalog-card-line">
<?= htmlspecialchars(
$shirt['line'],
ENT_QUOTES,
'UTF-8'
) ?>
</span>
<?php endif; ?>
<h3 class="catalog-card-title">
<a href="<?= htmlspecialchars(
$productUrl,
ENT_QUOTES,
'UTF-8'
) ?>">
<?= htmlspecialchars(
$shirt['title'],
ENT_QUOTES,
'UTF-8'
) ?>
</a>
</h3>
<?php if (!empty($shirt['short_description'])): ?>
<p class="catalog-card-desc">
<?= htmlspecialchars(
$shirt['short_description'],
ENT_QUOTES,
'UTF-8'
) ?>
</p>
<?php endif; ?>
<div class="catalog-card-footer">
<span class="catalog-card-price">
$<?= number_format(
(float) $shirt['price'],
2
) ?>
</span>
<span class="catalog-card-likes">
<i class="fa-regular fa-heart"
aria-hidden="true"></i>
<?= (int) $shirt['likes'] ?>
</span>
</div>
</div>
</article>
Render the Product Grid
<?php if (!$shirts): ?>
<div class="catalog-empty">
<h2>No products found</h2>
<p>
Try another collection or return to all shirts.
</p>
<a href="/index.php" class="hero-cta">
Shop All Shirts
</a>
</div>
<?php else: ?>
<div class="catalog-grid">
<?php foreach ($shirts as $shirt): ?>
<?php include __DIR__ . '/includes/product-card.php'; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
Add Pagination
Count matching products with the same filters used by the catalog query.
<?php
$countSql = "
SELECT COUNT(*)
FROM shirts AS s
WHERE " . implode(' AND ', $where);
$countStmt = $pdo->prepare($countSql);
foreach ($params as $key => $value) {
$countStmt->bindValue(
$key,
$value,
PDO::PARAM_STR
);
}
$countStmt->execute();
$totalProducts = (int) $countStmt->fetchColumn();
$totalPages = max(
1,
(int) ceil($totalProducts / $perPage)
);
function catalogUrl(
int $page,
string $line,
string $sort
): string {
$query = [
'page' => $page,
];
if ($line !== '') {
$query['line'] = $line;
}
if ($sort !== 'newest') {
$query['sort'] = $sort;
}
return '/index.php?' . http_build_query($query);
}
<?php if ($totalPages > 1): ?>
<nav class="catalog-pagination"
aria-label="Product catalog pages">
<?php if ($page > 1): ?>
<a href="<?= htmlspecialchars(
catalogUrl($page - 1, $line, $sort),
ENT_QUOTES,
'UTF-8'
) ?>">
Previous
</a>
<?php endif; ?>
<span>
Page <?= $page ?> of <?= $totalPages ?>
</span>
<?php if ($page < $totalPages): ?>
<a href="<?= htmlspecialchars(
catalogUrl($page + 1, $line, $sort),
ENT_QUOTES,
'UTF-8'
) ?>">
Next
</a>
<?php endif; ?>
</nav>
<?php endif; ?>
Load the Dynamic Product Page
The product page validates the item number, loads the product, variants, and approved reviews, and returns a 404 when the product does not exist.
<?php
$itemNumber = filter_input(
INPUT_GET,
'item',
FILTER_VALIDATE_INT,
[
'options' => [
'min_range' => 1,
],
]
);
if (!$itemNumber) {
http_response_code(404);
exit('Product not found.');
}
$productStmt = $pdo->prepare("
SELECT *
FROM shirts
WHERE item_number = :item_number
AND active = 1
LIMIT 1
");
$productStmt->execute([
':item_number' => $itemNumber,
]);
$shirt = $productStmt->fetch();
if (!$shirt) {
http_response_code(404);
exit('Product not found.');
}
$variantStmt = $pdo->prepare("
SELECT
id,
color,
size,
price,
enabled,
available,
shopify_variant_id,
printify_variant_id,
image_url
FROM shirt_variants
WHERE shirt_id = :shirt_id
AND enabled = 1
ORDER BY
color ASC,
FIELD(
size,
'XS',
'S',
'M',
'L',
'XL',
'2XL',
'3XL'
)
");
$variantStmt->execute([
':shirt_id' => $shirt['id'],
]);
$variants = $variantStmt->fetchAll();
Resolve Product Images
Use a predictable local image convention and a fallback order.
<?php
function productImageUrl(
int $itemNumber,
string $position,
string $color
): string {
$safePosition = preg_replace(
'/[^A-Za-z0-9 _-]/',
'',
$position
);
$safeColor = preg_replace(
'/[^A-Za-z0-9 _-]/',
'',
$color
);
$candidates = [
"/images/{$itemNumber}/{$safePosition}, {$safeColor}.jpg",
"/images/{$itemNumber}/{$safePosition}, {$safeColor}.png",
"/images/{$itemNumber}/design_transparent.jpg",
"/images/{$itemNumber}/design_transparent.png",
'/imgs/product-placeholder.png',
];
foreach ($candidates as $url) {
$file = $_SERVER['DOCUMENT_ROOT'] . $url;
if (is_file($file)) {
return $url;
}
}
return '/imgs/product-placeholder.png';
}
Build one image map for every available color:
<?php
$colors = [];
foreach ($variants as $variant) {
$color = $variant['color'];
if (!isset($colors[$color])) {
$colors[$color] = [
'front' => productImageUrl(
(int) $shirt['item_number'],
'Front',
$color
),
'back' => productImageUrl(
(int) $shirt['item_number'],
'Back',
$color
),
];
}
}
$defaultColor = array_key_first($colors);
$defaultImage = $colors[$defaultColor]['front']
?? '/imgs/product-placeholder.png';
Prepare Variant Data for JavaScript
JavaScript needs a clean list of only the fields used by the interface.
<?php
$clientVariants = array_map(
static function (array $variant): array {
return [
'id' => (int) $variant['id'],
'color' => $variant['color'],
'size' => $variant['size'],
'price' => (float) $variant['price'],
'available' => (bool) $variant['available'],
'shopifyVariantId' => $variant['shopify_variant_id'],
'imageUrl' => $variant['image_url'],
];
},
$variants
);
<script>
window.productData = {
variants: <?= json_encode(
$clientVariants,
JSON_UNESCAPED_SLASHES |
JSON_UNESCAPED_UNICODE
) ?>,
images: <?= json_encode(
$colors,
JSON_UNESCAPED_SLASHES |
JSON_UNESCAPED_UNICODE
) ?>
};
</script>
Build the Product-Page HTML
<section class="product-layout"
data-product-page>
<div class="product-gallery">
<img id="product-main-image"
src="<?= htmlspecialchars(
$defaultImage,
ENT_QUOTES,
'UTF-8'
) ?>"
alt="<?= htmlspecialchars(
$shirt['title'],
ENT_QUOTES,
'UTF-8'
) ?>"
class="product-main-image">
<div class="product-thumbnail-row">
<button type="button"
class="product-thumbnail is-active"
data-position="front">
Front
</button>
<button type="button"
class="product-thumbnail"
data-position="back">
Back
</button>
</div>
</div>
<div class="product-info">
<?php if (!empty($shirt['line'])): ?>
<p class="product-line">
<?= htmlspecialchars(
$shirt['line'],
ENT_QUOTES,
'UTF-8'
) ?>
</p>
<?php endif; ?>
<h1>
<?= htmlspecialchars(
$shirt['title'],
ENT_QUOTES,
'UTF-8'
) ?>
</h1>
<p id="product-price"
class="product-price">
$<?= number_format(
(float) $shirt['price'],
2
) ?>
</p>
<p class="product-short-description">
<?= htmlspecialchars(
$shirt['short_description'],
ENT_QUOTES,
'UTF-8'
) ?>
</p>
<fieldset class="product-option-group">
<legend>
Color:
<span id="selected-color-label">
<?= htmlspecialchars(
(string) $defaultColor,
ENT_QUOTES,
'UTF-8'
) ?>
</span>
</legend>
<div class="product-swatches"
id="product-swatches">
<?php foreach (array_keys($colors) as $color): ?>
<button type="button"
class="product-swatch<?=
$color === $defaultColor
? ' is-active'
: ''
?>"
data-color="<?= htmlspecialchars(
$color,
ENT_QUOTES,
'UTF-8'
) ?>"
aria-label="Select <?= htmlspecialchars(
$color,
ENT_QUOTES,
'UTF-8'
) ?>">
<span>
<?= htmlspecialchars(
$color,
ENT_QUOTES,
'UTF-8'
) ?>
</span>
</button>
<?php endforeach; ?>
</div>
</fieldset>
<fieldset class="product-option-group">
<legend>Size</legend>
<div class="product-sizes"
id="product-sizes"></div>
</fieldset>
<p id="variant-message"
class="product-variant-message"
aria-live="polite"></p>
<button type="button"
id="add-to-cart-button"
class="product-buy-button"
disabled>
Select a size
</button>
<div class="product-description">
<?= nl2br(
htmlspecialchars(
$shirt['description'],
ENT_QUOTES,
'UTF-8'
)
) ?>
</div>
</div>
</section>
Add Color and Image Switching
const productData = window.productData;
const mainImage = document.querySelector(
'#product-main-image'
);
const colorLabel = document.querySelector(
'#selected-color-label'
);
const swatches = Array.from(
document.querySelectorAll('.product-swatch')
);
const thumbnails = Array.from(
document.querySelectorAll('.product-thumbnail')
);
let selectedColor = swatches[0]?.dataset.color ?? '';
let selectedPosition = 'front';
function updateProductImage() {
const colorImages = productData.images[selectedColor];
if (!colorImages) {
return;
}
const nextImage =
colorImages[selectedPosition] ||
colorImages.front;
if (nextImage) {
mainImage.src = nextImage;
}
}
swatches.forEach((swatch) => {
swatch.addEventListener('click', () => {
selectedColor = swatch.dataset.color;
swatches.forEach((item) => {
item.classList.toggle(
'is-active',
item === swatch
);
});
colorLabel.textContent = selectedColor;
updateProductImage();
renderSizeButtons();
clearSelectedVariant();
});
});
thumbnails.forEach((thumbnail) => {
thumbnail.addEventListener('click', () => {
selectedPosition = thumbnail.dataset.position;
thumbnails.forEach((item) => {
item.classList.toggle(
'is-active',
item === thumbnail
);
});
updateProductImage();
});
});
Add Size Availability
Build the size buttons from variants available for the currently selected color.
const sizeOrder = [
'XS',
'S',
'M',
'L',
'XL',
'2XL',
'3XL'
];
const sizeContainer = document.querySelector(
'#product-sizes'
);
const variantMessage = document.querySelector(
'#variant-message'
);
const buyButton = document.querySelector(
'#add-to-cart-button'
);
let selectedVariant = null;
function variantsForSelectedColor() {
return productData.variants.filter(
(variant) => variant.color === selectedColor
);
}
function renderSizeButtons() {
const colorVariants = variantsForSelectedColor();
const variantsBySize = new Map(
colorVariants.map((variant) => [
variant.size,
variant
])
);
sizeContainer.innerHTML = '';
sizeOrder.forEach((size) => {
const variant = variantsBySize.get(size);
const button = document.createElement('button');
button.type = 'button';
button.className = 'product-size';
button.textContent = size;
button.dataset.size = size;
if (!variant || !variant.available) {
button.disabled = true;
button.classList.add('is-unavailable');
button.setAttribute(
'aria-label',
`${size} unavailable`
);
} else {
button.addEventListener('click', () => {
selectVariant(variant, button);
});
}
sizeContainer.appendChild(button);
});
}
Track the Selected Variant
const productPrice = document.querySelector(
'#product-price'
);
function selectVariant(variant, button) {
selectedVariant = variant;
document
.querySelectorAll('.product-size')
.forEach((item) => {
item.classList.toggle(
'is-active',
item === button
);
});
productPrice.textContent =
`$${variant.price.toFixed(2)}`;
variantMessage.textContent =
`${variant.color}, size ${variant.size} selected.`;
buyButton.disabled = false;
buyButton.textContent = 'Add to Cart';
buyButton.dataset.shopifyVariantId =
variant.shopifyVariantId || '';
}
function clearSelectedVariant() {
selectedVariant = null;
buyButton.disabled = true;
buyButton.textContent = 'Select a size';
buyButton.dataset.shopifyVariantId = '';
variantMessage.textContent = '';
}
renderSizeButtons();
Style the Catalog and Product Page
These styles provide a practical starting point. They can be merged into the existing catalog and product stylesheets.
.catalog-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2rem;
}
.catalog-filters {
display: flex;
flex-wrap: wrap;
gap: .6rem;
}
.catalog-filter {
display: inline-flex;
align-items: center;
gap: .45rem;
padding: .65rem .85rem;
border: 1px solid #d8d8d8;
border-radius: 999px;
background: #fff;
color: #111;
text-decoration: none;
font-size: .82rem;
font-weight: 700;
}
.catalog-filter.is-active {
border-color: #111;
background: #111;
color: #fff;
}
.catalog-grid {
display: grid;
grid-template-columns: repeat(
4,
minmax(0, 1fr)
);
gap: 1.25rem;
}
.catalog-card {
overflow: hidden;
border: 1px solid #dedede;
border-radius: 12px;
background: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, .05);
}
.catalog-card-image-link {
display: block;
aspect-ratio: 1 / 1;
background: #f5f5f5;
}
.catalog-card-image {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.catalog-card-body {
padding: 1rem;
}
.catalog-card-line {
display: block;
margin-bottom: .35rem;
color: #777;
font-size: .7rem;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
}
.catalog-card-title {
margin: 0 0 .5rem;
font-size: 1rem;
}
.catalog-card-title a {
color: inherit;
text-decoration: none;
}
.catalog-card-desc {
margin: 0 0 .8rem;
color: #555;
font-size: .82rem;
line-height: 1.5;
}
.catalog-card-footer {
display: flex;
justify-content: space-between;
gap: 1rem;
font-weight: 800;
}
.catalog-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
}
.product-layout {
display: grid;
grid-template-columns:
minmax(0, 1.1fr)
minmax(320px, .9fr);
gap: 3rem;
align-items: start;
}
.product-main-image {
width: 100%;
display: block;
border-radius: 12px;
background: #f5f5f5;
}
.product-thumbnail-row,
.product-swatches,
.product-sizes {
display: flex;
flex-wrap: wrap;
gap: .65rem;
margin-top: 1rem;
}
.product-thumbnail,
.product-swatch,
.product-size {
min-height: 42px;
padding: .65rem .8rem;
border: 1px solid #ccc;
border-radius: 8px;
background: #fff;
color: #111;
cursor: pointer;
}
.product-thumbnail.is-active,
.product-swatch.is-active,
.product-size.is-active {
border-color: #111;
background: #111;
color: #fff;
}
.product-size:disabled,
.product-size.is-unavailable {
cursor: not-allowed;
opacity: .35;
text-decoration: line-through;
}
.product-option-group {
margin: 1.5rem 0;
padding: 0;
border: 0;
}
.product-option-group legend {
margin-bottom: .7rem;
font-weight: 800;
}
.product-buy-button {
width: 100%;
min-height: 52px;
border: 0;
border-radius: 8px;
background: #111;
color: #fff;
font-weight: 800;
cursor: pointer;
}
.product-buy-button:disabled {
cursor: not-allowed;
opacity: .45;
}
@media (max-width: 1000px) {
.catalog-grid {
grid-template-columns: repeat(
3,
minmax(0, 1fr)
);
}
}
@media (max-width: 760px) {
.catalog-controls,
.product-layout {
grid-template-columns: 1fr;
display: grid;
}
.catalog-grid {
grid-template-columns: repeat(
2,
minmax(0, 1fr)
);
}
}
@media (max-width: 520px) {
.catalog-grid {
grid-template-columns: 1fr;
}
}
Accessibility and Fallback Behavior
Accessible Behavior
Fragile Behavior
Part 2 Storefront Checklist
- Catalog filters use real URLs.
- Collection values are passed through prepared statements.
- Sort options come from a fixed whitelist.
- The catalog query loads only active products.
- The catalog uses pagination or incremental loading.
- Product cards use one reusable include.
- Product images use predictable local paths and fallbacks.
- Product-card images use lazy loading.
- The product page validates the item number.
- Missing or inactive products return a 404 status.
- Only enabled variants are sent to the page.
- Unavailable variants cannot be selected.
- Color selection updates the product image.
- Color selection rebuilds size availability.
- The selected variant controls the displayed price.
- The selected Shopify variant ID is stored for checkout.
- Buttons work with a keyboard.
- Selection changes are announced through an aria-live region.
- Related products exclude the current product.
- JavaScript does not replace server-side validation.
Dynamic Catalog and Product Pages FAQ
How does one PHP product page display every shirt?
The product page reads an item number from the URL, validates it, loads the matching database record, loads its available variants and images, and then renders the same template with different data.
Should collection filters reload the page or use JavaScript?
Either approach works. Server-side query parameters are simpler, indexable, and reliable. JavaScript can improve the interaction, but the filtered URL should still work without JavaScript.
How should product card images be selected?
Use a predictable local image path or a stored image record. A common approach is to prefer a default front mockup and fall back to a transparent design or placeholder when the preferred image is missing.
How do color buttons know which image to display?
Each available color can include an image URL or a predictable image filename. JavaScript reads the selected color and updates the main product image and active swatch state.
How should unavailable sizes be handled?
Sizes should be enabled only when a matching variant exists and is both enabled and available. Unavailable sizes should be visibly disabled and excluded from checkout.
Why should variants be passed to JavaScript as JSON?
JSON lets the browser work with the same variant data already loaded by PHP. JavaScript can then update size availability, price, image, and Shopify variant IDs without another database request.
How are related products selected?
A simple related-product query can select active products from the same collection or category while excluding the current item. More advanced systems can use tags, themes, popularity, or manual relationships.
Should product pages use item numbers or slugs?
Item numbers are simple and stable. Slugs create cleaner URLs. A store can support both by using a slug publicly while retaining the item number as the internal business identifier.
How many products should load on the catalog page?
Use pagination or incremental loading instead of loading the entire catalog. The exact number depends on image weight and layout, but 12 to 24 products per request is a common starting point.
Does JavaScript replace server-side validation?
No. JavaScript improves the interface, but the server must still validate the product, variant, quantity, price assumptions, and cart request.
Continue to Part 3
The customer can now browse collections, open a product, select a color and size, and identify the correct Shopify variant. Part 3 turns that selected variant into a Shopify cart and checkout URL.