Build a product details page with Hydrogen
In this tutorial, you'll create a new page in your Remix app to display product data from Shopify.

You can display detailed information about products on your custom storefront. Your product details affect the way that the product is displayed to customers, help you to organize your products, and help customers to find the product.
By creating a product page, you can share information about a product with customers, including the product’s variants, description, and price. You’ll also be able to offer customers a way to purchase the product.
What you'll learn
Anchor link to section titled "What you'll learn"In this tutorial, you'll learn how to do the following tasks:
- Set up a new Remix route
- Query products by their handle
- Render product data on the page
- Create a product variant selector
- Make the product variant selector interactive with Remix Links and Optimistic UI
- Add a Shop Pay button
Requirements
Anchor link to section titled "Requirements"Step 1: Create a product route
Anchor link to section titled "Step 1: Create a product route"To begin building your product page, create a file called app/routes/products.$handle.jsx
, and add the following code.
You should test your route and ensure the page renders. Open your browser and navigate to http://localhost:3000/products/snowboard
Step 2: Setup the Remix loader to get product data
Anchor link to section titled "Step 2: Setup the Remix loader to get product data"Loaders are functions that retrieve the data that's needed to render this page.
Create the loader
Anchor link to section titled "Create the loader"Create a loader function by adding the following code to the top of your $handle.jsx
route. This code example retrieves the URL handle variable from the params
that are passed to the loader function, and returns sample JSON that you can use in the JSX component.
Render the loader data
Anchor link to section titled "Render the loader data"Now that you have a loader, you can get the loader's data in your JSX component with the useLoaderData()
hook. Now import that function and render the handle. Your complete $handle.jsx
file should look like this.
Reload your page and check if your URL handle shows in your <h2>
.

