Reading time: 15 min read

Dynamic GraphQL Queries in Sitecore XM Cloud with Next.js

A practical guide to building dynamic, user-driven GraphQL queries for Sitecore XM Cloud with real-world examples from a production file manager component.

Portrait photo of Sohrab Saboori, article author

Why Use Dynamic GraphQL Queries?

Static GraphQL queries work well for predictable data needs, but modern applications often require flexibility based on user interactions. When building Sitecore integrations, you’ll encounter scenarios where query parameters change based on user selections—navigating folder structures, filtering content, or loading paginated results. Hard-coding these queries for every possible path or filter combination is impractical and unmaintainable.

This guide demonstrates how to build dynamic GraphQL queries that respond to user actions in real-time. We’ll use a production file manager component as our example—a browser that lets users navigate Sitecore’s content tree, select items, and handle pagination dynamically. The patterns shown here apply to any scenario where query parameters depend on runtime conditions rather than build-time configuration.

Prerequisites

Before building dynamic queries, ensure you have:

  1. Sitecore API authentication configured
  2. Next.js API routes set up for GraphQL proxy
  3. Basic understanding of GraphQL query structure
  4. React hooks knowledge for state management

Required Setup:

# Environment variables (from authentication guide)
SITECORE_GRAPHQL_ENDPOINT=https://xmc-[instance].sitecorecloud.io/sitecore/api/graph/edge
SITECORE_API_KEY=your_api_key

Understanding Dynamic Queries

Static vs Dynamic Queries

Static Query (fixed at build time):

const query = `
  query {
    item(path: "/sitecore/content/Home", language: "en") {
      children {
        results {
          name
        }
      }
    }
  }
`;

Dynamic Query (changes based on user input):

const query = `
  query GetSitecoreItems($path: String!, $language: String!) {
    item(path: $path, language: $language) {
      children {
        results {
          name
        }
      }
    }
  }
`;
// Variables change based on user selection
const variables = {
  path: userSelectedPath,  // Changes dynamically
  language: currentLanguage
};

Key Concepts

  • GraphQL Variables: Parameterized queries that accept runtime values
  • State Management: React state to track user selections and query parameters
  • Pagination: Cursor-based pagination for large result sets
  • Error Handling: Graceful handling of invalid paths or missing data

GraphQL Proxy API Route

File: src/pages/api/sitecore/graphql.ts

Purpose: Provides a server-side proxy for GraphQL requests. This keeps your API key secure (never exposed to the browser) and allows dynamic queries from the frontend without authentication complexity.

import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }
  try {
    const { query, variables } = req.body;
    if (!query) {
      return res.status(400).json({ error: "GraphQL query is required" });
    }
    // Get Sitecore configuration from environment
    const endpoint = process.env.SITECORE_GRAPHQL_ENDPOINT;
    const apiKey = process.env.SITECORE_API_KEY;
    if (!endpoint || !apiKey) {
      return res.status(500).json({ error: "Sitecore not configured" });
    }
    // Execute GraphQL query against Sitecore
    const response = await fetch(endpoint, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        sc_apikey: apiKey,
      },
      body: JSON.stringify({
        query,
        variables: variables || {},
      }),
    });
    if (!response.ok) {
      throw new Error(`Sitecore API error: ${response.status}`);
    }
    const data = await response.json();
    // Return the GraphQL response
    res.status(200).json(data);
  } catch (error) {
    console.error("GraphQL proxy error:", error);
    res.status(500).json({
      error: "Failed to execute GraphQL query",
      details: error instanceof Error ? error.message : "Unknown error",
    });
  }
}

What this does:

  1. 🔒 Secures API Key - Keeps credentials server-side only
  2. 🔄 Proxies Requests - Forwards GraphQL queries to Sitecore
  3. Validates Input - Checks for required query parameter
  4. 📊 Returns Data - Passes Sitecore response back to frontend

Building the File Manager Component

Component Structure

