Skip to main content

Set up customer accounts

With customer accounts, customers can log in to your storefront, view their profile and order history, and check out as known customers. Hydrogen provides the Customer Account API client, OAuth route handlers, and session helpers. Your app owns the session storage and the account UI. This guide shows you how to wire customer accounts into your storefront and test them locally.


  1. A customer selects a login link that points to /account/login.
  2. Hydrogen's handler redirects the customer to Shopify's hosted login page.
  3. After the customer logs in, Shopify redirects back to /account/authorize. The handler exchanges the authorization code for tokens and stores them in your session.
  4. Your server reads the access token from the session and queries the Customer Account API.
  5. When the access token expires, /account/refresh gets a new one. A POST request to /account/logout ends the session.

Hydrogen owns these four routes through handleShopifyRoutes(), so you don't need to write login, authorize, refresh, or logout logic.



Anchor to Step 1: Configure the Customer Account APIStep 1: Configure the Customer Account API

In the Shopify admin, open the Headless or Hydrogen sales channel, select your storefront, and open the Customer Account API settings. Copy the client ID, then add the following values to your server environment:

Customer account environment variables

.env

NEXT_PUBLIC_SHOP_ID=<your numeric shop ID>
NEXT_PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID=<your Customer Account API client ID>
SESSION_SECRET=<a random string of 32 or more characters>
SITE_ORIGIN=https://local.tryhydrogen.dev:5173
SHOP_ID=<your numeric shop ID>
PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID=<your Customer Account API client ID>
SESSION_SECRET=<a random string of 32 or more characters>
VariableDescription
SHOP_IDYour numeric shop ID, like 12345678. Don't use a shop GID or your shop domain.
PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_IDThe Customer Account API client ID.
SESSION_SECRETA random secret, 32 characters or longer, that protects the session cookie. Keep it server-only.

In Next.js, the shop ID and client ID use the NEXT_PUBLIC_ prefix, and you also set SITE_ORIGIN to the origin that customers visit. Server Components can't read the request URL, so they build the request that reads the session from SITE_ORIGIN.

In the same settings, register your storefront's callback URI, JavaScript origin, and logout URI. The callback URI is your storefront origin plus /account/authorize.


Anchor to Step 2: Create the customer sessionStep 2: Create the customer session

Create the customer session once, at module scope, with createCustomerSession(). When you reuse it across requests, concurrent token refreshes for the same customer share one request to Shopify.

The customer session reads and writes tokens through a session manager that you create for each request. A session manager is an object with getSessionOrigin(), getSessionItem(), setSessionItem(), removeSessionItem(), and an optional commit() that returns the Set-Cookie headers to add to the response. Back it with protected server storage or an HttpOnly cookie. Never store tokens in browser storage or send them to client components.

Customer session

lib/customer-account.ts
import type {ShopifyRequestContext} from '@shopify/hydrogen';
import {
createCustomerAccountClient,
createCustomerSession,
} from '@shopify/hydrogen/customer-account';

import {env} from './env';
import {createRequestSessionManager} from './session';

export const customerSession = createCustomerSession({
shopId: env.SHOP_ID,
customerAccountApiClientId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID,
});

export function createCustomerSessionManager(request: Request) {
return createRequestSessionManager(request, env.SESSION_SECRET);
}

export function createRequestCustomerAccountClient(
requestContext: ShopifyRequestContext,
) {
return createCustomerAccountClient({shopId: env.SHOP_ID, requestContext});
}

env stands for your server environment. Read the variables from Step 1 with your framework's environment API. The module also exports a helper that creates a Customer Account API client for each request, so the client shares the request context with your Storefront API client.

Anchor to Create a session managerCreate a session manager

The session manager is app code, so you choose where tokens live. The following session manager keeps them in a signed, HttpOnly cookie. It uses @remix-run/cookie and @remix-run/session, which work with any framework that uses standard Request objects:

Cookie session manager

lib/session.ts
import {createCookie} from '@remix-run/cookie';
import {createCookieSessionStorage} from '@remix-run/session/cookie-storage';
import type {WritableCustomerSessionManager} from '@shopify/hydrogen/customer-account';

const sessionStorage = createCookieSessionStorage();

