Skip to main content

21 posts tagged with "nextjs"

View All Tags

Google Social Login in Headless Shopify Using Better-Auth and Next.js

· 11 min read

Social login has become a standard feature in modern e-commerce applications, offering users a convenient way to sign in without creating new credentials. In this article, we'll explore how to implement Google Social Login in a headless Shopify storefront using Better-Auth and Shopify Multipass.

Note: The live demo site uses a Shopify Partner development store which doesn't support Multipass (Multipass is only available on Shopify Plus plans). However, the complete working implementation of Google social login is available in the GitHub repository. You can refer to the codebase for a fully functional example.

What is Shopify Multipass?

Shopify Multipass is a feature available on Shopify Plus that allows you to authenticate customers from an external identity provider. When a user signs in through a third-party service (like Google), you generate a Multipass token that Shopify uses to create or sign in the customer automatically.

How Multipass Works

  1. User authenticates with Google (or another OAuth provider)
  2. Your application receives the user's email from Google
  3. You generate a Multipass token using the user's email
  4. Exchange the Multipass token with Shopify for a customer access token
  5. Store the customer access token in a secure cookie for subsequent requests

Prerequisites

Before implementing Google social login, ensure you have:

  1. Better-Auth Setup: Basic Better-Auth configuration in your Next.js application
  2. Shopify Plus Account: Multipass is only available on Shopify Plus plans
  3. Multipass Enabled: Enable Multipass in your Shopify admin settings and obtain the secret key
  4. Google OAuth Credentials: Google Cloud project with OAuth 2.0 credentials configured

If you haven't set up basic Better-Auth authentication yet, check out the article on implementing authentication in headless Shopify.

Step 1: Configure Environment Variables

Add the required environment variables for Google OAuth and Shopify Multipass:

# .env
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=your_better_auth_secret

# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret

# Shopify Multipass
SHOPIFY_MULTIPASS_SECRET=your_shopify_multipass_secret

Getting Google OAuth Credentials

  1. Go to Google Cloud Console
  2. Create a new project or select an existing one
  3. Navigate to APIs & Services > Credentials
  4. Click Create Credentials > OAuth client ID
  5. Configure the OAuth consent screen
  6. Set the application type to Web application
  7. Add authorized redirect URIs:
    • http://localhost:3000/api/auth/callback/google (for development)
    • https://yourdomain.com/api/auth/callback/google (for production)

Getting Shopify Multipass Secret

  1. Log in to your Shopify admin
  2. Navigate to Settings > Customer accounts
  3. Enable Multipass (requires Shopify Plus)
  4. Copy the Multipass secret key

Step 2: Configure Better-Auth with Google Provider

Update your Better-Auth configuration to include the Google social provider:

// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { shopifyAuthPlugin } from "@/lib/shopify-auth-plugin";

export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL,
plugins: [nextCookies(), shopifyAuthPlugin()],
// 👇 Google OAuth configuration
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
},
},
// 👆 Add your Google credentials here
});

This configuration:

  • Enables the Google OAuth provider
  • Uses nextCookies() for cookie management in Next.js
  • Includes the custom shopifyAuthPlugin for Shopify-specific auth flows
Important Configuration

The socialProviders.google object is where you configure Google OAuth. Make sure to add your Google Client ID and Client Secret from the Google Cloud Console.

Step 3: Create the Multipass Token Generator

Create a utility function to generate Multipass tokens:

// src/lib/shopify/multipass.ts
import Multipassify from "multipassify";

export const SHOPIFY_MULTIPASS_SECRET = process.env.SHOPIFY_MULTIPASS_SECRET;

export function generateMultipassToken(email: string): string {
if (!SHOPIFY_MULTIPASS_SECRET) {
throw new Error("SHOPIFY_MULTIPASS_SECRET is not configured.");
}

const multipassify = new Multipassify(SHOPIFY_MULTIPASS_SECRET);

return multipassify.encode({ email });
}

Install the multipassify package:

pnpm add multipassify
# or
npm install multipassify

Step 4: Create the Multipass Integration

Create a GraphQL mutation to exchange the Multipass token for a Shopify customer access token:

# src/integrations/shopify/customer-access-token-create-with-multipass/
# customer-access-token-create-with-multipass.shopify.graphql

mutation customerAccessTokenCreateWithMultipass($multipassToken: String!) {
customerAccessTokenCreateWithMultipass(multipassToken: $multipassToken) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
message
field
}
}
}

Create the integration function:

// src/integrations/shopify/customer-access-token-create-with-multipass/index.ts
import {
CustomerAccessTokenCreateWithMultipassDocument,
CustomerAccessTokenCreateWithMultipassMutation,
CustomerAccessTokenCreateWithMultipassMutationVariables,
} from "@/generated/shopifySchemaTypes";
import createApolloClient from "@/integrations/shopify/shopify-apollo-client";

export const customerAccessTokenCreateWithMultipass = async (
multipassToken: string,
): Promise<CustomerAccessTokenCreateWithMultipassMutation | undefined> => {
try {
const client = createApolloClient();
const { data } = await client.mutate<
CustomerAccessTokenCreateWithMultipassMutation,
CustomerAccessTokenCreateWithMultipassMutationVariables
>({
mutation: CustomerAccessTokenCreateWithMultipassDocument,
variables: { multipassToken },
});

if (!data) {
throw new Error(
"No data returned from customerAccessTokenCreateWithMultipass mutation",
);
}

return data;
} catch (error) {
console.error(
"Error creating customer access token with multipass:",
error,
);
}
};

Step 5: Create the Multipass API Endpoint

Create a Next.js API route that handles the Multipass authentication flow:

// src/app/api/shopify/multipass/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";

import { customerAccessTokenCreateWithMultipass } from "@/integrations/shopify/customer-access-token-create-with-multipass";
import { generateMultipassToken } from "@/lib/shopify/multipass";

const requestSchema = z.object({
email: z.string(),
});

export async function POST(request: Request) {
const parsed = requestSchema.safeParse(
await request.json().catch(() => ({})),
);

if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request body." },
{ status: 400 },
);
}

const { email } = parsed.data;

let multipassToken: string;

try {
multipassToken = generateMultipassToken(email);
} catch (error) {
console.error("Error generating multipass token:", error);
return NextResponse.json(
{ error: "Multipass is not configured." },
{ status: 500 },
);
}

try {
const result = await customerAccessTokenCreateWithMultipass(multipassToken);
const payload = result?.customerAccessTokenCreateWithMultipass;
const userErrors = payload?.customerUserErrors ?? [];
const token = payload?.customerAccessToken?.accessToken;
const expiresAt = payload?.customerAccessToken?.expiresAt;

if (userErrors.length || !token) {
return NextResponse.json(
{ error: userErrors[0]?.message || "Unable to create access token." },
{ status: 401 },
);
}

const response = NextResponse.json({ accessToken: token, expiresAt });

response.cookies.set("shopifyCustomerAccessToken", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
expires: expiresAt ? new Date(expiresAt) : undefined,
});

return response;
} catch (error) {
console.error("Error creating access token with multipass:", error);
return NextResponse.json(
{ error: "Unable to create access token." },
{ status: 500 },
);
}
}

This endpoint:

  1. Receives the user's email from the client
  2. Generates a Multipass token
  3. Exchanges it for a Shopify customer access token
  4. Stores the token in an HTTP-only cookie
  5. Returns the token and expiration to the client

Step 6: Create the Login Page with Google Sign-In

Now create the login page that integrates Google OAuth:

// src/app/account/login/page.tsx
"use client";

import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { authClient } from "@/lib/auth-client";
import Cookies from "js-cookie";

export default function LoginPage() {
const { data: session } = authClient.useSession();
const [loading, setLoading] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const [processedSocialEmail, setProcessedSocialEmail] = useState<
string | null
>(null);
const [error, setError] = useState<string | null>(null);

const updateCartBuyerIdentity = useCallback(async () => {
const cartId = Cookies.get("cart_id");
if (!cartId) {
return;
}

await fetch("/api/shopify/cart-buyer-identity", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ cartId }),
});
}, []);

