Minyie Diaz
← Back to blog
Apr 17, 2026Sitecore CDPSitecore PersonalizeNext.jsVercel

How to Personalize Sitecore Pages by Postal Code with CDP Custom Events

I got a requirement on a client project to implement personalization in Sitecore AI based on the user's zipcode. The goal was to capture location data automatically from the request and use it to drive content variants without adding friction to the user experience.
The approach I landed on was pretty simple. Because the app is running on Vercel, I can read Vercel's geolocation headers from the incoming request to get the postal code, send it to Sitecore CDP as a custom event, and then use that event inside Personalize and Pages to decide which content variant to show.

Architecture

There are three moving parts here:
  1. A Next.js API route that reads geolocation data.
  2. A client-side utility that sends a custom event to Sitecore CDP.
  3. A small React component that runs the flow on page load.
User Browser
    ↓
React Component (PostalCodeTracker)
    ↓
API Endpoint (/api/postal-code) → Vercel Headers
    ↓
Sitecore Event Tracker → CDP Event
    ↓
Sitecore CDP

Start with the Geolocation Endpoint

The first piece is an API endpoint that extracts location data from the request. I used Vercel's built-in geolocation headers, but you could swap this for another IP geolocation provider if your stack is set up differently.
// pages/api/postal-code.ts
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<PostalCodeResponse>,
) {
  try {
    const ip =
      req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || "unknown";

    const postalCode = req.headers["x-vercel-ip-postal-code"] as string;
    const city = req.headers["x-vercel-ip-city"] as string;
    const region = req.headers["x-vercel-ip-country-region"] as string;
    const country = req.headers["x-vercel-ip-country"] as string;

    if (!postalCode) {
      return res.status(200).json({
        postalCode: null,
        error: "Postal code not available for this request",
      });
    }

    res.status(200).json({
      postalCode: decodeURIComponent(postalCode),
      city: city ? decodeURIComponent(city) : undefined,
      region: region ? decodeURIComponent(region) : undefined,
      country: country || undefined,
      ip: Array.isArray(ip) ? ip[0] : ip,
    });
  } catch (error) {
    console.error("Error getting postal code:", error);
    res.status(500).json({
      postalCode: null,
      error: "Failed to retrieve postal code",
    });
  }
}
I liked keeping this logic in one place. The route becomes the normalization layer for location data, and it leaves room to expand later if you want to personalize by city, state, or country instead of just postal code.

Send It to Sitecore CDP

Once I had the postal code coming back from the API, the next step was to wrap the fetch and event call in a small utility so the client-side code stayed clean.
export const trackUserLocation = async (): Promise<{
  postalCode: string | null;
  error?: string;
}> => {
  try {
    const response = await fetch("/api/postal-code");
    const data = await response.json();

    if (data.postalCode) {
      const eventData = {
        type: "UserZipCodeCaptured",
        extensionData: {
          postalCode: data.postalCode,
        },
      };

      await event(eventData);

      return {
        postalCode: data.postalCode,
      };
    }

    return {
      postalCode: null,
      error: data.error || "Postal code not available",
    };
  } catch (error) {
    console.error("Failed to track user location:", error);
    return {
      postalCode: null,
      error: "Failed to fetch postal code",
    };
  }
};
The important part here is the event shape. I used UserZipCodeCaptured as the custom event name and passed the postal code in the payload so it could be evaluated later in Sitecore Personalize.

Trigger It on Page Load