export async function createRequestSessionManager(
request: Request,
secret: string,
): Promise<WritableCustomerSessionManager> {
const cookie = createCookie('__Host-session', {
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/',
secrets: [secret],
});
const session = await sessionStorage.read(
await cookie.parse(request.headers.get('cookie')),
);
const origin = new URL(request.url).origin;

return {
getSessionOrigin: () => origin,
getSessionItem: (key) => session.get(key),
setSessionItem: (key, value) => session.set(key, value),
removeSessionItem: (key) => session.unset(key),
async commit() {
if (session.dirty && session.size === 0) session.destroy();
const value = await sessionStorage.save(session);
if (value === null) return;
return {
'set-cookie': await cookie.serialize(
value,
value === '' ? {maxAge: 0} : {},
),
};
},
};
}

A signed cookie is tamper-proof, but whoever holds it can read its contents. Vercel Shop, Vercel's Next.js template, uses an encrypted cookie instead. You can copy its session code from lib/auth/server.ts. For production, prefer opaque server-side session storage.

Anchor to Separate read-only and writable sessionsSeparate read-only and writable sessions

Hydrogen types session managers by what the caller can do:

  • A ReadonlyCustomerSessionManager can read the session. Use it in render paths that can't set cookies, like Next.js Server Components, layouts, and header UI.
  • A WritableCustomerSessionManager can also commit changes. Use it only where the response can carry a Set-Cookie header, like route handlers, Next.js proxy.ts, and React Router middleware.

Token refresh changes the session, so getOrRefreshAccessToken() requires a writable session manager. TypeScript rejects the call when you have only a read-only one.


Anchor to Step 3: Register the account handlersStep 3: Register the account handlers

Create the account handlers once with createCustomerAccountServerHandlers(), then pass them to handleShopifyRoutes() along with the request's session manager. This example shows only the customer account parts of your server request handler. For the full wiring, refer to Request handlers.

Register account handlers

lib/shopify.ts
import {handleShopifyRoutes} from '@shopify/hydrogen';
import {createCustomerAccountServerHandlers} from '@shopify/hydrogen/customer-account';

import {cartHandlers} from './cart-handlers';
import {createCustomerSessionManager, customerSession} from './customer-account';
import {routeTemplates} from './route-templates';
import {createRequestStorefrontClient} from './storefront';

const customerAccountHandlers = createCustomerAccountServerHandlers({
customerSession,
defaultPostLoginRedirectPathname: '/account',
postLogoutRedirectUri: '/',
});

export async function handleRequest(
request: Request,
next: (request: Request) => Promise<Response>,
) {
const storefrontClient = createRequestStorefrontClient(request);
const {requestContext} = storefrontClient;
const sessionManager = await createCustomerSessionManager(request);

const shopifyResponse = handleShopifyRoutes({
request,
requestContext,
sessionManager,
storefrontClient,
routeTemplates,
handlers: [cartHandlers, customerAccountHandlers],
});
if (shopifyResponse) return shopifyResponse;

// Make the request context and session manager available to your
// page data functions, then run your router.
let response = await next(request);

// Headers may be immutable, so copy the response before you change them.
response = new Response(response.body, response);
const sessionHeaders = new Headers(
(await sessionManager.commit?.()) ?? undefined,
);
for (const [name, value] of sessionHeaders) {
response.headers.append(name, value);
}

requestContext.applyResponseHeaders(response.headers);
return response;
}

Call this from the request handler that runs before your router, and pass the request context and session manager to your page data through request-scoped context. For React Router and Next.js code, refer to Framework wiring.

Committing the session after your router runs saves any tokens that a page refreshed.

In Next.js, proxy.ts returns before the page renders, so it can't commit tokens that a page refreshes. Pass requestContext.getForwardedRequestHeaders() to the rendered request instead of calling next(). Pages can only read the session, as described in Step 5.

The handlers own GET /account/login, GET /account/authorize, GET /account/refresh, and POST /account/logout. They sanitize return_to parameters, check that logout requests are same-origin POST requests, set cache-control: no-store, and commit session cookies on their own responses. If you replace a handler with custom logic, then keep all of these safeguards.

Use the same request context for Storefront API and Customer Account API work in a request. Customer Account API reads mark the response as personalized, so apply the request context's response headers at your final response boundary.


Anchor to Step 4: Sync cart buyer identityStep 4: Sync cart buyer identity

By default, the cart and the customer session are independent. To attach the logged-in customer to their cart, create the cart handlers with the same customer session, then pass the cart handlers to the account handlers:

Cart buyer identity sync

lib/handlers.ts
import {createCartServerHandlers} from '@shopify/hydrogen';
import {createCustomerAccountServerHandlers} from '@shopify/hydrogen/customer-account';

import {customerSession} from './customer-account';

export const cartHandlers = createCartServerHandlers({customerSession});

export const customerAccountHandlers = createCustomerAccountServerHandlers({
customerSession,
cartServerHandlers: cartHandlers,
});

Register both handler groups with handleShopifyRoutes(), as in Step 3. Both groups must share one customer session. TypeScript rejects cart handlers that you created without customerSession.

With this wiring, Hydrogen keeps the cart in sync with the session:

  • New carts include the logged-in customer's buyer identity, and checkout URLs for authenticated carts include logged_in=true.
  • Login and token refresh attach the customer to the cart in the request's cart cookie.
  • Logout, or a refresh that Shopify rejects, detaches the customer from the cart.

Sync never blocks a login or logout redirect. If detaching fails, the handler expires the cart cookie, so a shared device doesn't keep a cart that belongs to the previous customer.


Anchor to Step 5: Build the account pageStep 5: Build the account page

Hydrogen doesn't include account UI, so build at least a minimal /account page. Without one, customers can't reach the login flow.

Query the Customer Account API with gql() and createCustomerAccountClient() from @shopify/hydrogen/customer-account. Use them only in server code, and pass the access token with each query. The typed queries setup validates Customer Account API documents too.

Start with a data function that loads the account and returns a plain result that any UI can render. How it gets a usable access token depends on whether the page can set cookies:

React Router middleware commits the session after loaders run, so the loader can refresh the token with getOrRefreshAccessToken(). It reads the access token, refreshes it if needed, and saves the new token to the writable session manager.

Load the account

lib/account.ts
import type {ShopifyRequestContext} from '@shopify/hydrogen';
import {
gql,
type WritableCustomerSessionManager,
} from '@shopify/hydrogen/customer-account';

import {
createRequestCustomerAccountClient,
customerSession,
} from './customer-account';

const CUSTOMER_QUERY = gql(`
query CurrentCustomer {
customer {
firstName
lastName
emailAddress {
emailAddress
}
}
}
`);

export async function loadAccount(
requestContext: ShopifyRequestContext,
sessionManager: WritableCustomerSessionManager,
) {
const accessToken = await customerSession.getOrRefreshAccessToken(
sessionManager,
requestContext,
);
if (!accessToken) return {status: 'signed-out'} as const;

const client = createRequestCustomerAccountClient(requestContext);
const {data, errors} = await client.graphql(CUSTOMER_QUERY, {accessToken});
if (errors || !data.customer) return {status: 'error'} as const;

return {status: 'signed-in', customer: data.customer} as const;
}

export type Account = Awaited<ReturnType<typeof loadAccount>>;

Call loadAccount() from your /account route's loader, with the request context and session manager from your router context. The root middleware commits any refreshed token after the loader runs, as in Step 3.

Then render the result:

Account page

lib/render-account.ts

import type {Account} from './account';

export function renderAccount(root: HTMLElement, account: Account) {
const main = document.createElement('main');

if (account.status === 'signed-out') {
main.innerHTML = `
<h1>Account</h1>
<a href="/account/login?return_to=/account">Log in</a>
`;
} else if (account.status === 'error') {
main.innerHTML = `<p role="alert">We couldn't load your account.</p>`;
} else {
const {customer} = account;
const heading = document.createElement('h1');
heading.textContent = `${customer.firstName ?? ''} ${customer.lastName ?? ''}`;
const email = document.createElement('p');
email.textContent = customer.emailAddress?.emailAddress ?? '';
const logout = document.createElement('form');
logout.method = 'post';
logout.action = '/account/logout';
logout.innerHTML = '<button type="submit">Log out</button>';
main.append(heading, email, logout);
}

root.replaceChildren(main);
}
import type {Account as AccountData} from '~/lib/account';