useEffect(() => {
const socialEmail = session?.user?.email;

if (!socialEmail || socialEmail === processedSocialEmail) {
return;
}

let cancelled = false;

async function authenticateSocialUserWithMultipass() {
setGoogleLoading(true);
setError(null);

try {
const response = await fetch("/api/shopify/multipass", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: socialEmail }),
});

const payload = (await response.json().catch(() => ({}))) as {
error?: string;
};

if (!response.ok) {
throw new Error(payload.error || "Unable to sign in with Google.");
}

if (cancelled) {
return;
}

setProcessedSocialEmail(socialEmail);
await updateCartBuyerIdentity();
window.location.href = "/";
} catch (err) {
if (cancelled) {
return;
}

setError(
err instanceof Error ? err.message : "Unable to sign in with Google.",
);
} finally {
if (!cancelled) {
setGoogleLoading(false);
}
}
}

authenticateSocialUserWithMultipass();

return () => {
cancelled = true;
};
}, [processedSocialEmail, session?.user?.email, updateCartBuyerIdentity]);

async function onGoogleSignIn() {
setError(null);
setGoogleLoading(true);

try {
const socialSignInResult = await authClient.signIn.social({
provider: "google",
callbackURL: "/account/login",
});

const socialSignInError = (
socialSignInResult as { error?: { message?: string } }
)?.error?.message;

if (socialSignInError) {
setError(socialSignInError || "Unable to start Google sign in.");
}
} catch {
setError("Unable to start Google sign in.");
} finally {
setGoogleLoading(false);
}
}

async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);

const form = e.currentTarget;
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
const password = (form.elements.namedItem("password") as HTMLInputElement)
.value;

try {
const shopifyAuth = await authClient.shopifySignIn({
email,
password,
});

const shopifyError = (shopifyAuth as { error?: { message?: string } })
?.error?.message;
if (shopifyError) {
setError(shopifyError || "Invalid email or password.");
return;
}

const shopifyData = (shopifyAuth as { data?: { ok?: boolean } })?.data;
if (!shopifyData?.ok) {
setError("Invalid email or password.");
return;
}

await updateCartBuyerIdentity();

window.location.href = "/";
} catch {
setError("Unable to sign in. Please try again.");
} finally {
setLoading(false);
}
}

return (
<div className="border-box px-5 py-8 lg:px-10 min-h-[60vh] flex items-center justify-center">
<div className="w-full max-w-md">
<h1 className="text-2xl font-semibold text-gray-900 text-center mb-8 ">
Login
</h1>
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<label htmlFor="email" className="text-gray-900">
Email
</label>
<input
type="email"
id="email"
name="email"
className="border border-gray-200 px-4 py-2 text-gray-900 focus:outline-none focus:border-gray-400"
placeholder="you@example.com"
required
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="password" className="text-gray-900">
Password
</label>
<input
type="password"
id="password"
name="password"
className="border border-gray-200 px-4 py-2 text-gray-900 focus:outline-none focus:border-gray-400"
placeholder="••••••••"
required
/>
</div>

{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}

<button
type="submit"
disabled={loading || googleLoading}
className="mt-4 bg-gray-900 text-white py-3 px-4 hover:bg-gray-800 transition-colors cursor-pointer uppercase disabled:opacity-60"
>
{loading ? "Signing In..." : "Sign In"}
</button>

<button
type="button"
onClick={onGoogleSignIn}
disabled={loading || googleLoading}
className="border border-gray-900 text-gray-900 py-3 px-4 hover:bg-gray-100 transition-colors cursor-pointer uppercase disabled:opacity-60"
>
{googleLoading
? "Signing In With Google..."
: "Sign In with Google"}
</button>
</form>
<div className="mt-6 flex flex-col items-center gap-4">
<Link
href="/account/forgot-password"
className="text-gray-600 hover:text-gray-900 font-light"
>
Forgot your password?
</Link>
<p className="text-gray-500 font-light">
Don&apos;t have an account?{" "}
<Link
href="/account/register"
className="text-gray-600 hover:text-gray-900 font-light"
>
Create one
</Link>
</p>
</div>
</div>
</div>
);
}

Key Implementation Details

  1. Session Monitoring: The useEffect hook watches for Better-Auth session changes after Google OAuth callback
  2. Multipass Authentication: When a Google email is detected, it triggers the Multipass flow
  3. Cart Association: After successful authentication, the cart is associated with the logged-in customer
  4. Error Handling: Comprehensive error handling for both OAuth and Multipass flows
  5. Duplicate Prevention: The processedSocialEmail state prevents duplicate Multipass calls

Step 7: Create a Session Provider (Optional)

Create a session provider to manage user state across your application:

// src/providers/session-provider.tsx
"use client";

import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { getCurrentUser } from "@/lib/shopify/getCurrentUser";

type SessionUser = Awaited<ReturnType<typeof getCurrentUser>>;

type SessionContextValue = {
user: SessionUser;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
};

const SessionContext = createContext<SessionContextValue | undefined>(
undefined,
);

export function SessionProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<SessionUser>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const refresh = useCallback(async () => {
try {
setLoading(true);
setError(null);
const currentUser = await getCurrentUser();
setUser(currentUser);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load user");
setUser(null);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
refresh();
}, [refresh]);

return (
<SessionContext.Provider value={{ user, loading, error, refresh }}>
{children}
</SessionContext.Provider>
);
}

export function useSession() {
const context = useContext(SessionContext);
if (!context) {
throw new Error("useSession must be used within a SessionProvider");
}
return context;
}

Create the getCurrentUser utility:

// src/lib/shopify/getCurrentUser.ts
export const getCurrentUser = async () => {
try {
const response = await fetch("/api/shopify/customer", {
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});

if (!response.ok) {
if (response.status === 401) {
return null;
}
return null;
}

const data = await response.json();
const customer = data?.customer ?? null;

if (!customer) {
return null;
}

const name = [customer.firstName, customer.lastName]
.filter(Boolean)
.join(" ")
.trim();

return {
...customer,
name: name || undefined,
};
} catch {
return null;
}
};

Understanding the Flow

Let's break down what happens when a user clicks "Sign In with Google":

Step 1: OAuth Initiation

authClient.signIn.social({
provider: "google",
callbackURL: "/account/login",
});
  • User is redirected to Google's OAuth consent screen
  • User grants permission
  • Google redirects back to /account/login with auth tokens

Step 2: Better-Auth Session Creation

  • Better-Auth processes the OAuth callback automatically
  • Creates a session with user information (email, name, etc.)
  • The authClient.useSession() hook detects the new session

Step 3: Multipass Token Generation

const multipassToken = generateMultipassToken(email);
  • Extract email from Better-Auth session
  • Generate encrypted Multipass token using Shopify secret

Step 4: Shopify Customer Access Token

customerAccessTokenCreateWithMultipass(multipassToken);
  • Send Multipass token to Shopify
  • Shopify validates and returns customer access token
  • If customer doesn't exist, Shopify creates one automatically