File: src/components/sitecore-file-manager.tsx

Purpose: A production-ready file browser that demonstrates dynamic GraphQL queries in action. Users can navigate Sitecore’s content tree, and each navigation action triggers a new GraphQL query with updated path parameters.

State Management

import { useState, useEffect } from "react";
export function SitecoreFileManager({ 
  onSelectPath, 
  language = "en",
  rootPath = "/sitecore/content" 
}) {
  // Dynamic query parameters
  const [currentPath, setCurrentPath] = useState(rootPath);
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  // Pagination state
  const [currentPage, setCurrentPage] = useState(0);
  const [hasNextPage, setHasNextPage] = useState(false);
  const [endCursor, setEndCursor] = useState(null);
  const [totalCount, setTotalCount] = useState(0);
  // UI state
  const [highlightedItem, setHighlightedItem] = useState(null);
  const [pathHistory, setPathHistory] = useState([rootPath]);
  const ITEMS_PER_PAGE = 20;
  // ... component logic
}

What this manages:

  1. 📂 Current Path - Tracks user’s location in content tree
  2. 📄 Items - Stores current folder’s children
  3. 🔄 Loading State - Shows loading indicators
  4. 📑 Pagination - Handles large result sets
  5. 🎯 Selection - Tracks highlighted item

Dynamic Query Execution

Purpose: Fetches items from Sitecore based on the current path. This function is called whenever the user navigates to a different folder, demonstrating how queries adapt to user actions.


const fetchItems = async (path, page = 0, append = false) => {
  setLoading(true);
  if (!append) {
    setError(null);
  }
  try {
    // Build dynamic GraphQL query with variables
    const query = `
      query GetSitecoreItems($path: String!, $language: String!, $first: Int!, $after: String) {
        item(path: $path, language: $language) {
          children(first: $first, after: $after) {
            total
            pageInfo {
              hasNext
              endCursor
            }
            results {
              id
              name
              path
            }
          }
        }
      }
    `;
    // Variables change based on user's current location
    const variables = {
      path: path,                           // Dynamic: user's selected path
      language: language,                   // Dynamic: current language
      first: ITEMS_PER_PAGE,               // Static: page size
      after: append ? endCursor : null,    // Dynamic: pagination cursor
    };
    // Call our proxy API route
    const response = await fetch("/api/sitecore/graphql", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ query, variables }),
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const data = await response.json();
    if (data.errors) {
      throw new Error(data.errors[0]?.message || "GraphQL error");
    }
    // Process results
    if (data.data?.item?.children) {
      const children = data.data.item.children;
      const newItems = children.results.map(item => ({
        id: item.id,
        name: item.name,
        path: item.path || `${path}/${item.name}`,
        hasChildren: true,
      }));
      // Append or replace items based on pagination
      if (append) {
        setItems(prev => [...prev, ...newItems]);
      } else {
        setItems(newItems);
      }
      // Update pagination state
      setTotalCount(children.total || newItems.length);
      setHasNextPage(children.pageInfo?.hasNext || false);
      setEndCursor(children.pageInfo?.endCursor || null);
    } else {
      if (!append) {
        setItems([]);
        setTotalCount(0);
        setHasNextPage(false);
      }
    }
  } catch (err) {
    setError(err instanceof Error ? err.message : "Failed to fetch items");
    if (!append) {
      setItems([]);
    }
  } finally {
    setLoading(false);
  }
};

What this does:

  1. 🔍 Dynamic Variables - Query parameters change based on user location
  2. 📡 API Call - Sends query to proxy endpoint
  3. Error Handling - Catches and displays errors gracefully
  4. 📊 Data Processing - Transforms Sitecore response for UI
  5. 📑 Pagination - Handles cursor-based pagination

User Navigation Handlers

Purpose: Responds to user interactions by updating the current path and triggering new queries. These handlers demonstrate how user actions drive dynamic query execution.

