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.

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"- You've built a collection page with Hydrogen.
- You're using API version 2023-07 or higher.
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.
Ensure the page renders by clicking through to the Hoodie.
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 the 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 products.$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. Import the hook and render the handle. Your complete products.$handle.jsx
file should look like this.
Reload your page to see the URL handle render on the page.

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
variable to retrieve the data for a single product.
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. Update your loader:
Log the product data
Anchor link to section titled "Log the product data"Update your ProductHandle()
component to destructure and log product data:
You should see product details in your developer console.

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 out the page. First, update your GraphQL query to get more product data.
Update the GraphQL query
Anchor link to section titled "Update the GraphQL query"Replace your existing PRODUCT_QUERY
with the following query to fetch more product data, including descriptive details, images, and options.
Render the title, vendor and description on the page:
Export a handle
object for your SEO component.
Render the product image
Anchor link to section titled "Render the product image"Import the Image
component at the top of your product route. This component will render a responsive image.
Replace <h2>TODO Product Image</h2>
in your ProductHandle()
function with the <Image>
component and pass along your featured image.
You should have the featured image, title, vendor, and description 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 to store state. This means that any interaction that you build for the product options selector should also be reflected in 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 the options as links. Create the file app/components/ProductOptions.jsx
. Add in the following code.
Now you can add this component to your ProductHandle()
function in products.$handle.jsx
.
At the top of the file, import the new component.
Right before the description block, render the component.
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"As well as params
and context
, the loader also 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:
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 which options are selected. You will fix this in the next section.
Display the selected variant's image
Anchor link to section titled "Display the selected variant's image"Update the product image to display the selected variant's image (if available).
Update the product options UI
Anchor link to section titled "Update the product options UI"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 underline to selected <Link>
elements.
Optimistic UI
Anchor link to section titled "Optimistic UI"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 usually 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 image changes and 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's primary domain from the Storefront API.
Update your
PRODUCT_QUERY
to also fetch the shop's primary domain URL.Destructure the
shop
object from the response.Return the
shop
object in thejson()
function.In the
ProductHandle
function, destructureshop
to theuseLoaderData()
hook.
Render the Shop Pay Button
Anchor link to section titled "Render the Shop Pay Button"In the
products.$handle.jsx
file, import the Hydrogen React components to render pricing and the Shop Pay button: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 (if the store has it activated).
The product page now renders all of the details for a product and its variants. It also includes a button to purchase the product.

Implement an Add to cart button, enabling customers to choose products to purchase before entering the payment process.