response.cookies.set("shopifyCustomerAccessToken", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
  • Store Shopify customer token in HTTP-only cookie
  • Token is automatically included in subsequent requests

Conclusion

Implementing Google social login in a headless Shopify storefront requires coordinating several technologies:

  1. Better-Auth: Handles OAuth flow and session management
  2. Shopify Multipass: Bridges external authentication with Shopify customers
  3. Next.js API Routes: Orchestrates the authentication flow
  4. Secure Cookies: Stores authentication tokens safely

This architecture provides a secure, user-friendly authentication experience while maintaining compatibility with Shopify's customer system. The approach can be extended to other OAuth providers (GitHub, Facebook, etc.) using the same Multipass integration pattern.

Additional Resources

Implementing Forgot Password and Reset Password in Headless Shopify

· 11 min read

Password recovery is an essential feature for any e-commerce application. When users forget their passwords, they need a secure and straightforward way to reset them. In this article, we'll implement a complete forgot password and reset password flow for a headless Shopify storefront using Better-Auth and Next.js.

Live Demo: https://headless-shopify-site.vercel.app/

Prerequisites

This article builds upon the authentication system covered in Authentication in Headless Shopify Using Better-Auth and Next.js. Make sure you have:

  1. ✅ Next.js application with Better-Auth configured
  2. ✅ Shopify Customer API integration
  3. ✅ Custom Shopify auth plugin implemented
  4. ✅ Sign-in and sign-up functionality working

Understanding Shopify's Password Recovery Flow

Shopify provides two mutations for password recovery:

  1. customerRecover: Sends a password reset email to the customer
  2. customerResetByUrl: Resets the password using the URL from the email

The flow works like this:

Password Reset Flow

Reset Password Email URL

This scenario is applicable only if your headless site domain is different from that given as primary domain in Shopify

By default, Shopify's password reset emails point to the Shopify-hosted store (your-store.myshopify.com). For a headless storefront, we need these emails to point to your custom domain instead.

We'll solve this by:

  1. Customizing Shopify's email templates (discussed later in this post)
  2. Implementing a reset password page on our site
  3. Handling the reset flow with Better-Auth

Step 1: Create the Forgot Password GraphQL Mutation

First, create the GraphQL mutation file for customerRecover:

# src/integrations/shopify/customer-recover/customer-recover.shopify.graphql
mutation customerRecover($email: String!) {
customerRecover(email: $email) {
customerUserErrors {
message
field
}
}
}

This mutation triggers Shopify to send a password reset email to the customer.

Step 2: Create the Integration Function

Create the TypeScript integration function:

// src/integrations/shopify/customer-recover/index.ts
import {
CustomerRecoverDocument,
CustomerRecoverMutation,
CustomerRecoverMutationVariables,
} from "@/generated/shopifySchemaTypes";
import createApolloClient from "@/integrations/shopify/shopify-apollo-client";

export const customerRecover = async (
email: string,
): Promise<CustomerRecoverMutation | undefined> => {
try {
const client = createApolloClient();
const { data } = await client.mutate<
CustomerRecoverMutation,
CustomerRecoverMutationVariables
>({
mutation: CustomerRecoverDocument,
variables: { email },
});

if (!data) {
throw new Error("No data returned from customerRecover mutation");
}

return data;
} catch (error) {
console.error("Error sending password reset email:", error);
}
};

Note: Run your GraphQL codegen after creating the mutation file:

npm run codegen

Step 3: Add Forgot Password to Better-Auth Plugin

Update your Shopify auth plugin to include the forgot password endpoint:

Add Types

// src/lib/shopify-auth-plugin.ts
export type ShopifyForgotPasswordInput = {
email: string;
};

Add Validation Schema

import * as z from "zod";

const forgotPasswordSchema = z.object({
email: z.email().min(1),
});

Create the Endpoint

import { customerRecover } from "@/integrations/shopify/customer-recover";

export const shopifyAuthPlugin = () => {
return {
id: "shopify-auth",
endpoints: {
// ... existing signIn and signUp endpoints

forgotPassword: createAuthEndpoint(
"/shopify-auth/forgot-password",
{
method: "POST",
body: forgotPasswordSchema,
},
async (ctx) => {
const { email } = ctx.body;

const result = await customerRecover(email);

if (!result) {
throw new APIError("BAD_REQUEST", {
message: "Unable to send password reset email.",
});
}

const payload = result.customerRecover;
const userErrors = payload?.customerUserErrors ?? [];

if (userErrors.length) {
throw new APIError("BAD_REQUEST", {
message:
userErrors[0]?.message ||
"Unable to send password reset email.",
});
}

return ctx.json({ ok: true });
},
),
},
} satisfies BetterAuthPlugin;
};

Step 4: Add Client-Side Action

Update your auth client plugin to expose the forgot password action:

// src/lib/shopify-auth-client.ts
import type {
ShopifyForgotPasswordInput,
} from "@/lib/shopify-auth-plugin";

export const shopifyAuthClientPlugin = () => {
return {
id: "shopify-auth",
$InferServerPlugin: {} as ReturnType<typeof shopifyAuthPlugin>,
getActions: ($fetch) => {
return {
// ... existing shopifySignIn and shopifySignUp

shopifyForgotPassword: async (
data: ShopifyForgotPasswordInput,
fetchOptions?: BetterFetchOption,
) => {
return $fetch("/shopify-auth/forgot-password", {
method: "POST",
body: data,
...fetchOptions,
});
},
};
},
} satisfies BetterAuthClientPlugin;
};

Step 5: Create the Forgot Password Page

Create a user-friendly forgot password page:

// src/app/account/forgot-password/page.tsx
"use client";

import React, { useState } from "react";
import Link from "next/link";
import { authClient } from "@/lib/auth-client";

export default function ForgotPasswordPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);

async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setSuccess(false);
setLoading(true);

const form = e.currentTarget;
const email = (form.elements.namedItem("email") as HTMLInputElement).value;

try {
const result = await authClient.shopifyForgotPassword({ email });

const shopifyError = (result as { error?: { message?: string } })?.error
?.message;
if (shopifyError) {
setError(shopifyError || "Unable to send password reset email.");
return;
}

const shopifyData = (result as { data?: { ok?: boolean } })?.data;
if (!shopifyData?.ok) {
setError("Unable to send password reset email.");
return;
}

setSuccess(true);
} catch {
setError("Unable to send password reset email. Please try again.");
} finally {
setLoading(false);
}
}

return (
<div className="border-box px-5 py-8 lg:px-10 min-h-[60vh] flex items-center justify-center">
<div className="w-full max-w-md">
<h1 className="text-2xl font-semibold text-gray-900 text-center mb-4">
Forgot Password
</h1>
<p className="text-gray-500 text-center mb-8 font-light">
Enter your email address and we'll send you a link to reset your
password.
</p>

{success ? (
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded">
<p className="text-sm">
If an account exists with this email, you will receive a password
reset link shortly.
</p>
</div>
) : (
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<label htmlFor="email" className="text-gray-900">
Email
</label>
<input
type="email"
id="email"
name="email"
className="border border-gray-200 px-4 py-2 text-gray-900 focus:outline-none focus:border-gray-400"
placeholder="you@example.com"
required
/>
</div>

{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}

<button
type="submit"
disabled={loading}
className="mt-4 bg-gray-900 text-white py-3 px-4 hover:bg-gray-800 transition-colors cursor-pointer uppercase disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Sending..." : "Send Reset Link"}
</button>
</form>
)}

<div className="mt-6 flex flex-col items-center gap-4">
<Link
href="/account/login"
className="text-gray-600 hover:text-gray-900 font-light"
>
Back to Login
</Link>
</div>
</div>
</div>
);
}

Step 6: Customize Shopify Email Template

This is the crucial step that redirects users to your site instead of Shopify's hosted store.

Access Email Templates

  1. Go to your Shopify Admin
  2. Navigate to SettingsNotifications
  3. Find Customer account password reset
  4. Click to edit the template

Update the Reset URL

Find the line containing the reset password link (typically):

{{ customer.reset_password_url }}

Replace it with:

https://your-vercel-domain.com/account/reset-password?url={{ customer.reset_password_url | url_encode }}

Example:

<!-- Before -->
<a href="{{ customer.reset_password_url }}">Reset your password</a>

<!-- After -->
<a href="https://headless-shopify-site.vercel.app/account/reset-password?url={{ customer.reset_password_url | url_encode }}">Reset your password</a>

Replace headless-shopify-site.vercel.app with your actual domain.

Step 7: Implement Password Reset Functionality

Now implement the actual password reset page that users land on after clicking the email link.

Create the GraphQL Mutation

# src/integrations/shopify/customer-reset-by-url/customer-reset-by-url.shopify.graphql
mutation customerResetByUrl($password: String!, $resetUrl: URL!) {
customerResetByUrl(password: $password, resetUrl: $resetUrl) {
customer {
id
email
firstName
lastName
}
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
message
field
}
}
}

Create the Integration Function

// src/integrations/shopify/customer-reset-by-url/index.ts
import {
CustomerResetByUrlDocument,
CustomerResetByUrlMutation,
CustomerResetByUrlMutationVariables,
} from "@/generated/shopifySchemaTypes";
import createApolloClient from "@/integrations/shopify/shopify-apollo-client";

export const customerResetByUrl = async (
password: string,
resetUrl: string,
): Promise<CustomerResetByUrlMutation | undefined> => {
try {
const client = createApolloClient();
const { data } = await client.mutate<
CustomerResetByUrlMutation,
CustomerResetByUrlMutationVariables
>({
mutation: CustomerResetByUrlDocument,
variables: { password, resetUrl },
});

if (!data) {
throw new Error("No data returned from customerResetByUrl mutation");
}

return data;
} catch (error) {
console.error("Error resetting password:", error);
}
};