Step 3: Query the Storefront API
Anchor link to section titled "Step 3: Query the Storefront API"Your loader function uses the params
object to get URL data. Remix loaders also include the request
and context
objects. Shopify's storefront query function exists in the context
object. You can use the following steps to set up a GraphQL query and connect it to your storefront query()
function.
Create a GraphQL string
Anchor link to section titled "Create a GraphQL string"Add the following query to the bottom of your product route. This simple query uses the $handle` variables to retrieve the data from a single product, using the handle in your URL.
Execute the query in the Remix loader
Anchor link to section titled "Execute the query in the Remix loader"Now you can combine your handle and GraphQL query string with a storefront query()
function. This function returns a promise object, so you need to update your loader function to be async
. You also need to update your loader parameters to destructure the context
object.
Update your loader so it includes this code.
Here are the changes made:
- Added
async
to allow the loader function to handle Promises - Added
context
to your loader function arguments - Added a function
context.storefront.query()
that uses thePRODUCT_QUERY
and thehandle
created in previous steps - Destructured the
product
from the API query function response - Added the
product
to the JSON object returned from the loader
Create a debug component to see product data
Anchor link to section titled "Create a debug component to see product data"It might be difficult to test the API query that you created. You can console.log()
your product data to do a quick check, but it might be easier to see the data formatted in your browser. You can add the following code to your product route to create a small utility component that renders the JSON.
You can update your ProductHandle()
component to show product data. Add the product
object to your userLoaderData()
destructured object. Then add the product data to your PrintJson()
component as a data
prop.

Step 4: Display title, description, and product images
Anchor link to section titled "Step 4: Display title, description, and product images"Now that you're familiar with querying product data, you can start building the page for your customers. First, you need to update your GraphQL query to get the full set of data for the rest of this tutorial.
Update the GraphQL query
Anchor link to section titled "Update the GraphQL query"Replace your existing PRODUCT_QUERY
with the following query. This query fetches data for the product display page, including descriptive details, images, and options data.
Now that you have the data, you can render it on the page. You can use HTML, CSS, and Tailwind to render the product data on the page, as shown in the following example:
Create a product image gallery
Anchor link to section titled "Create a product image gallery"In your previous query, you retrieved the image data for the product. In this step, you'll create a ProductGallery
component that uses Hydrogen's MediaFile
component to build a product image gallery.
Import the MediaFile
component at the top of your product route:
Below the ProductHandle
component, create a new component:
This code transforms the GraphQL MediaImage
and Model3d
nodes into a format that the MediaFile
component can use to render images and 3D models.
Render this component by replacing the <h2>TODO Product Gallery</h2>
in your ProductHandle()
function:

You should have the image, title, vendor, and description HTML rendering in a two-column layout.
Step 5: Render the product variant options
Anchor link to section titled "Step 5: Render the product variant options"Remix loads a full page when you navigate to it. For each additional URL change, React Router will handle page updates. The end result is a website that feels like an app, without all of the hassle.
Remix uses the URL as a state manager. This means that any interaction that you build for the product options selector should update the URL. You'll use Remix <Link>
components to help you build these interactive pieces.
The option selector works as follows:
- User clicks on option
<Link>
- Remix updates the URL
- Remix runs the
loader()
function with the new URL - The loader function executes a
storefront.query()
request - Remix returns the new data as JSON
- React receives an update from the
useLoaderData()
hook and makes the necessary updates to the page.
You will need to make two updates for the options selector to work.
- Render the options as
<Link>
components - Update the
storefront.query()
to accept the new options URL parameters
Render the product options links
Anchor link to section titled "Render the product options links"Create a new component to render these links. Create the file app/components/ProductOptions.jsx
. Add in the following code.
This is the important code to build your links:
Now you can add this component to your ProductHandle()
function in products.$handle.jsx
.
At the top of the file:
Right before the description block:
Reload your page and click on some links. You should see the URL change, but the page doesn't reload. This is React Router taking control and loading your updated state.
You still need to update the loader function to recognize our URL parameters and update the data, which is covered in the next section.
Update the Loader
Anchor link to section titled "Update the Loader"Just like params
and context
, the loader has access to the request
object. You'll need the searchParams
from the request URL.
Use the following code to update the loader
function in your product route to pull in the request
and format the options data for the Storefront API.
This updated code takes the search params and creates a selectedOptions
array of objects that's passed to the PRODUCT_QUERY
.
Now, you can update your PRODUCT_QUERY
to get a selected variant from the selectedOptions
data. Replace your current query with the following query:
The variantBySelectedOptions
returns the selected variant based on the option values that you retrieved from the URL. You can test the return by adding a line below <ProductOptions/>
to print out the selected variant ID:
Click on some options to see the selected variant ID change. After you have verified, you can remove the selected variant line of code.
Right now you can't visually see what options are selected. You will fix this in the next section.
Update the product options UI
Anchor link to section titled "Update the product options UI"The URL query parameters hold your "state", so you can refer to these parameters to determine the selected option. You can read these parameters with Remix's useSearchParams()
hook. Update your logic so that it loops over each option to add a dark border to selected <Link>
elements.
Now you have an option selector that responds to URL changes, but the link underline doesn't update immediately.
This delay happens because Remix is responding to the URL change, running the loader()
function, and passing that data down to the React component. Even when all servers and APIs are fast, this process isn't instant. Because you know that the selection will be successful, you can update the UI without waiting for a server response. Remix's Optimistic UI pattern is perfect for this use case. Next you'll add the useNavigation()
hook and update the logic that reads the search parameters.
Import
useNavigation
at the top of yourProductOptions.jsx
file:Insert the following code at the top of your
ProductOptions
function:Replace the
searchParams
value:
Reload the page and make some option selections. The underline selections are now instant, even when the loader is still working to update the data.

Here is your complete ProductOptions
component:
Step 6: Set a default variant
Anchor link to section titled "Step 6: Set a default variant"You might notice that a fresh product page has no options selected. You can update your loader to use a default variant so there is always an orderable product available on the page.
The PRODUCT_QUERY
already gets the first variant for you with the variants(first: 1)
query filter. You can use that data to update the logic in your loader()
function. You'll need to make updates in 4 places:
- Create a new
selectedVariant
value in the loader - Destructure it from
useLoaderData()
in yourProductHandle
JSX function - Pass the
selectedVariant
to theProductOptions
component - Update the logic in
ProductOptions
to set the selected default value
Update the loader
Anchor link to section titled "Update the loader"Add the following code to the bottom of the loader
function, replacing the return
statement:
Update the useLoaderData()
hook
Anchor link to section titled "Update the useLoaderData() hook"Add selectedVariant
to the useLoaderData()
destructured object:
Pass the variant to ProductOptions
Anchor link to section titled "Pass the variant to ProductOptions"Update the <ProductOptions>
component to accept a selectedVariant
parameter:
Update ProductOptions
Anchor link to section titled "Update ProductOptions"Replace the top portion of ProductOptions
with the following code:
The new logic does the following:
- Get the
selectedVariant
from the component props - On render, create a
paramsWithDefaults
URLSearchParams Object - Clone the existing parameters
- If the parameters don't already include a selected value for the selected variant, then add it to the cloned parameters
- Use
paramsWithDefaults
as the fallback when you create thesearchParams
object
Test the new logic by visiting http://localhost:3000/products/snowboard. Default option values should be selected on page load.

This logic doesn't override a user selection, but it pre-populates selected options based on the first variant returned from the Storefront API.
Step 7: Add a Shop Pay Button
Anchor link to section titled "Step 7: Add a Shop Pay Button"You've already created a page where customers can view and select your product. To add a Shop Pay button, you can refer to the Hydrogen React components for pre-built commerce primitives.
Hydrogen React components add client-side ecommerce functionality to JavaScript-based apps. In this step, you'll use the Money
and ShopPay
components to simplify your development experience.
Get the Shop Domain
Anchor link to section titled "Get the Shop Domain"To use the Shop Pay button, you'll need to get the shop domain from the context
object in the loader.
In the
products.$handle.jsx
loader()
function, add the following code:Return the
storeDomain
value in thejson()
function:In the
ProductHandle
function, add thestoreDomain
value to theuseLoaderData()
hook:
Create a Shop Pay Button
Anchor link to section titled "Create a Shop Pay Button"In the
products.$handle.jsx
file, import the Hydrogen React components to render pricing and the Shop Pay button:Below
useLoaderData()
, set a new value that's based on whether the user can purchase the selected product:Below the
<ProductOptions>
, render the price and the Shop Pay button:
The pricing should now show for the selected variant, and you can jump directly to a Shop Pay checkout.
The product page now renders all of the details for a product and its variants. It also includes a button to purchase the product.

Learn how to build a cart by defining the context for interacting with a cart and adding an Add to Cart button, enabling customers to choose products to purchase before entering the payment process.