@ai-growth/nextjs
Version:
Seamlessly integrate Sanity CMS with Next.js applications for automated blog routing and rendering
763 lines (762 loc) • 26.6 kB
JavaScript
import { z } from 'zod';
/**
* Schema for Sanity CMS configuration
*/
const sanityConfigSchema = z.object({
/**
* Sanity project ID - unique identifier for your Sanity project
* @example "abc123def"
*/
projectId: z.string().min(1, 'Sanity project ID is required'),
/**
* Sanity dataset name - typically 'production', 'development', or 'staging'
* @example "production"
*/
dataset: z.string().min(1, 'Sanity dataset is required'),
/**
* Optional Sanity API token for authenticated requests
* Required for write operations or accessing private datasets
*/
apiToken: z.string().optional(),
/**
* Sanity API version - determines which version of the API to use
* @default "2023-05-03"
*/
apiVersion: z.string().default('2023-05-03'),
});
/**
* Schema for CMS routing configuration
*/
const cmsConfigSchema = z.object({
/**
* Base route path for CMS pages
* Must start and end with forward slashes
* @default "/cms/"
* @example "/blog/", "/content/"
*/
routePath: z
.string()
.default('/cms/')
.refine(path => path.startsWith('/') && path.endsWith('/'), 'Route path must start and end with forward slashes (e.g., "/cms/")'),
});
/**
* Complete configuration schema for the AI Growth CMS library
*/
export const configSchema = z.object({
sanity: sanityConfigSchema,
cms: cmsConfigSchema,
});
/**
* Schema for client-safe configuration (browser-accessible)
* Only includes non-sensitive data that can be safely exposed to the client
*/
const clientConfigSchema = z.object({
cms: z.object({
routePath: z
.string()
.default('/cms/')
.refine(path => path.startsWith('/') && path.endsWith('/'), {
message: 'Route path must start and end with forward slashes',
}),
}),
sanity: z.object({
projectId: z.string().optional(),
dataset: z.string().optional(),
apiVersion: z.string().default('2023-05-03'),
}),
});
/**
* Environment variable names used by the configuration system
*/
export const ENV_VARS = {
SANITY_PROJECT_ID: 'SANITY_PROJECT_ID',
SANITY_DATASET: 'SANITY_DATASET',
SANITY_API_TOKEN: 'SANITY_API_TOKEN',
SANITY_API_VERSION: 'SANITY_API_VERSION',
CMS_ROUTE_PATH: 'CMS_ROUTE_PATH',
// Next.js public environment variables
NEXT_PUBLIC_SANITY_PROJECT_ID: 'NEXT_PUBLIC_SANITY_PROJECT_ID',
NEXT_PUBLIC_SANITY_DATASET: 'NEXT_PUBLIC_SANITY_DATASET',
NEXT_PUBLIC_SANITY_API_VERSION: 'NEXT_PUBLIC_SANITY_API_VERSION',
NEXT_PUBLIC_CMS_ROUTE_PATH: 'NEXT_PUBLIC_CMS_ROUTE_PATH',
};
/**
* Error class for configuration validation failures
*/
export class ConfigurationError extends Error {
constructor(message, details) {
super(message);
this.details = details;
this.name = 'ConfigurationError';
}
}
/**
* Configuration cache to avoid re-parsing environment variables
*/
let configCache = null;
/**
* Loads and validates configuration from environment variables
*
* @param options Configuration loading options
* @param options.useCache Whether to use cached configuration (default: true)
* @param options.env Custom environment object (default: process.env)
* @returns Validated configuration object
* @throws ConfigurationError when validation fails
*
* @example
* ```typescript
* // Load configuration with caching
* const config = getConfig();
* console.log(config.sanity.projectId);
*
* // Force reload without cache
* const freshConfig = getConfig({ useCache: false });
*
* // Use custom environment (useful for testing)
* const testConfig = getConfig({
* env: { SANITY_PROJECT_ID: 'test', SANITY_DATASET: 'test' }
* });
* ```
*/
export function getConfig(options = {}) {
const { useCache = true, env = process.env } = options;
// Return cached configuration if available and caching is enabled
if (useCache && configCache) {
return configCache;
}
try {
// Create configuration object from environment variables
const rawConfig = {
sanity: {
projectId: env[ENV_VARS.SANITY_PROJECT_ID],
dataset: env[ENV_VARS.SANITY_DATASET],
apiToken: env[ENV_VARS.SANITY_API_TOKEN],
apiVersion: env[ENV_VARS.SANITY_API_VERSION],
},
cms: {
routePath: env[ENV_VARS.CMS_ROUTE_PATH],
},
};
// Validate configuration against schema
const validatedConfig = configSchema.parse(rawConfig);
// Cache the validated configuration
if (useCache) {
configCache = validatedConfig;
}
return validatedConfig;
}
catch (error) {
if (error instanceof z.ZodError) {
// Create a more helpful error message
const missingFields = [];
const invalidFields = [];
error.issues.forEach(issue => {
const field = issue.path.join('.');
if (issue.code === 'invalid_type' && issue.received === 'undefined') {
missingFields.push(field);
}
else {
invalidFields.push(`${field}: ${issue.message}`);
}
});
let errorMessage = 'Configuration validation failed:\n';
if (missingFields.length > 0) {
errorMessage += `\nMissing required environment variables:\n`;
missingFields.forEach(field => {
const envVar = getEnvVarName(field);
errorMessage += ` - ${envVar}\n`;
});
}
if (invalidFields.length > 0) {
errorMessage += `\nInvalid configuration:\n`;
invalidFields.forEach(field => {
errorMessage += ` - ${field}\n`;
});
}
errorMessage += `\nPlease check your environment variables and ensure they are properly set.`;
throw new ConfigurationError(errorMessage, error);
}
// Re-throw other errors
throw error;
}
}
/**
* Helper function to map configuration field paths to environment variable names
*/
function getEnvVarName(fieldPath) {
const mapping = {
'sanity.projectId': ENV_VARS.SANITY_PROJECT_ID,
'sanity.dataset': ENV_VARS.SANITY_DATASET,
'sanity.apiToken': ENV_VARS.SANITY_API_TOKEN,
'sanity.apiVersion': ENV_VARS.SANITY_API_VERSION,
'cms.routePath': ENV_VARS.CMS_ROUTE_PATH,
};
return mapping[fieldPath] || fieldPath;
}
/**
* Clears the configuration cache
* Useful for testing or when environment variables change at runtime
*/
export function clearConfigCache() {
configCache = null;
}
// =============================================================================
// Configuration Helper Functions
// =============================================================================
/**
* Gets the complete Sanity configuration object
*
* @param options Configuration loading options
* @returns Sanity configuration object with projectId, dataset, apiToken, and apiVersion
* @throws ConfigurationError when configuration is invalid
*
* @example
* ```typescript
* const sanityConfig = getSanityConfig();
* console.log(sanityConfig.projectId, sanityConfig.dataset);
* ```
*/
export function getSanityConfig(options) {
const config = getConfig(options);
return config.sanity;
}
/**
* Gets the CMS route path
*
* @param options Configuration loading options
* @returns The base route path for CMS pages (e.g., "/cms/")
* @throws ConfigurationError when configuration is invalid
*
* @example
* ```typescript
* const routePath = getCmsRoutePath();
* // Use in Next.js router: router.push(`${routePath}posts`)
* ```
*/
export function getCmsRoutePath(options) {
const config = getConfig(options);
return config.cms.routePath;
}
/**
* Gets configuration specific to the Sanity client initialization
* Optimized for CDN usage with published content when no token is available
*
* @param options Configuration loading options
* @returns Sanity client configuration object
*
* @example
* ```typescript
* const clientConfig = getSanityClientConfig();
* const sanityClient = createClient(clientConfig);
* ```
*/
export function getSanityClientConfig(options) {
const config = getSanityConfig(options);
const token = config.apiToken;
// For performance, automatically use CDN when no token is provided
// This optimizes for read-only access to published content
// When a token is provided, disable CDN to get real-time content updates
const useCdn = token ? false : true;
return {
projectId: config.projectId,
dataset: config.dataset,
apiVersion: config.apiVersion,
...(token ? { token } : {}),
useCdn,
};
}
/**
* Checks if a Sanity API token is available
* Useful for conditional features that require authentication
*
* @param options Configuration loading options
* @returns true if API token is configured, false otherwise
* @throws ConfigurationError when configuration is invalid
*
* @example
* ```typescript
* if (isSanityTokenAvailable()) {
* // Enable write operations
* await client.create(document);
* } else {
* // Read-only mode
* console.log('Running in read-only mode');
* }
* ```
*/
export function isSanityTokenAvailable(options) {
const sanityConfig = getSanityConfig(options);
return Boolean(sanityConfig.apiToken);
}
/**
* Gets the Sanity API version string
*
* @param options Configuration loading options
* @returns The API version string (e.g., "2023-05-03")
* @throws ConfigurationError when configuration is invalid
*
* @example
* ```typescript
* const apiVersion = getApiVersion();
* // Use in direct API calls: `https://projectId.api.sanity.io/v${apiVersion}/...`
* ```
*/
export function getApiVersion(options) {
const sanityConfig = getSanityConfig(options);
return sanityConfig.apiVersion;
}
/**
* Gets the Sanity project ID
*
* @param options Configuration loading options
* @returns The Sanity project ID string
* @throws ConfigurationError when configuration is invalid
*
* @example
* ```typescript
* const projectId = getProjectId();
* // Use for custom API endpoints or analytics
* ```
*/
export function getProjectId(options) {
const sanityConfig = getSanityConfig(options);
return sanityConfig.projectId;
}
/**
* Gets the Sanity dataset name
*
* @param options Configuration loading options
* @returns The dataset name (e.g., "production", "development")
* @throws ConfigurationError when configuration is invalid
*
* @example
* ```typescript
* const dataset = getDataset();
* // Use for environment-specific logic
* if (dataset === 'development') {
* console.log('Running in development mode');
* }
* ```
*/
export function getDataset(options) {
const sanityConfig = getSanityConfig(options);
return sanityConfig.dataset;
}
// =============================================================================
// Next.js Environment Variable Support
// =============================================================================
/**
* Configuration cache for client-safe configuration
*/
let clientConfigCache = null;
/**
* Detects if the code is running in a browser environment
*/
export function isClient() {
return typeof window !== 'undefined';
}
/**
* Detects if the code is running in a server environment
*/
export function isServer() {
return typeof window === 'undefined';
}
/**
* Gets client-safe configuration for browser environment
* Automatically falls back from NEXT_PUBLIC_ variables to regular variables if not found
*
* @param options Configuration loading options
* @returns Client-safe configuration object
*
* @example
* ```typescript
* const clientConfig = getClientConfig();
* const routePath = clientConfig.cms.routePath; // '/cms/'
* const projectId = clientConfig.sanity.projectId; // Uses NEXT_PUBLIC_SANITY_PROJECT_ID or SANITY_PROJECT_ID
* const dataset = clientConfig.sanity.dataset; // Uses NEXT_PUBLIC_SANITY_DATASET or SANITY_DATASET
* const apiVersion = clientConfig.sanity.apiVersion; // '2023-05-03'
* ```
*/
export function getClientConfig(options = {}) {
const { useCache = true, env = process.env } = options;
// Return cached configuration if available and caching is enabled
if (useCache && clientConfigCache) {
return clientConfigCache;
}
try {
// Create client configuration object with fallback to NEXT_PUBLIC_ variables
// If NEXT_PUBLIC_ variable is not available, fall back to regular variable
const rawConfig = {
cms: {
routePath: env[ENV_VARS.NEXT_PUBLIC_CMS_ROUTE_PATH] || env[ENV_VARS.CMS_ROUTE_PATH],
},
sanity: {
projectId: env[ENV_VARS.NEXT_PUBLIC_SANITY_PROJECT_ID] || env[ENV_VARS.SANITY_PROJECT_ID],
dataset: env[ENV_VARS.NEXT_PUBLIC_SANITY_DATASET] || env[ENV_VARS.SANITY_DATASET],
apiVersion: env[ENV_VARS.NEXT_PUBLIC_SANITY_API_VERSION] || env[ENV_VARS.SANITY_API_VERSION],
},
};
// Validate configuration against client schema
const validatedConfig = clientConfigSchema.parse(rawConfig);
// Cache the validated configuration
if (useCache) {
clientConfigCache = validatedConfig;
}
return validatedConfig;
}
catch (error) {
if (error instanceof z.ZodError) {
let errorMessage = 'Client configuration validation failed:\n';
error.issues.forEach(issue => {
const field = issue.path.join('.');
errorMessage += ` - ${field}: ${issue.message}\n`;
});
errorMessage += `\nNote: Client configuration only includes non-sensitive values.`;
errorMessage += `\nFor server-side configuration, use getConfig() instead.`;
throw new ConfigurationError(errorMessage, error);
}
throw error;
}
}
/**
* Gets configuration specifically from NEXT_PUBLIC_ environment variables
* These are the only variables available in the browser in Next.js
*
* @param options Configuration loading options
* @returns Public configuration object with only NEXT_PUBLIC_ variables
*
* @example
* ```typescript
* // Only works with NEXT_PUBLIC_ prefixed variables
* const publicConfig = getPublicConfig();
* ```
*/
export function getPublicConfig(options = {}) {
const { env = process.env } = options;
// Force to only use NEXT_PUBLIC_ variables
const publicEnv = {
[ENV_VARS.SANITY_PROJECT_ID]: env[ENV_VARS.NEXT_PUBLIC_SANITY_PROJECT_ID],
[ENV_VARS.SANITY_DATASET]: env[ENV_VARS.NEXT_PUBLIC_SANITY_DATASET],
[ENV_VARS.CMS_ROUTE_PATH]: env[ENV_VARS.NEXT_PUBLIC_CMS_ROUTE_PATH],
[ENV_VARS.SANITY_API_VERSION]: env[ENV_VARS.NEXT_PUBLIC_SANITY_API_VERSION],
};
return getClientConfig({ ...options, env: publicEnv });
}
/**
* Clears the client configuration cache
* Useful for testing or when environment variables change at runtime
*/
export function clearClientConfigCache() {
clientConfigCache = null;
}
/**
* Gets a configuration object appropriate for the current environment
* Returns full configuration on server, client-safe configuration in browser
*
* @param options Configuration loading options
* @returns Configuration object appropriate for current environment
*
* @example
* ```typescript
* // Automatically chooses the right config based on environment
* const config = getEnvironmentConfig();
*
* // On server: includes projectId, dataset, apiToken, etc.
* // In browser: only includes apiVersion and routePath
* ```
*/
export function getEnvironmentConfig(options = {}) {
if (isClient()) {
return getClientConfig(options);
}
else {
// On server, we can safely return full config, but we'll cast to the union type
return getConfig(options);
}
}
/**
* Helper function to get CMS route path that works in both environments
* Uses client-safe configuration in browser, full configuration on server
*
* @param options Configuration loading options
* @returns CMS route path string
*
* @example
* ```typescript
* // Works in both server and client
* const routePath = getCmsRoutePathSafe();
* ```
*/
export function getCmsRoutePathSafe(options) {
if (isClient()) {
const clientConfig = getClientConfig(options);
return clientConfig.cms.routePath;
}
else {
return getCmsRoutePath(options);
}
}
/**
* Helper function to get API version that works in both environments
* Uses client-safe configuration in browser, full configuration on server
*
* @param options Configuration loading options
* @returns API version string
*
* @example
* ```typescript
* // Works in both server and client
* const apiVersion = getApiVersionSafe();
* ```
*/
export function getApiVersionSafe(options) {
if (isClient()) {
const clientConfig = getClientConfig(options);
return clientConfig.sanity.apiVersion;
}
else {
return getApiVersion(options);
}
}
/**
* Helper function to get project ID that works in both environments
* Uses client-safe configuration in browser (if available), full configuration on server
*
* @param options Configuration loading options
* @returns Project ID string or undefined if not available in client environment
*
* @example
* ```typescript
* // Works in both server and client
* const projectId = getProjectIdSafe();
* if (projectId) {
* // Use project ID
* }
* ```
*/
export function getProjectIdSafe(options) {
if (isClient()) {
const clientConfig = getClientConfig(options);
return clientConfig.sanity.projectId;
}
else {
return getProjectId(options);
}
}
/**
* Helper function to get dataset that works in both environments
* Uses client-safe configuration in browser (if available), full configuration on server
*
* @param options Configuration loading options
* @returns Dataset string or undefined if not available in client environment
*
* @example
* ```typescript
* // Works in both server and client
* const dataset = getDatasetSafe();
* if (dataset) {
* // Use dataset
* }
* ```
*/
export function getDatasetSafe(options) {
if (isClient()) {
const clientConfig = getClientConfig(options);
return clientConfig.sanity.dataset;
}
else {
return getDataset(options);
}
}
/**
* Validates configuration at startup and provides detailed feedback
* Useful for verifying all required configuration is present before the application starts
*
* @param options Validation options
* @param options.env Custom environment object (default: process.env)
* @param options.throwOnError Whether to throw an error if validation fails (default: false)
* @returns Detailed validation result with errors, warnings, and config if valid
*
* @example
* ```typescript
* // Validate configuration at app startup
* const result = validateConfiguration();
* if (!result.isValid) {
* console.error('Configuration errors:', result.errors);
* process.exit(1);
* }
*
* // In development, show warnings too
* if (result.warnings.length > 0) {
* console.warn('Configuration warnings:', result.warnings);
* }
* ```
*/
export function validateConfiguration(options = {}) {
const { env = process.env, throwOnError = false } = options;
const environment = isClient() ? 'client' : 'server';
const errors = [];
const warnings = [];
let config;
try {
if (environment === 'server') {
// Validate full server configuration
const serverConfig = getConfig({ env, useCache: false });
config = serverConfig;
// Check for common configuration issues
if (serverConfig.sanity.apiToken && serverConfig.sanity.apiToken.startsWith('sk_')) {
// Valid API token format
}
else if (serverConfig.sanity.apiToken) {
warnings.push('SANITY_API_TOKEN does not appear to be a valid Sanity API token (should start with "sk_")');
}
else {
warnings.push('SANITY_API_TOKEN is not set - some features may be limited to read-only access');
}
// Check dataset naming
if (serverConfig.sanity.dataset &&
!['production', 'development', 'staging'].includes(serverConfig.sanity.dataset)) {
warnings.push(`Dataset "${serverConfig.sanity.dataset}" is not a standard name (consider: production, development, staging)`);
}
}
else {
// Validate client configuration
config = getClientConfig({ env, useCache: false });
// Check for client-side best practices
const hasPublicProjectId = env[ENV_VARS.NEXT_PUBLIC_SANITY_PROJECT_ID];
const hasPublicDataset = env[ENV_VARS.NEXT_PUBLIC_SANITY_DATASET];
const hasPublicApiVersion = env[ENV_VARS.NEXT_PUBLIC_SANITY_API_VERSION];
const hasPublicRoutePath = env[ENV_VARS.NEXT_PUBLIC_CMS_ROUTE_PATH];
if (!hasPublicProjectId && !hasPublicDataset && !hasPublicApiVersion && !hasPublicRoutePath) {
warnings.push('No NEXT_PUBLIC_ variables set - consider using NEXT_PUBLIC_SANITY_PROJECT_ID, NEXT_PUBLIC_SANITY_DATASET, NEXT_PUBLIC_SANITY_API_VERSION and NEXT_PUBLIC_CMS_ROUTE_PATH for client-side access');
}
}
return {
isValid: true,
errors,
warnings,
config,
environment,
};
}
catch (error) {
let errorMessage;
if (error instanceof ConfigurationError) {
errorMessage = error.message;
}
else if (error instanceof Error) {
errorMessage = `Configuration validation failed: ${error.message}`;
}
else {
errorMessage = 'Unknown configuration validation error';
}
errors.push(errorMessage);
if (throwOnError) {
throw new ConfigurationError(`Configuration validation failed:\n${errors.join('\n')}`, error instanceof ConfigurationError ? error.details : undefined);
}
return {
isValid: false,
errors,
warnings,
environment,
};
}
}
/**
* Gets the current configuration status and health
* Provides a quick overview of configuration state without throwing errors
*
* @param options Configuration options
* @returns Configuration status information
*
* @example
* ```typescript
* const status = getConfigurationStatus();
* console.log(`Environment: ${status.environment}`);
* console.log(`Valid: ${status.isValid}`);
* console.log(`Has Token: ${status.hasApiToken}`);
* ```
*/
export function getConfigurationStatus(options = {}) {
const { env = process.env } = options;
const environment = isClient() ? 'client' : 'server';
const requiredVars = environment === 'server' ? [ENV_VARS.SANITY_PROJECT_ID, ENV_VARS.SANITY_DATASET] : []; // Client has no required vars (all have defaults)
const allVars = [
ENV_VARS.SANITY_PROJECT_ID,
ENV_VARS.SANITY_DATASET,
ENV_VARS.SANITY_API_TOKEN,
ENV_VARS.SANITY_API_VERSION,
ENV_VARS.CMS_ROUTE_PATH,
ENV_VARS.NEXT_PUBLIC_SANITY_PROJECT_ID,
ENV_VARS.NEXT_PUBLIC_SANITY_DATASET,
ENV_VARS.NEXT_PUBLIC_SANITY_API_VERSION,
ENV_VARS.NEXT_PUBLIC_CMS_ROUTE_PATH,
];
const configuredVariables = allVars.filter(varName => env[varName] && env[varName].trim() !== '');
const missingRequiredVariables = requiredVars.filter(varName => !env[varName] || env[varName].trim() === '');
const hasApiToken = Boolean(env[ENV_VARS.SANITY_API_TOKEN]);
const hasPublicVariables = Boolean(env[ENV_VARS.NEXT_PUBLIC_SANITY_PROJECT_ID] ||
env[ENV_VARS.NEXT_PUBLIC_SANITY_DATASET] ||
env[ENV_VARS.NEXT_PUBLIC_SANITY_API_VERSION] ||
env[ENV_VARS.NEXT_PUBLIC_CMS_ROUTE_PATH]);
const isValid = missingRequiredVariables.length === 0;
return {
environment,
isValid,
hasApiToken,
hasPublicVariables,
configuredVariables,
missingRequiredVariables,
};
}
/**
* Debug utility to display current configuration state
* Useful during development to understand what configuration is loaded
*
* @param options Debug options
* @param options.env Custom environment object (default: process.env)
* @param options.showValues Whether to show actual values (default: false for security)
* @param options.maskSensitive Whether to mask sensitive values (default: true)
* @returns Debug information object
*
* @example
* ```typescript
* // Safe debugging (masks sensitive values)
* const debug = debugConfiguration();
* console.log('Debug Info:', debug);
*
* // Development debugging (shows values but still masks sensitive)
* const debugWithValues = debugConfiguration({ showValues: true });
* console.log('Config Values:', debugWithValues);
* ```
*/
export function debugConfiguration(options = {}) {
const { env = process.env, showValues = false, maskSensitive = true } = options;
const sensitiveVars = [ENV_VARS.SANITY_API_TOKEN];
const environmentVariables = {};
if (showValues) {
Object.values(ENV_VARS).forEach(varName => {
const value = env[varName];
if (value &&
maskSensitive &&
sensitiveVars.includes(varName)) {
environmentVariables[varName] = value.substring(0, 8) + '***';
}
else {
environmentVariables[varName] = value;
}
});
}
else {
Object.values(ENV_VARS).forEach(varName => {
environmentVariables[varName] = env[varName] ? '***set***' : undefined;
});
}
return {
environment: isClient() ? 'client' : 'server',
timestamp: new Date().toISOString(),
configurationStatus: getConfigurationStatus({ env }),
validationResult: validateConfiguration({ env, throwOnError: false }),
environmentVariables,
};
}