Run codegen again:

npm run codegen

Step 8: Add Reset Password to Auth Plugin

Add Types

// src/lib/shopify-auth-plugin.ts
export type ShopifyResetPasswordInput = {
password: string;
resetUrl: string;
};

Add Validation Schema

const resetPasswordSchema = z.object({
password: z.string().min(5),
resetUrl: z.string().url(),
});

Create the Endpoint

import { customerResetByUrl } from "@/integrations/shopify/customer-reset-by-url";

export const shopifyAuthPlugin = () => {
return {
id: "shopify-auth",
endpoints: {
// ... existing endpoints

resetPassword: createAuthEndpoint(
"/shopify-auth/reset-password",
{
method: "POST",
body: resetPasswordSchema,
},
async (ctx) => {
const { password, resetUrl } = ctx.body;

const result = await customerResetByUrl(password, resetUrl);

if (!result) {
throw new APIError("BAD_REQUEST", {
message: "Unable to reset password.",
});
}

const payload = result.customerResetByUrl;
const userErrors = payload?.customerUserErrors ?? [];
const token = payload?.customerAccessToken?.accessToken;
const expiresAt = payload?.customerAccessToken?.expiresAt;

if (userErrors.length || !token) {
throw new APIError("BAD_REQUEST", {
message: userErrors[0]?.message || "Unable to reset password.",
});
}

// Auto sign-in after successful password reset
ctx.setCookie(SHOPIFY_CUSTOMER_TOKEN_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
expires: expiresAt ? new Date(expiresAt) : undefined,
});

return ctx.json({ ok: true });
},
),
},
} satisfies BetterAuthPlugin;
};

Step 9: Add Client-Side Reset Action

// src/lib/shopify-auth-client.ts
import type {
ShopifyResetPasswordInput,
} from "@/lib/shopify-auth-plugin";

export const shopifyAuthClientPlugin = () => {
return {
id: "shopify-auth",
getActions: ($fetch) => {
return {
// ... existing actions

shopifyResetPassword: async (
data: ShopifyResetPasswordInput,
fetchOptions?: BetterFetchOption,
) => {
return $fetch("/shopify-auth/reset-password", {
method: "POST",
body: data,
...fetchOptions,
});
},
};
},
} satisfies BetterAuthClientPlugin;
};

Step 10: Create the Reset Password Page

Create a comprehensive reset password page with validation:

// src/app/account/reset-password/page.tsx
"use client";

import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { authClient } from "@/lib/auth-client";

export default function ResetPasswordPage() {
const searchParams = useSearchParams();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [resetUrl, setResetUrl] = useState<string | null>(null);

useEffect(() => {
// Extract the full reset URL from query params
const url = searchParams.get("url");
if (url) {
setResetUrl(decodeURIComponent(url));
} else {
setError("Invalid or missing reset link.");
}
}, [searchParams]);

async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);

const form = e.currentTarget;
const password = (form.elements.namedItem("password") as HTMLInputElement)
.value;
const confirmPassword = (
form.elements.namedItem("confirmPassword") as HTMLInputElement
).value;

// Validate passwords match
if (password !== confirmPassword) {
setError("Passwords do not match.");
setLoading(false);
return;
}

// Validate password length
if (password.length < 5) {
setError("Password must be at least 5 characters.");
setLoading(false);
return;
}

if (!resetUrl) {
setError("Invalid reset link.");
setLoading(false);
return;
}

try {
const result = await authClient.shopifyResetPassword({
password,
resetUrl,
});

const shopifyError = (result as { error?: { message?: string } })?.error
?.message;
if (shopifyError) {
setError(shopifyError || "Unable to reset password.");
return;
}

const shopifyData = (result as { data?: { ok?: boolean } })?.data;
if (!shopifyData?.ok) {
setError("Unable to reset password.");
return;
}

setSuccess(true);

// Redirect to home after successful reset and auto sign-in
setTimeout(() => {
window.location.href = "/";
}, 2000);
} catch {
setError("Unable to reset password. Please try again.");
} finally {
setLoading(false);
}
}

if (!resetUrl && !error) {
return (
<div className="border-box px-5 py-8 lg:px-10 min-h-[60vh] flex items-center justify-center">
<div className="w-full max-w-md text-center">
<p className="text-gray-500">Loading...</p>
</div>
</div>
);
}

return (
<div className="border-box px-5 py-8 lg:px-10 min-h-[60vh] flex items-center justify-center">
<div className="w-full max-w-md">
<h1 className="text-2xl font-semibold text-gray-900 text-center mb-4">
Reset Password
</h1>
<p className="text-gray-500 text-center mb-8 font-light">
Enter your new password below.
</p>

{success ? (
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded">
<p className="text-sm">
Your password has been reset successfully! Redirecting...
</p>
</div>
) : (
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<label htmlFor="password" className="text-gray-900">
New Password
</label>
<input
type="password"
id="password"
name="password"
className="border border-gray-200 px-4 py-2 text-gray-900 focus:outline-none focus:border-gray-400"
placeholder="••••••••"
minLength={5}
required
disabled={!resetUrl}
/>
</div>

<div className="flex flex-col gap-2">
<label htmlFor="confirmPassword" className="text-gray-900">
Confirm New Password
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
className="border border-gray-200 px-4 py-2 text-gray-900 focus:outline-none focus:border-gray-400"
placeholder="••••••••"
minLength={5}
required
disabled={!resetUrl}
/>
</div>

{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}

<button
type="submit"
disabled={loading || !resetUrl}
className="mt-4 bg-gray-900 text-white py-3 px-4 hover:bg-gray-800 transition-colors cursor-pointer uppercase disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Resetting..." : "Reset Password"}
</button>
</form>
)}

<div className="mt-6 flex flex-col items-center gap-4">
<Link
href="/account/login"
className="text-gray-600 hover:text-gray-900 font-light"
>
Back to Login
</Link>
</div>
</div>
</div>
);
}

Project Structure

Your final project structure should look like this:

src/
├── app/
│ └── account/
│ ├── forgot-password/
│ │ └── page.tsx # Forgot password form
│ └── reset-password/
│ └── page.tsx # Reset password form
├── integrations/
│ └── shopify/
│ ├── customer-recover/
│ │ ├── customer-recover.shopify.graphql
│ │ └── index.ts
│ └── customer-reset-by-url/
│ ├── customer-reset-by-url.shopify.graphql
│ └── index.ts
└── lib/
├── auth-client.ts # Better-Auth client
├── shopify-auth-plugin.ts # Server plugin with endpoints
└── shopify-auth-client.ts # Client plugin with actions

Conclusion

You now have a complete, secure password recovery system for your headless Shopify storefront!

Resources

Questions?

If you have questions or run into issues implementing this flow, feel free to:

Happy coding! 🚀

Authentication in Headless Shopify Using Better-Auth and Next.js

· 13 min read

Authentication is a critical component of any e-commerce application. When building a headless Shopify storefront with Next.js, you need a robust authentication solution that integrates seamlessly with Shopify's customer API. In this article, we'll explore how to implement authentication using Better-Auth, a modern authentication library for Next.js applications.

Live Demo: https://headless-shopify-site.vercel.app/

What is Better-Auth?

Better-Auth is a flexible, type-safe authentication library for Next.js that provides a plugin-based architecture. It offers:

  • 🔐 Type-safe authentication flows
  • 🔌 Plugin-based extensibility
  • 🍪 Secure cookie-based session management
  • 📦 Built-in Next.js integration
  • 🎯 Developer-friendly API

Why Better-Auth for Shopify?

When building a headless Shopify storefront, you need to integrate with Shopify's Customer API for authentication. Better-Auth's plugin system makes it perfect for this use case because:

  1. Custom Plugin Support: Create a custom Shopify authentication plugin that wraps Shopify's Customer API
  2. Next.js Integration: Built-in support for Next.js App Router and API routes
  3. Secure Cookie Management: Handles access token storage securely with HTTP-only cookies
  4. Type Safety: Full TypeScript support for authentication flows

Architecture Overview

Our authentication implementation consists of several key components:

Authentication Architecture

Prerequisites