export function Account({account}: {account: AccountData}) {
if (account.status === 'signed-out') {
return (
<main>
<h1>Account</h1>
<a href="/account/login?return_to=/account">Log in</a>
</main>
);
}

if (account.status === 'error') {
return <p role="alert">We couldn't load your account.</p>;
}

const {customer} = account;
return (
<main>
<h1>
{customer.firstName} {customer.lastName}
</h1>
<p>{customer.emailAddress?.emailAddress}</p>
<form method="post" action="/account/logout">
<button type="submit">Log out</button>
</form>
</main>
);
}
<script setup lang="ts">
import type {Account} from '~/lib/account';

defineProps<{account: Account}>();
</script>

<template>
<main v-if="account.status === 'signed-out'">
<h1>Account</h1>
<a href="/account/login?return_to=/account">Log in</a>
</main>
<p v-else-if="account.status === 'error'" role="alert">
We couldn't load your account.
</p>
<main v-else>
<h1>{{ account.customer.firstName }} {{ account.customer.lastName }}</h1>
<p>{{ account.customer.emailAddress?.emailAddress }}</p>
<form method="post" action="/account/logout">
<button type="submit">Log out</button>
</form>
</main>
</template>

Follow these rules for account UI:

  • Use a plain <a> element for login links and a plain <form method="post"> for logout. The handlers return redirects to Shopify's hosted pages, which client-side navigation, like next/link or a React Router <Form>, can't follow.
  • For signed-in state in shared UI, like a header account link, call isLoggedIn(). It treats a refreshable session as logged in without refreshing tokens. Wrap the UI in <Suspense> so the page shell renders first.
  • Gate Customer Account API calls on an access token, as loadAccount() does, not on isLoggedIn(). getAccessToken() returns a token only when the token is usable right now.
  • If isLoggedIn() returns false, then show a login link instead of redirecting to /account/refresh.
  • Render only the profile fields you need. Never serialize tokens or session objects into loader data or HTML.

To add order history and other account data, refer to the Customer Account API reference.


Anchor to Step 6: Test sign-in locally with HTTPSStep 6: Test sign-in locally with HTTPS

Customer Account API login, logout, and callbacks require an HTTPS origin that isn't localhost, and that exactly matches the URLs registered in the Shopify admin. local.tryhydrogen.dev is a Shopify-owned domain that resolves to 127.0.0.1, so you can use it as a stable local hostname.

Add the localHttps() plugin from @shopify/hydrogen/vite to your Vite config, and enable it only for a dev:https script:

Local HTTPS server

vite.config.ts
import {reactRouter} from '@react-router/dev/vite';
import {localHttps} from '@shopify/hydrogen/vite';
import {defineConfig} from 'vite';

const httpsOptions = {
enabled: process.env.npm_lifecycle_event === 'dev:https',
};

export default defineConfig({
plugins: [localHttps(httpsOptions), reactRouter()],
server: {
allowedHosts: ['.tryhydrogen.dev'],
},
});

Add a dev:https script to package.json that runs the same command as dev, like vite dev. A plain npm run dev stays on HTTP.

Start the server, then open https://local.tryhydrogen.dev:5173:

Terminal

npm run dev:https

The plugin creates missing certificates when the server starts. It downloads a checksum-verified release of mkcert, installs a local certificate authority, and writes certificates to ~/.shopify/hydrogen/certs/. Installing the certificate authority might ask for your password.

To install certificates ahead of time, run the following command:

Terminal

npx hydrogen certs install

To remove Hydrogen's certificates, run npx hydrogen certs uninstall. The mkcert certificate authority stays trusted, because other projects might use it. To remove it from your system trust stores too, add --remove-ca.

The plugin doesn't create certificates when the CI environment variable is set, because installing a certificate authority needs an interactive prompt. If a CI job needs local HTTPS, then run npx hydrogen certs install in that job.

When the server starts, the plugin also uses Shopify CLI to register your local URLs in the Customer Account API settings. This requires Shopify CLI with @shopify/cli-hydrogen 13.0.4 or higher. If your project isn't linked to a storefront, then Shopify CLI starts the linking flow first. If the plugin can't register the URLs, then add them by hand, as described in Register local URLs.

To register your local URLs by hand, open your storefront's Customer Account API settings in the Headless or Hydrogen sales channel and enter the following values:

SettingValue
Callback URIhttps://local.tryhydrogen.dev:5173/account/authorize
JavaScript originhttps://local.tryhydrogen.dev
Logout URIhttps://local.tryhydrogen.dev:5173

The JavaScript origin has no port, because Shopify rejects JavaScript origins that include one.



Was this page helpful?