- Published on
Sitecore Headless Paywall with Geo Restrictions in Next.js
- Authors
- Name
- Jorge Lusar
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
- Protected content — A
Protected
field on the Sitecore item controls visibility. - Login status —
session.isLoggedIn
from ouruseSession()
hook. - Country allowlist — From
NEXT_PUBLIC_ALLOWED_COUNTRIES
(e.g.GB,US,IE
). If unset, geo restriction is disabled.
Layout.tsx
Simplified 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.
Layout
changes — gate inside ArticlePage.tsx
Option B: No 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.