Before implementing authentication with Better-Auth, ensure you have:

  1. Next.js Setup: A working Next.js application (App Router or Pages Router)
  2. Shopify Integration: Logic to execute Shopify customer mutations (customerAccessTokenCreate, customerCreate, etc.) via Shopify's Storefront API

This article focuses specifically on integrating Better-Auth with Shopify and does not cover Next.js setup or Shopify API integration basics.

Step 1: Install Better-Auth

First, install the required dependencies:

pnpm add better-auth
# or
npm install better-auth
# or
yarn add better-auth

Step 2: Configure Environment Variables

Add the required environment variable for Better-Auth:

# .env
BETTER_AUTH_SECRET=your_secret_key_here

Generate a secure secret key using:

openssl rand -base64 32

Your project might have other environment variables like Shopify Graphql endpoint, storefront access token. I am not writing everything here to stick to the auth logic.

Step 3: Create the Shopify Auth Plugin (Server)

The Shopify auth plugin is the heart of our authentication system. It creates custom endpoints that integrate with Shopify's Customer API.

Define Input Types

First, define TypeScript types for sign-in and sign-up inputs:

// src/lib/shopify-auth-plugin.ts
export type ShopifySignInInput = {
email: string;
password: string;
};

export type ShopifySignUpInput = {
email: string;
password: string;
firstName?: string;
lastName?: string;
acceptsMarketing?: boolean;
autoSignIn?: boolean;
};

You might not need this if you are not using TypeScript.

Setup Validation Schemas

Use Zod to validate incoming requests:

import * as z from "zod";

const signInSchema = z.object({
email: z.email().min(1),
password: z.string().min(1),
});

const signUpSchema = z.object({
email: z.email().min(1),
password: z.string().min(1),
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
acceptsMarketing: z.boolean().optional(),
autoSignIn: z.boolean().optional(),
});

Zod is optional. You can skip this if you are not concerned about input validation. Not a mandatory thing for better-auth.

Create the Sign-In Endpoint

The sign-in endpoint calls Shopify's customerAccessTokenCreate mutation and stores the token in an HTTP-only cookie:

import { APIError, createAuthEndpoint } from "better-auth/api";

const SHOPIFY_CUSTOMER_TOKEN_COOKIE = "shopifyCustomerAccessToken";

export const shopifyAuthPlugin = () => {
return {
id: "shopify-auth",
endpoints: {
signIn: createAuthEndpoint(
"/shopify-auth/sign-in",
{
method: "POST",
body: signInSchema,
},
async (ctx) => {
const { email, password } = ctx.body;

// Call Shopify's customer access token create mutation
const result = await customerAccessTokenCreate({ email, password });

if (!result) {
throw new APIError("BAD_REQUEST", {
message: "Shopify sign-in failed.",
});
}

// Extract token and errors from response
const payload = result.customerAccessTokenCreate;
const userErrors = payload?.customerUserErrors ?? [];
const token = payload?.customerAccessToken?.accessToken;
const expiresAt = payload?.customerAccessToken?.expiresAt;

if (userErrors.length || !token) {
throw new APIError("UNAUTHORIZED", {
message: userErrors[0]?.message || "Invalid email or password.",
});
}

// Store token in secure HTTP-only cookie
ctx.setCookie(SHOPIFY_CUSTOMER_TOKEN_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
expires: expiresAt ? new Date(expiresAt) : undefined,
});

return ctx.json({ ok: true });
},
),
// ... signUp endpoint
},
};
};

The sign-in flow:

  1. Validates email and password using Zod
  2. Calls Shopify's customerAccessTokenCreate mutation
  3. Checks for errors in the response
  4. Stores the access token in an HTTP-only cookie
  5. Returns success response

I have not given the details of customerAccessTokenCreate() function to keep this article stick to auth related logic. You can visit the Github repo of headless-shopify to get that function and see how it works.

Create the Sign-Up Endpoint

This section should go inside the endpoints object, just like signIn.

The sign-up endpoint creates a new customer in Shopify and optionally signs them in:

signUp: createAuthEndpoint(
"/shopify-auth/sign-up",
{
method: "POST",
body: signUpSchema,
},
async (ctx) => {
const { email, password, firstName, lastName, acceptsMarketing, autoSignIn } = ctx.body;

// Create customer in Shopify
const result = await customerCreate({
email,
password,
firstName,
lastName,
acceptsMarketing,
});

const payload = result.customerCreate;
const userErrors = payload?.customerUserErrors ?? [];
const customer = payload?.customer;

if (userErrors.length || !customer) {
throw new APIError("BAD_REQUEST", {
message: userErrors[0]?.message || "Unable to create customer.",
});
}

// Optionally sign in the user immediately after signup
if (autoSignIn) {
const signInResult = await customerAccessTokenCreate({ email, password });
const token = signInResult?.customerAccessTokenCreate?.customerAccessToken?.accessToken;

if (token) {
ctx.setCookie(SHOPIFY_CUSTOMER_TOKEN_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
}
}

return ctx.json({ ok: true, customer });
},
),

The sign-up flow:

  1. Validates all input fields using Zod
  2. Calls Shopify's customerCreate mutation
  3. Handles any errors from Shopify
  4. If autoSignIn is true, immediately signs in the user
  5. Returns success with customer data

Step 4: Configure Better-Auth Server

Create the Better-Auth server instance:

// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { shopifyAuthPlugin } from "@/lib/shopify-auth-plugin";

export const auth = betterAuth({
plugins: [nextCookies(), shopifyAuthPlugin()],
});

Step 5: Create API Route Handler

Create a catch-all API route for Better-Auth:

// src/app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);

This creates the following endpoints:

  • POST /api/auth/shopify-auth/sign-in - Sign in
  • POST /api/auth/shopify-auth/sign-up - Sign up

Step 6: Create Client-Side Auth Plugin

A better-auth client-side plugin consumes the APIs created by server-side plugin.

Create the client-side plugin:

// src/lib/shopify-auth-client.ts
import type { BetterAuthClientPlugin } from "better-auth/client";
import type { BetterFetchOption } from "@better-fetch/fetch";
import type {
shopifyAuthPlugin,
ShopifySignInInput,
ShopifySignUpInput,
} from "@/lib/shopify-auth-plugin";

export const shopifyAuthClientPlugin = () => {
return {
id: "shopify-auth",
$InferServerPlugin: {} as ReturnType<typeof shopifyAuthPlugin>,
getActions: ($fetch) => {
return {
shopifySignIn: async (
data: ShopifySignInInput,
fetchOptions?: BetterFetchOption,
) => {
return $fetch("/shopify-auth/sign-in", {
method: "POST",
body: data,
...fetchOptions,
});
},
shopifySignUp: async (
data: ShopifySignUpInput,
fetchOptions?: BetterFetchOption,
) => {
return $fetch("/shopify-auth/sign-up", {
method: "POST",
body: data,
...fetchOptions,
});
},
};
},
} satisfies BetterAuthClientPlugin;
};

Step 7: Initialize Auth Client

Create the auth client instance:

// src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { shopifyAuthClientPlugin } from "@/lib/shopify-auth-client";

export const authClient = createAuthClient({
plugins: [shopifyAuthClientPlugin()],
});

Step 8: Create Login Page

Now let's build the login UI that uses our auth client.

Setup Component State

// src/app/account/login/page.tsx
"use client";

import React, { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";

export default function LoginPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
// ... form handler
}

Handle Form Submission

The form handler calls the shopifySignIn method from our auth client:

async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);

const form = e.currentTarget;
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
const password = (form.elements.namedItem("password") as HTMLInputElement)
.value;

try {
const shopifyAuth = await authClient.shopifySignIn({
email,
password,
});

// Check for errors in the response
const shopifyError = (shopifyAuth as { error?: { message?: string } })
?.error?.message;
if (shopifyError) {
setError(shopifyError || "Invalid email or password.");
return;
}

// Verify successful sign-in
const shopifyData = (shopifyAuth as { data?: { ok?: boolean } })?.data;
if (!shopifyData?.ok) {
setError("Invalid email or password.");
return;
}

// Redirect to account page on success
router.push("/account");
} catch (err) {
setError("An error occurred. Please try again.");
} finally {
setLoading(false);
}
}

Render the Form

Create a simple, accessible form:

