When building headless Sitecore implementations with Next.js, securing specific pages or sections of your website is a common requirement. Whether it's user profiles, admin areas, or premium content, you need a robust way to verify authentication and redirect unauthorized users to a login page—without writing custom server-side code for each route.
This post walks through implementing a Secured Pages SXA Module using Next.js middleware architecture, similar to Sitecore's out-of-the-box redirect module. The solution provides a flexible, configuration-driven approach to protect pages based on path patterns stored in Sitecore.
The Challenge
In headless Sitecore implementations, traditional server-side authentication checks aren't always feasible. You need:
- Flexible path matching: Support for both exact paths (
/Profile) and regex patterns (/securedTree*)
- Sitecore-driven configuration: Content authors should be able to configure secured paths without code deployments
- Middleware-based protection: Leverage Next.js middleware for efficient request interception
- GraphQL integration: Fetch security mappings from Sitecore's GraphQL endpoint
- Performance: Cache security mappings to avoid repeated API calls
Solution Overview
The Secured Pages SXA Module consists of three main components:
- SecuredPagesMiddleware: Next.js middleware that intercepts requests and checks authentication
- GraphQLSecuredPagesService: Service that fetches security mappings from Sitecore via GraphQL
- Sitecore Content Structure: Templates and items that store the security configuration
Architecture Flow
Request → Middleware → Check Authentication Token
↓
Is Authorized?
/ \
Yes No
↓ ↓
Allow Request Check Sitecore Mappings
↓
Path Matches Secured Pattern?
/ \
Yes No
↓ ↓
Redirect to Login Allow Request
Sitecore Setup
Step 1: Create the Secured Pages Template
First, create a template in Sitecore to store security mappings:
-
Navigate to /sitecore/templates/Project/YourProject/Security
-
Create a new template named Secured Pages
-
Add the following fields:
- UrlMapping (Multi-Line Text)
- Description: "Enter relative paths or regex patterns (one per line). Examples:
/Profile, /securedTree*"
- LoginRedirectUrl (Single-Line Text)
- Description: "Relative URL to redirect unauthorized users (e.g.,
/login)"
-
Set the template ID and note it for use in your constants file:
// lib/constants.ts
export const securedPagesTemplateId = '{YOUR-TEMPLATE-ID-HERE}';
Step 2: Create Security Mappings Container
- Navigate to your site's root item (e.g.,
/sitecore/content/YourSite)
- Create a folder named
Settings (if it doesn't exist)
- Under Settings, create a folder named
Security Mappings
- Set insert options on
Security Mappings to allow Secured Pages template
Step 3: Create Security Mapping Items
For each set of secured paths:
- Navigate to
Settings/Security Mappings
- Insert →
Secured Pages
- Configure:
Code Implementation
Step 1: Create Constants File
// lib/constants.ts
export const securedPagesTemplateId = '{YOUR-TEMPLATE-ID-HERE}';
Step 2: GraphQL Service Implementation
The GraphQLSecuredPagesService fetches security mappings from Sitecore:
// lib/middleware/graphql-auth-service.ts
import {
GraphQLRequestClient,
GraphQLRequestClientFactory,
} from '@sitecore-jss/sitecore-jss-nextjs/graphql';
import { securedPagesTemplateId } from '../constants';
export const GetItemUrl = /* GraphQL */ `
query GetItemIdByPath($path: String, $lang: String = "en") {
item(path: $path, language: $lang) {
id
}
}
`;
const SiteRootQuery = /* GraphQL */ `
query SiteRootQuery($siteName: String!) {
site {
siteInfo(site: $siteName) {
rootPath
}
}
}
`;
const defaultQuery = /* GraphQL */ `
query SearchSecuredPages($pathId: String!, $templateId: String!) {
search(
where: {
AND: [
{ name: "_path", value: $pathId, operator: CONTAINS }
{ name: "_templates", value: $templateId, operator: CONTAINS }
]
}
first: 1
) {
total
pageInfo {
endCursor
hasNext
}
results {
UrlMapping: field(name: "UrlMapping") {
value
}
LoginRedirectUrl: field(name: "LoginRedirectUrl") {
value
}
}
}
}
`;
export type GraphQLSecuredPagesServiceConfig = {
endpoint?: string;
apiKey?: string;
fetch?: typeof fetch;
clientFactory?: GraphQLRequestClientFactory;
cacheEnabled?: boolean;
cacheTimeout?: number;
};
export type SecuredPagesQueryResult = {
search: {
total: number;
results: resultEntry[] | null;
};
};
export type resultEntry = {
UrlMapping: resultField;
LoginRedirectUrl: resultField;
};
export type resultField = {
value: string;
};
type SiteRootQueryResult = {
site: { siteInfo: { rootPath: string } | null };
};
type TItem = {
id: string;
};
type TGetItemId = {
item: TItem;
};
export class GraphQLSecuredPagesService {
private graphQLClient: GraphQLRequestClient;
private cache: Map<string, { value: SecuredPagesQueryResult; expiresAt: number }> = new Map();
protected get siteRootQuery(): string {
return SiteRootQuery;
}
protected get query(): string {
return defaultQuery;
}
constructor(private options: GraphQLSecuredPagesServiceConfig) {
this.graphQLClient = this.getGraphQLClient();
}
async fetchSecuredPages({ siteName }: { siteName: string }): Promise<SecuredPagesQueryResult> {
if (!siteName) {
throw new Error('The siteName cannot be empty');
}
// Check cache first
const cacheKey = `auth-${siteName}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() < cached.expiresAt) {
return cached.value;
}
// Fetch from Sitecore
const siteRoot = await this.graphQLClient.request<SiteRootQueryResult>(this.siteRootQuery, {
siteName,
});
if (!siteRoot?.site?.siteInfo?.rootPath) {
return { search: { total: 0, results: [] } };
}
const settingItemPath = siteRoot.site.siteInfo.rootPath + '/Settings';
const settingItemData = await this.graphQLClient.request<TGetItemId>(GetItemUrl, {
path: settingItemPath,
});
if (!settingItemData?.item?.id) {
return { search: { total: 0, results: [] } };
}
const pathId = settingItemData.item.id;
const securedPages = await this.graphQLClient.request<SecuredPagesQueryResult>(
this.query,
{
pathId,
templateId: securedPagesTemplateId,
}
);
// Cache the result
if (securedPages) {
const cacheTimeout = (this.options.cacheTimeout || 10) * 1000;
this.cache.set(cacheKey, {
value: securedPages,
expiresAt: Date.now() + cacheTimeout,
});
return securedPages;
}
return { search: { total: 0, results: [] } };
}
protected getGraphQLClient(): GraphQLRequestClient {
if (!this.options.endpoint) {
if (!this.options.clientFactory) {
throw new Error('You should provide either an endpoint and apiKey, or a clientFactory.');
}
return this.options.clientFactory({
fetch: this.options.fetch,
});
}
return new GraphQLRequestClient(this.options.endpoint, {
apiKey: this.options.apiKey,
fetch: this.options.fetch,
});
}
}
Key Features:
- Caching: Uses in-memory cache to avoid repeated GraphQL calls (default: 10 seconds)
- Path Resolution: Dynamically resolves the site's Settings folder path
- Template Filtering: Searches for items using the
Secured Pages template ID
Note: The caching implementation uses a simple in-memory Map. For production environments with multiple instances, consider implementing a distributed cache solution.
Step 3: Middleware Implementation
The middleware intercepts requests and enforces authentication:
// lib/middleware/secured-pages-middleware.ts
import { SiteInfo } from '@sitecore-jss/sitecore-jss-nextjs';
import { MiddlewareBase, MiddlewareBaseConfig } from '@sitecore-jss/sitecore-jss-nextjs/middleware';
import { getToken } from 'next-auth/jwt';
import { NextRequest, NextResponse } from 'next/server';
import regexParser from 'regex-parser';
import {
GraphQLSecuredPagesService,
GraphQLSecuredPagesServiceConfig,
resultEntry,
} from './graphql-auth-service';
export type SecuredPagesMiddlewareConfig = Omit<GraphQLSecuredPagesServiceConfig, 'fetch'> &
MiddlewareBaseConfig & {
protectedRoutes?: string[];
defaultLoginPage?: string;
locales: string[];
};
export class SecuredPagesMiddleware extends MiddlewareBase {
private securedPagesService: GraphQLSecuredPagesService;
constructor(protected config: SecuredPagesMiddlewareConfig) {
super(config);
this.securedPagesService = new GraphQLSecuredPagesService({ ...config, fetch: fetch });
}
public getHandler(): (req: NextRequest, res?: NextResponse) => Promise<NextResponse> {
return async (req, res) => {
return await this.handler(req, res);
};
}
private handler = async (req: NextRequest, res?: NextResponse): Promise<NextResponse> => {
// Skip if disabled or in preview mode
if (
(this.config.disabled && this.config.disabled(req, res || NextResponse.next())) ||
this.isPreview(req) ||
this.excludeRoute(req.nextUrl.pathname)
) {
return res || NextResponse.next();
}
const site = this.getSite(req, res);
const isSecured = this.config.protectedRoutes?.includes(req.nextUrl.pathname);
const existsSecuredPageMapping = await this.getExistsSecuredPages(req, site.name);
// Allow if not secured
if (!isSecured && !existsSecuredPageMapping) {
return res || NextResponse.next();
}
// Check authorization
if (await this.isAuthorized(req)) {
return res || NextResponse.next();
}
// Redirect to login
const url = req.nextUrl.clone();
url.pathname =
existsSecuredPageMapping?.LoginRedirectUrl?.value || this.config.defaultLoginPage || '/';
url.locale = req.nextUrl.locale;
return NextResponse.redirect(decodeURIComponent(url.href));
};
private async isAuthorized(req: NextRequest): Promise<boolean> {
const secret = process.env.NEXTAUTH_JWT_SECRET;
const token = await getToken({ req, secret });
return !!token;
}
private async getExistsSecuredPages(
req: NextRequest,
siteName: string
): Promise<resultEntry | undefined> {
const securedPages = await this.securedPagesService.fetchSecuredPages({ siteName });
if (!securedPages?.search?.results || securedPages.search.total === 0) {
return undefined;
}
const pathname = req.nextUrl.pathname.toLowerCase();
const pathnameWithLocale = `/${req.nextUrl.locale}${req.nextUrl.pathname}`.toLowerCase();
// Check each secured page mapping
for (const result of securedPages.search.results) {
const urlMappings = result?.UrlMapping?.value;
if (!urlMappings) continue;
const paths = urlMappings.split(/\r?\n/);
for (const urlMapping of paths) {
if (!urlMapping.trim()) continue;
const pattern = regexParser(urlMapping.toLowerCase());
const matches = pattern.test(pathname) || pattern.test(pathnameWithLocale);
if (matches && result.LoginRedirectUrl) {
return result;
}
}
}
return undefined;
}
}
Key Features:
- Token Verification: Uses
next-auth/jwt to verify authentication tokens
- Path Matching: Supports both exact paths and regex patterns
- Locale Awareness: Matches paths with and without locale prefixes
- Preview Mode: Skips authentication checks in Sitecore preview mode
- Configurable Routes: Supports both Sitecore-driven and code-based protected routes
Configuration
Environment Variables
Add to your .env file:
# Enable/disable the secured pages module
Enable_Auth_Secured_Pages=true
# NextAuth JWT secret (required for token verification)
NEXTAUTH_JWT_SECRET=your-secret-key-here
Middleware Integration
Integrate the middleware in your Next.js middleware file:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { createGraphQLClientFactory } from '@sitecore-jss/sitecore-jss-nextjs';
import { SecuredPagesMiddleware } from './lib/middleware/secured-pages-middleware';
const securedPagesMiddleware = new SecuredPagesMiddleware({
// GraphQL configuration
clientFactory: createGraphQLClientFactory({
endpoint: process.env.GRAPH_QL_ENDPOINT!,
apiKey: process.env.SITECORE_API_KEY!,
}),
// Middleware configuration
defaultHostname: process.env.SITECORE_DEFAULT_HOSTNAME || 'localhost',
locales: ['en', 'es', 'fr'], // Add your locales
defaultLoginPage: '/login',
// Optional: Code-based protected routes (in addition to Sitecore config)
protectedRoutes: ['/admin', '/dashboard'],
// Cache configuration
cacheEnabled: true,
cacheTimeout: 10, // seconds
// Exclude routes from authentication checks
excludeRoute: (pathname: string) => {
return pathname.startsWith('/api') || pathname.startsWith('/_next');
},
// Disable middleware conditionally
disabled: (req: NextRequest) => {
return process.env.Enable_Auth_Secured_Pages !== 'true';
},
});
export async function middleware(req: NextRequest) {
// Run other middlewares first (e.g., Sitecore JSS middleware)
// Then run secured pages middleware
return securedPagesMiddleware.getHandler()(req);
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
Step-by-Step Setup Guide
1. Install Dependencies
npm install regex-parser next-auth
2. Create File Structure
lib/
├── constants.ts
└── middleware/
├── secured-pages-middleware.ts
└── graphql-auth-service.ts
- Create the
Secured Pages template with UrlMapping and LoginRedirectUrl fields
- Create
Settings/Security Mappings folder under your site root
- Create security mapping items as needed
4. Update Environment Variables
Add Enable_Auth_Secured_Pages and NEXTAUTH_JWT_SECRET to your .env file.
5. Integrate Middleware
Add the middleware configuration to middleware.ts as shown above.
6. Test the Implementation
- Test Unauthorized Access: Visit a secured path without authentication → should redirect to login
- Test Authorized Access: Login and visit secured path → should allow access
- Test Regex Patterns: Verify that patterns like
/securedTree* match correctly
Advanced Configuration
Multiple Security Mappings
You can create multiple Secured Pages items under Security Mappings, each with different path patterns and redirect URLs. The middleware will check all mappings and use the first match.
Combining Code-Based and Sitecore-Based Routes
The middleware supports both approaches:
- Code-based: Define
protectedRoutes in middleware config
- Sitecore-based: Configure paths in Sitecore items
Both are checked, and either can trigger authentication.
Custom Authentication Logic
To use a different authentication provider, modify the isAuthorized method:
private async isAuthorized(req: NextRequest): Promise<boolean> {
// Example: Check custom session cookie
const sessionCookie = req.cookies.get('session');
return !!sessionCookie?.value;
// Or: Check custom header
const authHeader = req.headers.get('Authorization');
return authHeader?.startsWith('Bearer ') ?? false;
}
- Caching: Security mappings are cached for 10 seconds by default. Adjust
cacheTimeout based on your needs.
- GraphQL Efficiency: The service uses a single GraphQL query to fetch all mappings for a site.
- Middleware Overhead: Middleware runs on every request. Use
excludeRoute to skip static assets and API routes.
Troubleshooting
Mappings Not Found
- Verify the template ID in
constants.ts matches your Sitecore template
- Check that items exist under
Settings/Security Mappings
- Ensure the
UrlMapping field is populated
Redirects Not Working
- Verify
LoginRedirectUrl is set in Sitecore items
- Check that
defaultLoginPage is configured in middleware
- Ensure the login page route exists
Authentication Not Detected
- Verify
NEXTAUTH_JWT_SECRET is set correctly
- Check that your authentication provider sets tokens correctly
Final Thoughts
The Secured Pages SXA Module provides a flexible, maintainable solution for protecting pages in headless Sitecore implementations. By leveraging Next.js middleware and Sitecore's content management capabilities, you can:
- Empower content authors to configure security without code changes
- Maintain performance through intelligent caching
- Support complex scenarios with regex pattern matching
- Integrate seamlessly with existing authentication providers
This approach follows Sitecore best practices by keeping configuration in the CMS while leveraging Next.js's powerful middleware capabilities for request handling.
Ready to secure your headless site?
👉 Follow the step-by-step guide above to implement the module in your Next.js Sitecore project.