Nikola Ristić     ← back to index

Tips for making Shopify storefronts in Remix

29/01/2023

Shopify's Hydrogen v2 with Remix got out so if you're planning to build a full-fledged storefront I recommend that. The tips here are stil valid, especially if you want to build a small site without any framework.

Recently I got into building some Shopify headless storefronts — I wasn't familiar with the Storefront API but seeing the docs are super clear and rich, how hard can it be I thought? As it turns out, not super hard! Their API is incredibly useful and intuitive. For somebody that never did any ecommerce work beforehand, it was a breeze, especially if you worked with other graphql apis like Github's.

A couple of days before we started the project, Hydrogen v1 has been just out. Lucky us, the official React framework by Shopify is exactly what we needed for a site that was getting over 1 million monthly views. We haven't had much time to experiment but trusted the framework will work. It was a partial success. The server/client components distinction wasn't such a problem — I was following React's development and it didn't come up as a surprise. More so, it was a lack of features, documentation, and occasional bugs that caused issues. We had luck that the hard-working Hydrogen team was fixing issues as we encountered them. (#1843 and #2086 to name a few).

I won't talk about the Hydrogen storefront we built — but rather a new one I built in Remix. I wanted to use this new framework for quite some time, and this was an easy pick, especially since Remix was bought by Shopify and Hydrogen v2 will basically be Remix on batteries. So hopefully all of the examples will still be applicable when Hydrogen v2 gets out.

So let's dive into some of the tips that can help you build Shopify storefronts with Remix and Typescript.

Storefront API Types

To consume the types we need to install a package from Shopify. IMO there should be a separate package only for the Storefront API types.

npm i --save @shopify/hydrogen-react
import type {Product} from '@shopify/hydrogen-react/storefront-api-types';

const product = {} satisfies Product;

Querying, rate rimits & buyer IP

Since we'll be making requests from the server we need to make sure we're not hitting the rate limits with the Storefront API. To do this, Shopify instructs us to include two headers in our requests: Shopify-Storefront-Private-Token and Shopify-Storefront-Buyer-IP.

Shopify-Storefront-Private-Token is an Admin API access token that can be generated inside the Shopify admin dashboard (/admin/settings/apps/development → [your app] → "API Credentials" tab). Be sure to chose only the permissions you will need — it will most probably be only reading the data, depending on your storefront shop.

Shopify-Storefront-Buyer-IP is an optional but highly recommended header that has the visitors IP. I've used the following code to get it:

// used in dev env to get the user's ip
async function getDevIp(): Promise<string | null> {
	const { ip } = (await fetch('https://api64.ipify.org?format=json').then(
		(res) => res.json(),
	)) as { ip: string }
	return ip || null
}

const ipHeaders = ['X-Forwarded-For', 'CF-Connecting-IP'] as const

export async function getBuyerIp(req: Request): Promise<string | null> {
	const { headers } = req

	if (process.env.NODE_ENV === 'development') {
		const devIp = await getDevIp()
		if (devIp) {
			headers.set(ipHeaders[0], devIp)
		}
	}

	return (
		ipHeaders.flatMap((h) => headers.get(h)?.split(',')).find(Boolean) ||
		null
	)
}

You can add other headers depending on your hosting provider but this should work if you're deploying to Vercel, Cloudflare or similar platforms.

Use it inside your loaders:

const query = gql`...`
const buyerIp = await getBuyerIp(request)
const data = await graphql<Data>(buyerIp, query, { handle: params.product })

The graphql function sets the headers internally, and makes the request:

export async function graphql<T>(
	buyerIp: string | null,
	query: string,
	variables: Record<string, unknown> = {},
): Promise<T> {
	const headers = new Headers({
		'Content-Type': 'application/json',
		'X-Shopify-Storefront-Access-Token': SHOPIFY_ACCESS_TOKEN!,
		'Shopify-Storefront-Private-Token': SHOPIFY_PRIVATE_ACCESS_TOKEN!,
	})

	if (buyerIp) {
		headers.set('Shopify-Storefront-Buyer-IP', buyerIp)
	}

	const resp = await fetch(
		`https://${SHOPIFY_SHOP}/api/${STOREFRONT_VERSION}/graphql.json`,
		{
			method: 'POST',
			headers,
			body: JSON.stringify({ query, variables }),
		},
	)
	if (!resp.ok) {
		// error handling
	}
	const json = await resp.json()
	if (json.errors) {
		// error handling
	}
	return json.data as T
}

That of course means you can't make any client-side requests to the Storefront API directly — just pipe them through an API route. Like so:

// app/routes/api/graphql.ts
export const action = async ({ request }: ActionArgs) => {
	const { query, variables } = await request.json()
	const buyerIp = await getBuyerIp(request)

	try {
		const data = await graphql(buyerIp, query, variables)
		return json(data)
	} catch (err) {
		// error handling
	}
}

Country Selection

Similiary to the visitors IP, there are certain headers we can use to access the visitors country.

import { CountryCode } from '@shopify/hydrogen-react/storefront-api-types'

// will only be used in dev because vercel headers are missing
export const DEFAULT_COUNTRY = CountryCode.Rs

export function getBuyerCountry(request: Request): CountryCode {
	// get the visitors country from vercel's header
	// https://vercel.com/docs/concepts/edge-network/headers#x-vercel-ip-country
	const buyerCountry = request.headers.get(
		'X-Vercel-IP-Country',
	) as CountryCode

	// https://developers.cloudflare.com/fundamentals/get-started/reference/http-request-headers/#cf-ipcountry
	// For Cloudflare, use 'CF-IPCountry'

	return buyerCountry || DEFAULT_COUNTRY
}

But users should be able to choose their preferred country. We will save this preference inside a cookie.

Create a file called app/cookies.tsx for creating a cookie instance. At the moment Remix suggests creating cookies inside this file.

import { createCookie } from "@remix-run/node"
export const countryCode = createCookie("country-code", { maxAge: 604_800 }) // one week

And inside root.tsx (or any other) loader, read the countries preference, which by default will be unset.

const buyerCountry = getBuyerCountry(request)
const userPickedCountry = (await countryCode.parse(request.headers.get("Cookie"))) || {};

return json({ ...data, buyerCountry: userPickedCountry || buyerCountry })

To allow changing user's preference create a file app/routes/api/countries.tsx.

import { ActionArgs, redirect } from '@remix-run/node'
import { countryCode } from '~/cookies'

export const action = async ({ request }: ActionArgs) => {
	const formData = await request.formData()
	const country = formData.get('country')

	// validate country from a list or otherwise

	const cookie =
		(await countryCode.parse(request.headers.get('Cookie'))) || {}
	// set country inside cookie
	cookie.countryCode = country

	const path = request.referrer ? new URL(request.referrer).pathname : '/'

	return redirect(path, {
		headers: {
			'Set-Cookie': await countryCode.serialize(cookie),
		},
	})
}

A basic example form on the client-side could look like this. Send an empty value to clear out user preference.

<Form method="post" action="/api/countries">
	<input
		type="text"
		name="country"
		placeholder={data.buyerCountry || ''}
	/>
	<button type="submit" hidden>
		Save
	</button>
</Form>

You can also read it anywhere with this simple hook:

const useCountry = () => {
	const [root] = useMatches()
	return root.data.buyerCountry
}

Sending emails from actions

Sending emails using Sendgrid or nodemailer is apparently not working, so there's a workaround. This repo shows the issue but basically to use @sendgrid/mail or nodemailer import and re-export them from entry.server.tsx and use them inside your actions.

// entry.server.tsx
import sgMail from '@sendgrid/mail'
export const mailer = sgMail
mailer.setApiKey(SENDGRID_API_KEY)

// route.tsx
import { mailer as sgMail } from '../entry.server'

export const action = async (args: ActionArgs) => {
	// ...
	await sgMail.send(msg)
}

Thanks @ccssmnn and @terza98 for helping with this issue.

Nikola Ristić     ← back to index