return (
<div className="max-w-md mx-auto mt-8 p-6">
<h1 className="text-2xl font-bold mb-6">Sign In</h1>

<form onSubmit={onSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full px-3 py-2 border rounded-md"
/>
</div>

<div>
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full px-3 py-2 border rounded-md"
/>
</div>

{error && <div className="text-red-600 text-sm">{error}</div>}

<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded-md"
>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
</div>
);

Step 8b: Create Signup Page

Similarly, let's create the signup page that allows new users to create accounts.

Setup Component State

// src/app/account/register/page.tsx
"use client";

import React, { useState } from "react";
import { authClient } from "@/lib/auth-client";

export default function RegisterPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ... form handler
}

Handle Form Submission

The form handler calls shopifySignUp with the new customer information:

async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);

const form = e.currentTarget;
const firstName = (form.elements.namedItem("firstName") as HTMLInputElement)
.value;
const lastName = (form.elements.namedItem("lastName") as HTMLInputElement)
.value;
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
const password = (form.elements.namedItem("password") as HTMLInputElement)
.value;

try {
const shopifyAuth = await authClient.shopifySignUp({
email,
password,
firstName,
lastName,
acceptsMarketing: false,
autoSignIn: true, // Automatically sign in after signup
});

// Check for errors
const shopifyError = (shopifyAuth as { error?: { message?: string } })
?.error?.message;
if (shopifyError) {
setError(shopifyError || "Unable to create account.");
return;
}

// Verify success
const shopifyData = (shopifyAuth as { data?: { ok?: boolean } })?.data;
if (!shopifyData?.ok) {
setError("Unable to create account.");
return;
}

// Redirect to home page on success
window.location.href = "/";
} catch {
setError("Unable to create account. Please try again.");
} finally {
setLoading(false);
}
}

Note the autoSignIn: true option - this automatically signs in the user after successful registration, providing a seamless onboarding experience.

Render the Form

Create a registration form with fields for first name, last name, email, and password:

return (
<div className="max-w-md mx-auto mt-8 p-6">
<h1 className="text-2xl font-bold mb-6">Create Account</h1>

<form onSubmit={onSubmit} className="space-y-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium mb-2">
First Name
</label>
<input
id="firstName"
name="firstName"
type="text"
required
className="w-full px-3 py-2 border rounded-md"
/>
</div>

<div>
<label htmlFor="lastName" className="block text-sm font-medium mb-2">
Last Name
</label>
<input
id="lastName"
name="lastName"
type="text"
required
className="w-full px-3 py-2 border rounded-md"
/>
</div>

<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full px-3 py-2 border rounded-md"
/>
</div>

<div>
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full px-3 py-2 border rounded-md"
/>
</div>

{error && <div className="text-red-600 text-sm">{error}</div>}

<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded-md"
>
{loading ? "Creating Account..." : "Create Account"}
</button>
</form>
</div>
);

Step 9: Create Session Provider

A session provider manages user authentication state across your application using React Context.

Define Context Types

// src/providers/session-provider.tsx
"use client";

import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { getCurrentUser } from "@/lib/shopify/queries/customers/getCurrentUser";

type SessionUser = Awaited<ReturnType<typeof getCurrentUser>>;

type SessionContextValue = {
user: SessionUser;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
};

Create the Provider Component

const SessionContext = createContext<SessionContextValue | undefined>(
undefined,
);

export function SessionProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<SessionUser>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const refresh = useCallback(async () => {
try {
setLoading(true);
setError(null);
const currentUser = await getCurrentUser();
setUser(currentUser);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load user");
setUser(null);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
refresh();
}, [refresh]);

return (
<SessionContext.Provider value={{ user, loading, error, refresh }}>
{children}
</SessionContext.Provider>
);
}

The provider automatically fetches the current user on mount and provides a refresh method to reload user data.

Create a Custom Hook

useSession() provides access to session-related data and functionality throughout your application.

export function useSession() {
const context = useContext(SessionContext);
if (context === undefined) {
throw new Error("useSession must be used within a SessionProvider");
}
return context;
}

Step 10: Use Session in Your App

Wrap Your App

Add the SessionProvider to your root layout:

// src/app/layout.tsx
import { SessionProvider } from "@/providers/session-provider";

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
}

Access User Data

Use the useSession hook in any component to access authentication state:

"use client";

import { useSession } from "@/providers/session-provider";

export function UserProfile() {
const { user, loading, error } = useSession();

if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>Not logged in</div>;

return (
<div>
<h2>Welcome, {user.firstName}!</h2>
<p>Email: {user.email}</p>
</div>
);
}

The session hook provides:

  • user - Current user data or null
  • loading - Boolean indicating if user data is being fetched
  • error - Error message if fetching failed
  • refresh() - Function to manually reload user data

Key Security Features

1. HTTP-Only Cookies

The access token is stored in an HTTP-only cookie, making it inaccessible to JavaScript:

ctx.setCookie(SHOPIFY_CUSTOMER_TOKEN_COOKIE, token, {
httpOnly: true, // Prevents XSS attacks
secure: process.env.NODE_ENV === "production", // HTTPS only in production
sameSite: "lax", // CSRF protection
path: "/",
expires: expiresAt ? new Date(expiresAt) : undefined,
});

2. Input Validation

All inputs are validated using Zod schemas before processing:

const signInSchema = z.object({
email: z.email().min(1),
password: z.string().min(1),
});

3. Error Handling

Proper error handling prevents information leakage:

if (userErrors.length || !token) {
throw new APIError("UNAUTHORIZED", {
message: userErrors[0]?.message || "Invalid email or password.",
});
}

Benefits of This Approach

  1. Type Safety: Full TypeScript support throughout the authentication flow
  2. Security: HTTP-only cookies and secure token management
  3. Extensibility: Plugin-based architecture makes it easy to add features
  4. Developer Experience: Clean API with minimal boilerplate
  5. Integration: Seamless integration with Shopify's Customer API
  6. Session Management: Built-in session handling with React Context

Conclusion

Implementing authentication in a headless Shopify storefront using Better-Auth provides a secure, type-safe, and developer-friendly solution. The plugin-based architecture allows you to create custom authentication flows that integrate perfectly with Shopify's Customer API while maintaining security best practices.

The complete implementation includes:

  • ✅ Custom Better-Auth plugin for Shopify
  • ✅ Secure cookie-based session management
  • ✅ Sign-in and sign-up functionality
  • ✅ Client-side session provider
  • ✅ Type-safe authentication flows
  • ✅ Error handling and validation

You can find the complete implementation in the Headless Shopify repository.

Resources

Why sls-next Component Throwing Error About Unsupported Target Property?

· One min read

If you are here, then you might be trying to deploy Next.js v13 to AWS using @sls-next component. As of now, the @sls-next component does not support Next.js v13.

@sls-next component is adding target: "serverless" configuration dynamically to next.config.js. Here is the code that is doing it, link:

Unupported target attribute

Many suggests to use output configuration instead. But, output does not work exactly like target.

I was trying to explain the reason behind the error. Hope, I could throw some light.

How to Deploy Next.js Application in AWS?

· 2 min read

To deploy a Next.js application on AWS, you can follow these general steps:

Build the Next.js application for production by running the command npm run build.

Set up an EC2 instance or Elastic Beanstalk environment to run your application. EC2 provides you with more control over your infrastructure, while Elastic Beanstalk offers a more managed solution with less configuration.

Install Node.js on your EC2 instance or Elastic Beanstalk environment.

Copy the Next.js application files to your EC2 instance or Elastic Beanstalk environment using a file transfer protocol (FTP) or a secure copy (SCP) tool.

Install the necessary dependencies for your Next.js application on the EC2 instance or Elastic Beanstalk environment by running the command npm install.

Configure your Next.js application to run as a service using a process manager like pm2.

Set up a reverse proxy like Nginx or Apache to route traffic from your EC2 instance or Elastic Beanstalk environment to your Next.js application.

Optionally, configure a domain name for your application using Amazon Route 53 or another DNS provider.

Once your application is up and running, you can use AWS Load Balancers to distribute traffic across multiple instances of your application, increase reliability, and scale as needed.

These are the general steps for deploying a Next.js application on AWS. The exact details will depend on your specific setup and requirements.

Code Elimination Tool in Next.js

· 2 min read