With that utility in place, I needed a safe place to call it. I used a small React component that runs on page load and skips tracking in development or Sitecore editing contexts.
// components/Feature/PostalCodeTracker.tsx
const PostalCodeTracker = (): JSX.Element | null => {
  const { sitecoreContext } = useSitecoreContext();
  const [postalCode, setPostalCode] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const shouldTrack = (): boolean => {
    if (process.env.NODE_ENV === "development") {
      return false;
    }

    if (sitecoreContext.pageState !== LayoutServicePageState.Normal) {
      return false;
    }

    return true;
  };

  useEffect(() => {
    const trackLocation = async () => {
      if (!shouldTrack() || loading) {
        return;
      }

      setLoading(true);
      try {
        const result = await trackUserLocation();
        if (result.postalCode) {
          setPostalCode(result.postalCode);
          console.debug("User location tracked:", result.postalCode);
        } else if (result.error) {
          console.debug("Location tracking skipped:", result.error);
        }
      } catch (error) {
        console.error("Error tracking location:", error);
      } finally {
        setLoading(false);
      }
    };

    trackLocation();
  }, [sitecoreContext.pageState]);

  return null;
};
This component does not render anything. Its only job is to decide whether tracking should happen and then kick off the request.

Add It to the Layout

At that point, the remaining work was just wiring the tracker into the shared layout and confirming the event was actually being sent.
import PostalCodeTracker from "components/Feature/PostalCodeTracker";

export const Layout = () => (
  <>
    <PostalCodeTracker />
    {/* Rest of your layout */}
  </>
);
Before moving on, I would verify four things:
  1. The tracker is included in the shared layout.
  2. The custom event exists in Sitecore CDP.
  3. The logic respects your consent requirements.
  4. You can see both the /api/postal-code response and the outgoing CDP event in the network tab.

Create the Custom Condition in Personalize

Once the browser was sending the event, the next challenge was making it usable in Personalize. The easiest way I found was to create a custom condition that looks through the current session and checks whether the expected postal code was captured.
I started with a single postal code match because it is much easier to debug than jumping straight to a broader list of values.
(function () {
  var zipCodeToMatch = "[[zip code | string | | { required: true }]]";
  var returnValue = false;
  var expectedType = "WEB";
  var expectedStatus = "OPEN";

  if (guest && guest.sessions && guest.sessions.length > 0) {
    loop: for (var i = 0; i < guest.sessions.length; i++) {
      if (guest.sessions[i]) {
        if (
          guest.sessions[i].sessionType !== expectedType ||
          guest.sessions[i].status !== expectedStatus
        ) {
          continue loop;
        } else if (guest.sessions[i].events) {
          for (var j = 0; j < guest.sessions[i].events.length; j++) {
            if (guest.sessions[i].events[j].type === "UserZipCodeCaptured") {
              if (
                guest.sessions[i].events[j].arbitraryData &&
                guest.sessions[i].events[j].arbitraryData.ext &&
                guest.sessions[i].events[j].arbitraryData.ext.postalCode
              ) {
                if (
                  guest.sessions[i].events[j].arbitraryData.ext.postalCode ===
                  zipCodeToMatch
                ) {
                  returnValue = true;
                  break loop;
                }
              }
            }
          }
        }
      }
    }
  }

  return returnValue;
})();
What the script is doing is fairly simple: it loops through the current web session, looks for the UserZipCodeCaptured event, reads the postal code from arbitraryData.ext.postalCode, and returns true when it finds a match.
Once that condition is saved, it becomes available anywhere you want to use it in targeting rules.

Use the Condition in Pages

The last step is applying the condition in Pages so the event actually affects the experience.
  1. Open the page you want to personalize in Pages.
  2. Select Personalize to create a rule-based experience.
  3. Add a new variant with a clear name.
  4. Choose your custom postal code condition.
  5. Enter the postal code or codes you want to target.
  6. Update the component content or datasource for that audience.
One easy use case is swapping the datasource for a banner so users in one region see a location-specific message while everyone else gets the default version.

Things to Watch

There were a few practical details worth keeping in mind while I set this up:
  • Postal code data is not guaranteed for every request.
  • Development, preview, and editing modes should usually skip tracking.
  • Consent requirements still apply even if location is inferred passively.

Final Result

This ended up being a nice lightweight way to introduce location-based personalization without adding friction to the user experience. Once the postal code is flowing into CDP reliably, you can reuse it across audience rules, regional content variants, and other CDP-driven scenarios.
It also gives you a decent foundation to build on later. You can expand the payload, switch geolocation providers, or make the matching logic more sophisticated once the basic flow is working.