From vercel-shop
Adds customer authentication to Shopify shop templates using better-auth with OIDC. Includes login flow, profile, orders, addresses pages, and nav integration.
npx claudepluginhub vercel/shop --plugin vercel-shopThis skill uses the workspace's default tool permissions.
Add customer authentication using [better-auth](https://www.better-auth.com/) with Shopify Customer Account API OIDC. This enables customer login, profile management, order history, and address book.
Installs and configures Shopify app authentication with OAuth, session tokens, and @shopify/shopify-api SDK for API access in Node.js apps.
Builds custom functionality installable at defined points on Shopify customer account Order index, Order status, and Profile pages using UI extensions and Shopify CLI scaffolding.
Sets up Shopify CLI auth and Admin API access token for a store: install CLI, login, create custom app with scopes, store token securely, verify with GraphQL. For store connections or auth issues.
Share bugs, ideas, or general feedback.
Add customer authentication using better-auth with Shopify Customer Account API OIDC. This enables customer login, profile management, order history, and address book.
AUTH_SECRET value for session signing (generate with openssl rand -base64 32)| Variable | Description |
|---|---|
AUTH_SECRET | Secret for signing sessions (also known as BETTER_AUTH_SECRET) |
SHOPIFY_CUSTOMER_CLIENT_ID | Shopify Customer Account API client ID |
SHOPIFY_CUSTOMER_CLIENT_SECRET | Shopify Customer Account API client secret |
BETTER_AUTH_BASE_URL | App base URL (e.g. http://localhost:3000 for dev) |
SHOPIFY_STORE_DOMAIN | Already set — used for OIDC discovery |
pnpm add better-auth
next.config.tsAdd better-auth to serverExternalPackages:
const nextConfig: NextConfig = {
// ... existing config
serverExternalPackages: ["better-auth"],
};
turbo.jsonAdd auth env vars to globalEnv:
{
"globalEnv": [
"BETTER_AUTH_SECRET",
"SHOPIFY_CUSTOMER_ACCOUNT_URL",
"SHOPIFY_CUSTOMER_CLIENT_ID",
"SHOPIFY_CUSTOMER_CLIENT_SECRET"
]
}
lib/auth/auth.tsCore better-auth configuration with Shopify OIDC:
import { betterAuth } from "better-auth/minimal";
import { genericOAuth } from "better-auth/plugins";
const SHOPIFY_STORE_DOMAIN = process.env.SHOPIFY_STORE_DOMAIN;
if (!SHOPIFY_STORE_DOMAIN) {
console.warn("[better-auth] SHOPIFY_STORE_DOMAIN not set - auth will not work");
}
const SHOPIFY_OIDC_SCOPES = ["openid", "email", "customer-account-api:full"];
function decodeIdTokenPayload(idToken: string): {
sub: string;
email: string;
email_verified?: boolean;
given_name?: string;
family_name?: string;
} {
const parts = idToken.split(".");
if (parts.length !== 3) {
throw new Error("Invalid ID token format");
}
const payload = parts[1];
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
const decoded = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(decoded);
}
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_BASE_URL || process.env.NEXT_PUBLIC_BASE_URL,
secret: process.env.AUTH_SECRET,
session: {
expiresIn: 7 * 24 * 60 * 60,
updateAge: 24 * 60 * 60,
cookieCache: {
enabled: true,
maxAge: 7 * 24 * 60 * 60,
},
},
account: {
storeStateStrategy: "cookie",
storeAccountCookie: true,
},
plugins: [
genericOAuth({
config: [
{
providerId: "shopify",
clientId: process.env.SHOPIFY_CUSTOMER_CLIENT_ID ?? "",
clientSecret: process.env.SHOPIFY_CUSTOMER_CLIENT_SECRET ?? "",
discoveryUrl: SHOPIFY_STORE_DOMAIN
? `https://${SHOPIFY_STORE_DOMAIN}/.well-known/openid-configuration`
: undefined,
scopes: SHOPIFY_OIDC_SCOPES,
pkce: true,
accessType: "offline",
getUserInfo: async (tokens) => {
const idToken = tokens.idToken;
if (!idToken) {
throw new Error("No ID token received from Shopify");
}
const decoded = decodeIdTokenPayload(idToken);
const nameParts = [decoded.given_name, decoded.family_name].filter(Boolean);
let name = nameParts.join(" ");
if (!name) {
name = decoded.email?.split("@")[0] || "Customer";
}
return {
id: decoded.sub,
email: decoded.email,
emailVerified: decoded.email_verified ?? false,
name,
image: undefined,
};
},
mapProfileToUser: (profile) => {
return {
id: profile.id,
email: profile.email,
name: profile.name,
image: profile.image,
emailVerified: profile.emailVerified,
};
},
},
],
}),
],
basePath: "/api/auth",
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") || [],
});
export type Auth = typeof auth;
lib/auth/server.tsServer-side session helpers with React cache for per-request memoization:
import { auth } from "./auth";
import { cache } from "react";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export { auth };
export interface CustomerSession {
customerId: string;
email: string;
firstName?: string;
lastName?: string;
}
export interface FullSession extends CustomerSession {
accessToken: string;
}
const getAuthSession = cache(async () => {
const reqHeaders = await headers();
return auth.api.getSession({ headers: reqHeaders });
});
function mapCustomerSession(
session: Awaited<ReturnType<typeof getAuthSession>>,
): CustomerSession | null {
if (!session?.user) return null;
const [firstName, ...lastParts] = (session.user.name || "").split(" ");
return {
customerId: session.user.id,
email: session.user.email,
firstName: firstName || undefined,
lastName: lastParts.join(" ") || undefined,
};
}
const getAccessToken = cache(async (): Promise<string> => {
const session = await getAuthSession();
if (!session?.user) return "";
const reqHeaders = await headers();
let accessToken = "";
try {
const tokenResponse = await auth.api.getAccessToken({
headers: reqHeaders,
body: { providerId: "shopify" },
});
accessToken = tokenResponse?.accessToken || "";
} catch (error) {
console.error("Failed to get access token:", error);
}
return accessToken;
});
export const getCustomerSession = cache(async (): Promise<CustomerSession | null> => {
const session = await getAuthSession();
return mapCustomerSession(session);
});
export const getSession = cache(async (): Promise<FullSession | null> => {
const session = await getCustomerSession();
if (!session) return null;
return {
...session,
accessToken: await getAccessToken(),
};
});
export async function requireCustomerSession(): Promise<CustomerSession> {
const session = await getCustomerSession();
if (!session) redirect("/login");
return session;
}
export async function requireSession(): Promise<FullSession> {
const session = await getSession();
if (!session) redirect("/login");
return session;
}
lib/auth/client.tsClient-side auth hooks and actions:
"use client";
import { genericOAuthClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import type { CustomerSession } from "./server";
export const authClient = createAuthClient({
plugins: [genericOAuthClient()],
});
export interface SessionState {
loading: boolean;
authenticated: boolean;
customer: CustomerSession | null;
}
export function useSession(): SessionState {
const { data, isPending } = authClient.useSession();
if (isPending) {
return { loading: true, authenticated: false, customer: null };
}
if (!data?.user) {
return { loading: false, authenticated: false, customer: null };
}
const [firstName, ...lastParts] = (data.user.name || "").split(" ");
return {
loading: false,
authenticated: true,
customer: {
customerId: data.user.id,
email: data.user.email,
firstName: firstName || undefined,
lastName: lastParts.join(" ") || undefined,
},
};
}
export function signIn(callbackURL = "/account"): void {
authClient.signIn.oauth2({ providerId: "shopify", callbackURL });
}
export async function signOut(): Promise<void> {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
window.location.href = "/";
},
},
});
}
app/api/auth/[...all]/route.tsimport { auth } from "@/lib/auth/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);
{YOUR_DOMAIN}/api/auth/callback/shopifySHOPIFY_STORE_DOMAINgetSession() and requireSession() are server-onlyrequireSession() before any customer API operationshopify-ai-toolkit or vercel-shop:fetch-shopify-schemahttpOnly and secure flags automatically via better-auth{
"questions": [
{
"question": "Auth plumbing is set up. Would you also like me to create the customer-facing UI?",
"header": "Auth UI",
"multiSelect": false,
"options": [
{
"label": "Yes, create the full UI",
"description": "Login page, account pages (profile, orders, addresses), nav account dropdown, authenticated checkout, and chat context. Includes all translation keys."
},
{
"label": "Skip UI for now",
"description": "Stop here. You can build the UI yourself or run this skill again later."
}
]
}
]
}
If the user chooses "Skip UI for now", stop here — Part 1 is complete and functional for programmatic use (server actions, API routes, session checks).
If the user chooses "Yes, create the full UI", proceed with Part 2.
app/login/layout.tsx:
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("seo");
return {
title: t("loginTitle"),
robots: { index: false, follow: false },
};
}
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return children;
}
app/login/page.tsx:
"use client";
import { useEffect } from "react";
import { signIn } from "@/lib/auth/client";
import { useTranslations } from "next-intl";
export default function LoginPage() {
const t = useTranslations("common");
useEffect(() => {
signIn("/account");
}, []);
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">{t("loginRedirecting")}</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("loginNotRedirected")}{" "}
<button type="button" onClick={() => signIn("/account")} className="underline">
{t("loginClickHere")}
</button>
</p>
</div>
</div>
);
}
The login page uses robots: { index: false, follow: false } to prevent indexing.
Create lib/shopify/types/customer.ts with domain types for Customer, Address, Order, Fulfillment, and related types.
Create lib/shopify/operations/customer.ts with:
discoverCustomerApiEndpoint() — auto-discovers GraphQL endpoint from .well-known/customer-account-apicustomerApiFetch() — GraphQL client with Bearer token authgetCustomer(accessToken) — profile datagetOrders(accessToken, options) — paginated ordersgetOrder(accessToken, orderId) — single order detailgetAddresses(accessToken) — address bookupdateCustomer(accessToken, input) — profile mutationcreateAddress(accessToken, address) — add addressupdateAddress(accessToken, addressId, address) — edit addressdeleteAddress(accessToken, addressId) — remove addresssetDefaultAddress(accessToken, addressId) — set defaultValidate field names with shopify-ai-toolkit or vercel-shop:fetch-shopify-schema. All operations use the Customer Account API (separate from Storefront API) with OAuth Bearer tokens.
Create the account section with this structure:
app/account/
layout.tsx — Responsive layout with sidebar (desktop) and tabs (mobile)
page.tsx — Redirect to /account/profile
error.tsx — Error boundary
profile/page.tsx — Profile display with inline edit
orders/page.tsx — Order list with status filters
orders/[id]/page.tsx — Order detail with fulfillment tracking
addresses/page.tsx — Address book CRUD
components/account/
actions.ts — Server action for profile update
sidebar.tsx — Navigation sidebar with profile/orders/addresses links
sidebar-client.tsx — Active state detection for sidebar items
mobile-tabs.tsx — Mobile tab navigation
mobile-tabs-client.tsx — Client-side mobile tab state
page-header.tsx — Breadcrumb + title layout
profile-section.tsx — Profile UI primitives
profile-section-composed.tsx — Async composed profile section
profile-edit-form.tsx — Sheet-based profile edit form
profile-edit-inline.tsx — Inline profile edit form
profile-page-skeleton.tsx — Loading skeleton
client.tsx — ProfileEditToggle client component
components/addresses/
actions.ts — Server actions for address CRUD
address-form.tsx — Address form with country select
address-card.tsx — Address card display
All account pages must call requireSession() or requireCustomerSession() before rendering. The layout uses getTranslations("account") for i18n.
Create components/layout/nav/account.tsx — a server component that renders an icon-only account link matching the spartan style of the cart icon:
import { getCustomerSession } from "@/lib/auth/server";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { UserRoundIcon, UserRoundCheckIcon } from "lucide-react";
export async function NavAccount() {
const [session, t] = await Promise.all([getCustomerSession(), getTranslations("nav")]);
if (!session) {
return (
<Link
href="/login"
className="flex items-center justify-center text-foreground hover:text-foreground/80 transition-colors"
>
<UserRoundIcon className="size-5" />
<span className="sr-only">{t("signIn")}</span>
</Link>
);
}
return (
<Link
href="/account"
className="flex items-center justify-center text-foreground hover:text-foreground/80 transition-colors"
>
<UserRoundCheckIcon className="size-5" />
<span className="sr-only">{t("account")}</span>
</Link>
);
}
export function NavAccountFallback() {
return (
<div className="flex items-center justify-center text-foreground">
<UserRoundIcon className="size-5" />
</div>
);
}
Then add to components/layout/nav/index.tsx:
import { NavAccount, NavAccountFallback } from "./account";
// ... in the nav bar, inside the actions div:
<Suspense fallback={<NavAccountFallback />}>
<NavAccount />
</Suspense>;
In lib/cart/action.ts, import getSession and update:
buyNowAction: Run addToCart and getSession in parallel. If authenticated, call linkCartToCustomer(session.accessToken) before returning checkout URL.prepareCheckoutAction: Check session, if authenticated call linkCartToCustomer(session.accessToken), fall back to plain cart checkout URL.In app/api/chat/route.ts, add a resolveUser function:
import { getSession } from "@/lib/auth/server";
async function resolveUser(locale: Locale): Promise<User> {
try {
const session = await getSession();
if (session?.accessToken) {
return {
type: "user",
locale,
id: session.customerId,
email: session.email,
name: [session.firstName, session.lastName].filter(Boolean).join(" "),
accessToken: session.accessToken,
};
}
} catch {
// Fall through to guest
}
return { type: "guest", locale };
}
Update lib/agent/context.ts to include the authenticated user variant in the User type:
export type User =
| { type: "guest"; locale: Locale }
| {
type: "user";
locale: Locale;
id: string;
email: string;
name: string;
accessToken: string;
};
Add to ALL locale files under nav:
signIn, signOut, profile, ordersAdd seo.loginTitle.
Add common.loginRedirecting, common.loginNotRedirected, common.loginClickHere.
Add full account, orders, and address sections. See the base en.json translations for the complete key set.