When creating a page in Next.js, there are code that runs only in server. There are also code that is added to the bundle and returned to the browser.

If you are a beginner in Next.js, it might be difficult to separate one from another. Next.js has come up with a code elimination tool to give more clarity on this.

App Component in Next.js

· 3 min read

Next.js initializes all pages using an App component. In other words, this App component acts as a Higher Order Component(HOC) of all pages. Using an App component is part of internal working of Next.js. During build step, Next.js creates a .next folder and stores the output files inside it.

If we open, .next/static/chunks/pages folder, we can see an _app.js automatically generated.

Adding Google Font in Next.js

· 2 min read

Next.js has a default Document structure using which it renders all pages. We can override that structure using pages/_document.js.

For this article, let us try adding Luckiest Guy Google font. Here is the link code which I got from Google.

Isomorphic Rendering in Next.js

· 4 min read

Next.js is an isomorphic app framework. If I visit a url directly in the browser, Next.js renders the page in the server. Instead, if we visit the url through a link, then the page is rendered client side just like SPA applications.

It was and still is a miracle for me. I was playing with Next.js and trying to understand how Next.js implement this feature. I can say I am still learning. I am expressing only the very high level knowledge which I got.

Server Side Rendering(SSR) in Next.js

· 2 min read

Next.js is a React framework to build Isomorphic applications. Isomorphic applications support both client side and server side rendering.

Server side rendering means the complete HTML structure and its contents are gathered and rendered in the server. In case of Next.js, the server is a Node.js server.

Server side pages are usually dynamic. Example, a blog post or a product details page in ecommerce site.

Running Inline JavaScript Inside Next.js JSX

· One min read

Next.js supports inserting JavaScript code to our project in various ways. We are going to explore how we can add and run a piece of inline JavaScript code in a Next.js component.

Here we have a piece of JavaScript code:

console.log("This is an inline JavaScript");

Creating Page in Next.js

· 2 min read

Next.js is a React framework to build websites. Next.js creates page routes based on file and folder structure. For example, if we want to create a url like /contact-us, we need to create a JavaScript file with file name contact-us.js under /pages folder.

Each JavaScript file should export a React Component. Here is an example content that can go inside contact-us.js:

const ContactUs = () => {
return <h1>Contact us page</h1>;
};

export default ContactUs;

Dynamic Page in Next.js

· 2 min read

Next.js is a React framework to build web application. Next.js creates page routes based on file and folder structure. We can also set and handle dynamic pages in Next.js.

Next.js Setup Without create-next-app

· 2 min read

In order to setup Next.js, it is very easy to start with create-next-app. This command sets up everything in a folder which you mention. Now, if you are person who would like to do everything from bits and pieces, there is also a way for that.

TypeScript Support in Next.js

· One min read

A Next.js project can be easily setup using create-next-app command.

npx create-next-app@latest

But the project created using above command does not support TypeScript. If we want to add TypeScript support, we need to manually add TypeScript related configuration to the project.

Automatic Static Optimization in Next.js

· One min read

Next.js can create static HTML pages at build time. Static HTML pages can then be deployed in CDNs to improve page performance. Using next export command, we can create a static HTML site from a Next.js site.

Even without using next export, Next.js does automatic static optimization by generating HTML files for pages. If a page contains getServerSideProps or getInitialProps, Next.js will switch to render the page on-demand or per-request (meaning Server-Side Rendering). If both getServerSideProps and getInitialProps are NOT present, Next.js automatically create a static HTML file.

In my Next.js project, I created a new page for about. It contains following code.

export async function getStaticProps(context) {
return {
props: {
name: "Joby",
age: 35,
},
};
}

export default function About({ name, age }) {
return (
<div>
<h1>About Page</h1>
<span>
{name} is {age} years old
</span>
</div>
);
}

As you can see, in the above code getServerSideProps and getInitialProps are not present. Now when I run next build, I could see about.html generated under .next/server/pages/.

Static HTML Nextjs

If there is getServerSideProps in the page, then, instead of about.html, Next.js creates about.js. The about.js is then executed in a node server like a normal Node.js application.

SOLVED: 502 Error The Lambda function returned invalid JSON

· 2 min read

In my current project, we are hosting Next.js in AWS using Next.js Serverless Component. It is an eCommerce website. After integrating few APIs in product listing page, some browse pages started throwing below error when directly accessing the CloudFront url.

502 ERROR
The request could not be satisfied.
The Lambda function returned invalid JSON: The JSON output is not parsable. We can't connect to the server for this app or website at this time. There might be too much traffic or a configuration error. Try again later, or contact the app or website owner.
If you provide content to customers through CloudFront, you can find steps to troubleshoot and help prevent this error by reviewing the CloudFront documentation.

Initially we thought something was breaking from the backend and the API response was corrupted. But after reading through AWS documentation and understanding the working of Next.js Serverless component to an extend, this is what we found.

Next.js Serverless component is creating AWS Lambda function to run server side code. For improved performance, the Lambda functions are copied to Lambda@Edge and executed from there.

If a Next.js page contains getServerSideProps(), there is a server side execution. This server side execution happens at Lambda@Edge. Lambda@Edge then returns the response from getServerSideProps() to CloudFront . This response has a size limit of 1MB set by AWS. So if our response is greater than 1MB, Lamba@Edge truncates it and pass to CloudFront. Truncation makes the response invalid. When CloudFront tries to parse the invalid response JSON, this error is thrown.

We reduced the size of JSON object by optimizing the returned data from getServerSideProps() function and solved the issue.

SOLVED: Next.js Serverless NoSuchDistribution: The specified distribution does not exist.

· One min read

When using Next.js Serverless Component to deploy a Next.js application to AWS Lambda, a Cloudfront distribution is automatically created. If by any chance, we delete the Cloudfront distribution directly from AWS console, the next time when we try to deploy to AWS, an error is thrown as below.

NoSuchDistribution: The specified distribution does not exist.

This is happening because, when we deploy our application, the Serverless framework creates two new folders in our project folder. They are .serverless and .serverless_next.

Inside .serverless folder, Serverless is storing the name of earlier created cloudfront distribution id. What we need to do is to delete both these folders and try again. It should work.

Setup Gitlab CI/CD Pipeline to Host Next.js App in AWS Lambda

· 7 min read

Next.js is a React framework that helps us to build isomorphic web applications easily. Isomorphic applications are those web applications which can be rendered either in the client side or in the server side. In a real world project, building an application and deploying it to server should be automized using any CI/CD tool. In this article, we will perform following steps.

  1. Save and version a Next.js application in Gitlab repository
  2. Setup a Gitlab CI/CD pipeline that deploys the application to AWS Lambda

Here, Gitlab is the Git server. It can be different for you like Bitbucket, Github and so on. Also, the CI/CD used is also from Gitlab. For other CI/CD tools, the syntax will be different but the base algorithm will be same.

Project Setup

First step in our process is to setup a Next.js application. For that you need to have Node.js installed in your machine. After that run following command in terminal.

npx create-next-app@latest

Above command asks for a project name. It then creates a folder with that name and then setup your Next.js application in that folder. If you have difficulty in setting up a Next.js application, you can also refer to this article. Refer to Create Next.js Application section.

Next, create a repository in Gitlab and push the current code to the repo. My repository name is Next.js Intro.

Next.js App in Gitlab Repo

The Gitlab repo looks like above.

Serverless

Serverless is a NPM package using which we can upload our Next.js application to AWS Lambda. To install that, go to our Next.js app root folder and run below command.

npm install --save serverless

Now serverless package is added to our Next.js project. The package name is also appended to dependencies section in package.json file. Next, we need to create a new command in package.json to invoke our serverless package. For that, open package.json file of our Next.js application. Under "scripts" section, add below key-value pair.

"deploy": "serverless"

By adding above command, we can run npm run deploy in terminal to invoke serverless. When serverless command runs, it executes the set of tasks written in serverless.yml file. Right now, we do not have that file in our project. So, create a serverless.yml in the project root folder.

In the yml file, add the following content:

myNextCICDApplication:
component: "@sls-next/serverless-component@latest"

The @sls-next/serverless-component is written exclusively for Next.js. By adding the reference to it in serverless.yml file, serverless now know the steps to deploy our Next.js application to AWS Lambda@edge.