// Trigger query when path changes
useEffect(() => {
  setCurrentPage(0);
  setHighlightedItem(null);
  setEndCursor(null);
  fetchItems(currentPath);
}, [currentPath, language]);
// Navigate into a folder
const handleNavigateInto = (item) => {
  if (item.hasChildren && item.path) {
    setCurrentPath(item.path);                    // Update path
    setPathHistory(prev => [...prev, item.path]); // Track history
    setHighlightedItem(null);
    setCurrentPage(0);
    setEndCursor(null);
    // fetchItems will be called automatically by useEffect
  }
};
// Navigate back to previous folder
const handleBackClick = () => {
  if (pathHistory.length > 1) {
    const newHistory = pathHistory.slice(0, -1);
    const previousPath = newHistory[newHistory.length - 1];
    setCurrentPath(previousPath);  // Update path triggers new query
    setPathHistory(newHistory);
    setHighlightedItem(null);
    setCurrentPage(0);
    setEndCursor(null);
  }
};
// Go to root folder
const handleGoToRoot = () => {
  setCurrentPath(rootPath);        // Update path triggers new query
  setPathHistory([rootPath]);
  setHighlightedItem(null);
  setCurrentPage(0);
  setEndCursor(null);
};

What this does:

  1. 🔄 Auto-Fetch - useEffect triggers query when path changes
  2. 📂 Navigation - Updates path state to navigate folders
  3. ⬅️ Back Button - Returns to previous folder
  4. 🏠 Home Button - Returns to root folder
  5. 🧹 State Reset - Clears pagination when navigating

Infinite Scroll Pagination

Purpose: Loads more items as the user scrolls, providing a seamless browsing experience for large folders. This demonstrates dynamic pagination with cursor-based queries.

// Load more items when scrolling
const loadMoreItems = () => {
  if (hasNextPage && !loading) {
    const nextPage = currentPage + 1;
    setCurrentPage(nextPage);
    // Fetch next page with cursor (append to existing items)
    fetchItems(currentPath, nextPage, true);
  }
};
// Scroll event handler
<div
  className="h-96 overflow-auto"
  onScroll={(e) => {
    const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
    const isNearBottom = scrollTop + clientHeight >= scrollHeight - 10;
    if (isNearBottom && hasNextPage && !loading) {
      loadMoreItems();
    }
  }}
>
  {/* Items list */}
  {items.map(item => (
    <div key={item.id}>
      {item.name}
    </div>
  ))}
  {/* Loading indicator */}
  {loading && items.length > 0 && (
    <div>Loading more items...</div>
  )}
  {/* Pagination info */}
  {totalCount > 0 && (
    <div>
      Showing {items.length} of {totalCount} items
      {hasNextPage && " (scroll down for more)"}
    </div>
  )}
</div>

What this does:

  1. 📜 Scroll Detection - Detects when user reaches bottom
  2. 🔄 Auto-Load - Fetches next page automatically
  3. Append Results - Adds new items to existing list
  4. 📊 Progress Indicator - Shows loading state and count
  5. 🎯 Cursor-Based - Uses GraphQL cursor for pagination

Item Selection and Output

Purpose: Handles item selection and generates Sitecore-compatible internal link format. This shows how to work with Sitecore GUIDs and create proper link markup.

import { formatGuidWithHyphens } from "@/lib/guid-utils";
const handleSelectItem = () => {
  if (highlightedItem) {
    const itemId = highlightedItem.id;
    const displayPath = highlightedItem.path || highlightedItem.name || "";
    if (itemId) {
      // Format GUID for Sitecore
      const formattedId = formatGuidWithHyphens(itemId);
      // Create Sitecore internal link format
      const internalLink = `<link text="" anchor="" linktype="internal" class="" title="" target="_blank" querystring="" id="{${formattedId}}" />`;
      // Pass both display path and internal link to parent
      onSelectPath(displayPath, internalLink);
    } else {
      // Fallback to path only
      onSelectPath(displayPath);
    }
  }
};
// UI for selection
<div>
  {/* Highlighted item display */}
  {highlightedItem && (
    <div className="border-t p-3 bg-muted/20">
      <div className="text-xs text-muted-foreground mb-1">Selected Item:</div>
      <div className="text-sm font-mono break-all">{highlightedItem.path}</div>
    </div>
  )}
  {/* Select button */}
  <Button
    onClick={handleSelectItem}
    disabled={!highlightedItem}
  >
    Insert Item
  </Button>
