Logo
Published on

Sitecore Headless Paywall with Geo Restrictions in Next.js

Authors
  • avatar
    Name
    Jorge Lusar
    Twitter

Following our previous post on securing a Sitecore Headless app with Next.js and iron-session, let’s extend a simple paywall for Sitecore Headless to also include geographic restrictions. We’ll keep the logic in Layout.tsx, use our session from iron-session, and still render global <Head> assets and the Sitecore footer.


What We Check

  1. Protected content — A Protected field on the Sitecore item controls visibility.
  2. Login statussession.isLoggedIn from our useSession() hook.
  3. Country allowlist — From NEXT_PUBLIC_ALLOWED_COUNTRIES (e.g. GB,US,IE). If unset, geo restriction is disabled.

Simplified Layout.tsx

src/Layout.tsx
import { LayoutServiceData, Placeholder, HTMLLink } from '@sitecore-jss/sitecore-jss-nextjs';
import { useI18n } from 'next-localization';
import Head from 'next/head';
import { useSession } from 'src/context/sessionContext';
import config from 'temp/config';

const publicUrl = config.publicUrl;

interface LayoutProps {
  layoutData: LayoutServiceData;
  headLinks: HTMLLink[];
}

export default function Layout({ layoutData, headLinks }: LayoutProps) {
  const { route, context } = layoutData.sitecore;
  const protectedField = route?.fields?.Protected?.value as boolean | undefined;
  const session = useSession();
  const i18n = useI18n();

  const isPageEditing = context.pageEditing;
  const isProtected = !(isPageEditing || !protectedField);
  const isLoggedIn = session?.isLoggedIn;

  const allowedCountries =
    (process.env.NEXT_PUBLIC_ALLOWED_COUNTRIES || '')
      .split(',')
      .map((s) => s.trim().toUpperCase())
      .filter(Boolean);

  const isCountryAllowed =
    !isLoggedIn ||
    !session?.country ||
    allowedCountries.length === 0 ||
    allowedCountries.includes(String(session.country).toUpperCase());

  return (
    <>
      <Head>
        <link rel="icon" href={`${publicUrl}/favicon.ico`} />
        {headLinks.map((headLink) => (
          <link rel={headLink.rel} key={headLink.href} href={headLink.href} />
        ))}
      </Head>

      <main>
        {isProtected && !isLoggedIn ? (
          <>
            <p>{i18n.t('Article/Protected') || 'This article requires login.'}</p>
            <a href="/login">{i18n.t('BrandHeader/Login') || 'Login'}</a>
          </>
        ) : null}

        {isProtected && isLoggedIn && !isCountryAllowed ? (
          <p>
            {i18n.t('Article/GeographicRestriction') ||
              'Due to geographic restrictions, this content is not available in your country.'}
          </p>
        ) : null}

        {(!isProtected || (isLoggedIn && isCountryAllowed)) && route ? (
          <Placeholder name="headless-main" rendering={route} />
        ) : null}
      </main>

      <footer>{route && <Placeholder name="headless-footer" rendering={route} />}</footer>
    </>
  );
}

Environment

Set your allowlist (optional):

NEXT_PUBLIC_ALLOWED_COUNTRIES=GB,US,IE
  • If unset, geo checks are skipped (content allowed).
  • If set, logged-in users must have session.country in that list.

Option B: No Layout changes — gate inside ArticlePage.tsx

If you prefer not to edit src/Layout.tsx, gate the content at the component level:

src/components/ArticlePage.tsx
import {
  ComponentParams,
  ComponentRendering,
  Field,
  Placeholder,
  RichText,
  RichTextField,
  useSitecoreContext,
} from '@sitecore-jss/sitecore-jss-nextjs';
import { useI18n } from 'next-localization';
import { useSession } from 'src/context/sessionContext';

type ArticleProps = {
  params: ComponentParams; // Optional: AllowedCountries="GB,US"
  rendering: ComponentRendering & { params: ComponentParams };
};

const csv = (v?: string) =>
  (v || '')
    .split(',')
    .map((s) => s.trim().toUpperCase())
    .filter(Boolean);

export default function ArticlePage({ params, rendering }: ArticleProps) {
  const { sitecoreContext } = useSitecoreContext();
  const i18n = useI18n();
  const session = useSession();

  // Minimal Sitecore fields
  const routeFields = sitecoreContext.route?.fields || {};
  const protectedField = routeFields.Protected as Field<boolean>;
  const content = routeFields.Content as RichTextField;

  // Gates
  const isPageEditing = !!sitecoreContext.pageEditing;
  const isProtected = !(isPageEditing || !protectedField?.value);
  const isLoggedIn = !!session?.isLoggedIn;

  // Optional component param: AllowedCountries="GB,US"
  const allowedCountries = csv(params?.AllowedCountries);
  const userCountry = String(session?.country || '').toUpperCase();
  const countryAllowed =
    !session?.country || allowedCountries.length === 0 || allowedCountries.includes(userCountry);

  // 1) Protected + logged out → login prompt
  if (isProtected && !isLoggedIn) {
    return (
      <section className="container">
        <p>{i18n.t('Article/Protected') || 'This article requires login.'}</p>
        <a href="/login">{i18n.t('BrandHeader/Login') || 'Login'}</a>
      </section>
    );
  }

  // 2) Protected + logged in but geo-blocked → restriction
  if (isProtected && isLoggedIn && !countryAllowed) {
    return (
      <section className="container">
        <p>
          {i18n.t('Article/GeographicRestriction') ||
            'Due to geographic restrictions, this content is not available in your country.'}
        </p>
      </section>
    );
  }

  // 3) Public or allowed → render article + placeholder
  return (
    <section className="container">
      <RichText field={content} />
    </section>
  );
}

Summary

  • Protected + logged out → show “login required” with /login link
  • Protected + logged in + not allowed → show geographic restriction message
  • Protected + logged in + allowed → render content normally

This approach keeps the paywall + geofencing logic centralized, predictable, and easy to tweak via environment configuration.