Ok, now serverless is ready to upload our Next.js application to AWS. But which AWS account? For that, we need to set two environment variables, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.

Testing Serverless

First take terminal. Navigate to the folder of our application. If you are using a Mac machine, run following commands to set environment variables.

export AWS_ACCESS_KEY_ID=<your access key id here>
export AWS_SECRET_ACCESS_KEY=<your secret access key here>

You can confirm if the environment variable is properly set by running printenv in Mac. For other OS, the command might be different.

After setting environment variables, run npm run deploy in terminal. That will execute our serverless command. We can see the deploying process.

Serverless Deploying

Once the deployment is complete, the serverless component, gives the AWS cloudfront url to access our site.

Serverless Deployed

Push the new changes to the project repo in Gitlab. The main changes are related to serverless package. Just to ensure that we are in sync, our project structure now looks like this.

Serverless Added

Now, its time to tell Gitlab CI/CD to handle the build and deployment. Gitlab has all necessary files to do that in the repository.

Gitlab CI/CD

First we need to be clear about the expectation from Gitlab CI/CD. Here is the flow of process:

  • We push any changes to our app to the main branch
  • Gitlab CI/CD recognizes the change and start building the project
  • After building, Gitlab CI/CD redeploys our application to AWS Lambda with the help of serverless framework
  • We can see our changes reflected in the cloudfront url

Just like serverless is expecting a serverless.yml file, Gitlab CI/CD expects a .gitlab-ci.yml file in our project. A software called Gitlab Runner is the one who listens for change and execute the jobs defined in the yml file. We are going to implement the steps mentioned above in this yml file.

To start with, create .gitlab-ci.yml file in the project root.

In order to run our project, we need a Node.js environment. We can use a Docker image here using image property.

default:
image: node:16.13.1

In Gitlab CI/CD yml file, we can define different jobs. A job contains a set of scripts to be executed. There are ways to run multiple jobs serially or parallely. Let us create a job in our yml file.

stages:
- deploy

aws-deploy-job:
stage: deploy
script:
- npm install
- npm run deploy

Before all jobs, we define stages. In our code, there is only one stage, deploy. There can be multiple stages. aws-deploy-job is a job name. That job is connected to deploy stage. All the commands under scripts are executed when this job is running.

Setup Environment Variables

When Gitlab runs our script, the serverless framework in our project will search for AWS credentials. It is there in our local machine, but not in Gitlab. We cannot directly and openly paste AWS credentials in .gitlab-ci.yml file. That causes security breach. Instead, there is an option to define variables and their values in Gitlab console. This console can only be accessed by authorized users.

Let us set two variables in the console for AWS access key ID and AWS secret access key. For that, Go to Repo Settings > CI/CD > Variables section. Expand the section and add a new variable like below.

Add variables in Gitlab

In similar manner, add another variable for Access Secret. Here is how the Variables sections looks finally.

Variables section in Gitlab

We created two variables required for serverless Component. Since we did not change anything in the code, there is nothing to push to Gitlab. Instead, Go to Gitlab CI/CD pipeline and rerun the last failed pipeline. That will install all dependencies for the project using npm install and after that, run serverless.

Site deployed in AWS

Now, when serverless is run by Gitlab CI/CD it has the needed AWS credentials from the environment variables. From now on, if we push any changes to our Gitlab repo, automatically the pipeline will run and deploy the updated site to AWS Lambda@edge.

Summary

We learned how to setup CI/CD pipeline for deploying our Next.js site to AWS Lambda. Now the pipeline is not production ready. Ideally, we setup different branches that maps to different environments(dev, qa, stage, prod). After that, when we push code to dev branch, deployment to dev environment happens, qa to qa environment and so on. We need to do update Gitlab yml file to manage our project like that.

Hosting Next.js App in AWS Lambda Using Serverless

· 5 min read

Next.js is a React framework to create isomorphic applications. In this article we learn how to

  • Create a Next.js application
  • Setup AWS to host our Next.js application
  • Use Serverless package to host our application to Lambda

Create Next.js Application

In order to spin up a Next.js application in our machine, we first need to install Node.js. You can test if Node.js is installed in your machine by typing following command:

node -v

It should return the version number of installed Node like v14.15.0. If it is not installed, go and install Node.js first. Once Node.js is ready, navigate to a folder where you want Next.js app to be setup. Then run following command:

npx create-next-app@latest

When running above command, it will ask for the project name. A folder will be then created with this name and all the files are put inside that folder. My app's name is my-nextjs-app.

Nextjs Installation

Now Next.js app is ready. We can go into the app folder and run yarn dev to see the running site.

AWS Setup

We need to create an AWS user that has necessary privileges to host the application in AWS Lambda. For that, we need to create an IAM user. This step is like any other IAM user creation. Nothing special. So if you dont know how to create an IAM user, check this link.

I am listing out some details which I gave while creating my IAM user. My user id is serverless. I gave AdministratorAccess policy to this user. Now this user has all the power to do whatever it wants in AWS.

In the last step of user creation, we get an access ID and secret key for serverless user. Save it safely somewhere.

Now we have a user who is ready to take our Next.js code and host it in Lambda. Next, we need to install AWS-CLI(Command Line Interface) in our laptop and configure it. Then only any tool in the future, if it wants to do something in AWS using serverless user, it can make use of aws-cli commands to perform the same.

AWS CLI

Best way to install AWS CLI is to follow the official documentation. It lists out OS specific documentation to setup AWS-CLI. Please complete it.

Once AWS CLI is installed, you can check if it is installed correctly by running following command:

aws --version

It should return something like this:

aws-cli/2.2.31 Python/3.8.8 Darwin/19.6.0 exe/x86_64 prompt/off

After AWS-CLI installation, you need to configure it by following this link. During the configuration step, you provide the user credentials like access ID and secret. The AWS-CLI then remembers it and uses it for future transactions.

The AWS configure command looks like this:

$ aws configure
AWS Access Key ID [None]: AKIAIOSFODNN7EXAMPLE
AWS Secret Access Key [None]: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Default region name [None]: us-west-2
Default output format [None]: json

During configuration process, AWS is storing the credentials in ~/.aws/credentials file in plain text. We can view the output and see our credentials.

AWS Credentials

In the credentials file, we can see [default]. That is the profile name. We can add more profiles like that. Later, when we execute Serverless framework, we can tell which profile to use.

In the next section, we are going to use a package called serverless that makes use of the aws credentials and host our Next.js app in AWS Lambda.

Serverless in Action

Serverless framework make development, deployment and running serverless application very easy. The framework contains code that can push our application to most of the serverless solutions out there like AWS, Google cloud and Azure. In this demo, we are going to use AWS.

Serverless is a general framework that helps to host any applications to any serverless solutions. In order to host a Next.js application, there is a serverless component particularly created for Next.js. It is open source and known as Serverless Next.js Component.

We are going to use Serverless Next.js Component.

serverless.yml

In order to use Serverless framework, we need to have a serverless.yml in the root folder of the project. It is going to have all the configurations. Create this file in our Next.js root folder, ie in the same level as of parent package.json file.

In the yml file, add the following content:

myNextApplication:
component: "@sls-next/serverless-component@latest"

Deploy Script

In package.json file, add one more command for deploy:

"deploy": "serverless --aws-profile default"

Now when we run npm run deploy, serverless framework starts running. The framework uses the credentials of AWS default profile.

We now have all the main ingredients ready. Let us go to terminal and run below command from our app root folder.

npm run deploy

This command now runs Serverless and do all steps and configure our application in AWS. The Serverless Next.js Component finally shows the cloudfront URL in the console along with other details.

Nextjs AWS Lambda

If we take the cloudfront url, https://d1h3cs35iia2hn.cloudfront.net in browser, we can see the deployed Next.js application.

Next.js Application deployed in AWS Lambda

Technical Summary

Next.js application requires server side rendering of React. Here, Serverless Next.js Component is deploying our app to Lambda@Edge which has the capability to execute Lambda code at CDN edges.

Serverless.yml is converted to a valid Cloud Formation file by Serverless. The entire deployment process is then conducted based on the steps provided by this Cloud Formation file.

Once Next.js app is built, we get some chunk of Javascript files and other files. These files are stored in a S3 bucket by the Serverless and the bucket id is displayed in the console(in the screenshot above) after deployment.