</div>

What this does:

  1. 🎯 Selection Tracking - Stores currently highlighted item
  2. 🔧 GUID Formatting - Formats ID for Sitecore compatibility
  3. 🔗 Link Generation - Creates proper Sitecore internal link markup
  4. 📤 Parent Callback - Passes selected data to parent component
  5. 💡 Visual Feedback - Shows selected item path

Complete Usage Example

File: src/pages/content-browser.tsx

Purpose: Demonstrates how to integrate the file manager component into a page, showing the complete flow from user interaction to data handling.

import { useState } from "react";
import { SitecoreFileManager } from "@/components/sitecore-file-manager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function ContentBrowser() {
  const [selectedPath, setSelectedPath] = useState("");
  const [internalLink, setInternalLink] = useState("");
  const handleSelectPath = (displayPath, linkMarkup) => {
    setSelectedPath(displayPath);
    if (linkMarkup) {
      setInternalLink(linkMarkup);
    }
  };
  return (
    <div className="container mx-auto p-6">
      <Card>
        <CardHeader>
          <CardTitle>Sitecore Content Browser</CardTitle>
        </CardHeader>
        <CardContent>
          <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
            {/* File Manager */}
            <div>
              <h3 className="text-sm font-medium mb-2">Browse Content</h3>
              <SitecoreFileManager
                onSelectPath={handleSelectPath}
                language="en"
                rootPath="/sitecore/content"
              />
            </div>
            {/* Selected Item Display */}
            <div>
              <h3 className="text-sm font-medium mb-2">Selected Item</h3>
              {selectedPath ? (
                <div className="space-y-4">
                  <div>
                    <label className="text-xs text-muted-foreground">Path:</label>
                    <div className="p-2 bg-muted rounded text-sm font-mono">
                      {selectedPath}
                    </div>
                  </div>
                  {internalLink && (
                    <div>
                      <label className="text-xs text-muted-foreground">
                        Internal Link (Sitecore Format):
                      </label>
                      <div className="p-2 bg-muted rounded text-xs font-mono break-all">
                        {internalLink}
                      </div>
                    </div>
                  )}
                </div>
              ) : (
                <div className="text-sm text-muted-foreground">
                  No item selected. Browse and select an item from the tree.
                </div>
              )}
            </div>
          </div>
        </CardContent>
      </Card>
    </div>
  );
}

What this demonstrates:

  1. 🎨 Component Integration - Using the file manager in a page
  2. 📊 State Management - Handling selected item data
  3. 💬 Callback Pattern - Parent receives data from child
  4. 🎯 Display Output - Shows both path and link markup
  5. 📱 Responsive Layout - Grid layout for desktop/mobile

Best Practices

1. Always Use Variables for Dynamic Values

// ❌ WRONG - String interpolation in query
const query = `
  query {
    item(path: "${userPath}", language: "${lang}") {
      children { results { name } }
    }
  }
`;
// ✅ CORRECT - Use GraphQL variables
const query = `
  query GetItems($path: String!, $language: String!) {
    item(path: $path, language: $language) {
      children { results { name } }
    }
  }
`;
const variables = { path: userPath, language: lang };

2. Handle Loading and Error States

// ✅ CORRECT - Comprehensive state handling
{loading && items.length === 0 ? (
  <div>Loading...</div>
) : error ? (
  <div>Error: {error}</div>
) : items.length === 0 ? (
  <div>No items found</div>
) : (
  <div>{/* Render items */}</div>
)}

3. Reset State When Navigation Changes

// ✅ CORRECT - Clean state on navigation
const handleNavigateInto = (item) => {
  setCurrentPath(item.path);
  setHighlightedItem(null);  // Clear selection
  setCurrentPage(0);          // Reset pagination
  setEndCursor(null);         // Reset cursor
  setError(null);             // Clear errors
};

4. Use Cursor-Based Pagination

// ✅ CORRECT - Cursor-based pagination
const query = `
  query GetItems($path: String!, $first: Int!, $after: String) {
    item(path: $path) {
      children(first: $first, after: $after) {
        pageInfo {
          hasNext
          endCursor
        }
        results { id name }
      }
    }
  }
`;

5. Validate User Input

// ✅ CORRECT - Validate before querying
const fetchItems = async (path) => {
  if (!path || typeof path !== 'string') {
    setError("Invalid path");
    return;
  }
  if (!path.startsWith('/sitecore/')) {
    setError("Path must start with /sitecore/");
    return;
  }
  // Proceed with query
};

Common Pitfalls

❌ Pitfall 1: Not Using GraphQL Variables

Problem: String interpolation in queries causes security issues and breaks caching.

Solution: Always use GraphQL variables for dynamic values.

❌ Pitfall 2: Forgetting to Reset Pagination

Problem: Navigating to a new folder shows old pagination state.

Solution: Reset currentPage and endCursor when path changes.

❌ Pitfall 3: Not Handling Empty Results

Problem: UI breaks when folder has no children.

Solution: Check for empty results and show appropriate message.

❌ Pitfall 4: Exposing API Keys

Problem: Making GraphQL calls directly from browser exposes credentials.

Solution: Always use a server-side proxy API route.

❌ Pitfall 5: Infinite Scroll Without Throttling

Problem: Scroll events trigger too many requests.

Solution: Check loading state before triggering new requests.

Testing Dynamic Queries

File: src/pages/api/debug/test-dynamic-query.ts

Purpose: Validates that dynamic queries work correctly with different parameters. This helps verify your GraphQL proxy and query structure before building UI components.

import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { path = "/sitecore/content", language = "en" } = req.query;
  try {
    const query = `
      query GetItems($path: String!, $language: String!) {
        item(path: $path, language: $language) {
          id
          name
          path
          children(first: 5) {
            total
            results {
              id
              name
              path
            }
          }
        }
      }
    `;
    const response = await fetch(
      `${req.headers.host}/api/sitecore/graphql`,
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          query,
          variables: { path, language },
        }),
      }
    );
    const data = await response.json();
    res.status(200).json({
      success: !data.errors,
      query: { path, language },
      itemFound: !!data.data?.item,
      childCount: data.data?.item?.children?.total || 0,
      children: data.data?.item?.children?.results || [],
      errors: data.errors || null,
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error instanceof Error ? error.message : "Unknown error",
    });
  }
}

Test it:

# Test default path
curl "http://localhost:3001/api/debug/test-dynamic-query"
# Test specific path
curl "http://localhost:3001/api/debug/test-dynamic-query?path=/sitecore/content/Home&language=en"

Final Thoughts on Dynamic GraphQL Queries

Building dynamic GraphQL queries in Sitecore XM Cloud transforms static content displays into interactive, user-driven experiences. The key to success lies in proper state management, using GraphQL variables instead of string interpolation, and implementing robust error handling for edge cases. The file manager example demonstrates these principles in action—each user interaction triggers a new query with updated parameters, pagination happens seamlessly through cursor-based loading, and the component gracefully handles empty folders or invalid paths.

The patterns shown here—server-side proxy for security, React state for tracking user position, and cursor-based pagination for performance—form the foundation for any dynamic Sitecore integration. Whether you’re building a content browser, search interface, or filtered list view, these same principles apply. Remember to always validate user input before querying, reset pagination state when navigation changes, and provide clear feedback during loading and error states for the best user experience.