Learn to detect circular $ref loops in JSON Schema for Next.js—using a Set‑based client check and json-schema-ref-parser for server‑side validation.
Detecting circular references in JSON Schema can be tricky, especially when a $ref points back to the root schema ("#").
Below is a practical guide for a Next.js + TypeScript project that shows two approaches:
- A lightweight client‑side detector using a simple recursive walk with a
Setto remember visited$refs. - A production‑grade server‑side solution leveraging the
json-schema-ref-parserpackage, which resolves$refs and automatically throws on circular references.
1️⃣ Why the Problem Matters
{
"name": "Circular Json Schema",
"strict": true,
"schema": {
"type": "object",
"properties": {
"self": { "$ref": "#" }
}
}
}
"$ref": "#"points to the root schema.- Traversing the schema recursively without a guard causes infinite recursion and crashes.
- Detecting the cycle early lets you either reject the schema or handle it safely (e.g., by limiting depth).
2️⃣ Client‑Side Detection – Manual Traversal
When you need a fast check in the browser (or any environment without Node‑specific packages) you can walk the schema yourself and keep track of already‑seen $ref pointers.
2.1 TypeScript Types
type JSONSchema = {
$ref?: string;
type?: string;
properties?: { [key: string]: JSONSchema };
items?: JSONSchema | JSONSchema[];
anyOf?: JSONSchema[];
allOf?: JSONSchema[];
oneOf?: JSONSchema[];
// any additional keywords are allowed
[key: string]: any;
};
2.2 Core Functions
/**
* Entry point – returns true if a circular $ref is found.
*/
function hasCircularRef(
schema: JSONSchema,
refPath: string = '',
seenRefs: Set<string> = new Set()
): boolean {
// Handle a $ref at the current node first
if (schema.$ref) {
const target = schema.$ref;
// If we have already processed this target, we are in a cycle
if (seenRefs.has(target)) {
return true;
}
// Clone the set for the next recursion level and add the current target
const nextSeen = new Set(seenRefs);
nextSeen.add(target);
// Resolve the reference (this example only knows about "#")
if (target === '#') {
// Re‑enter the whole schema – the root contains the same $ref again
return traverseSchema(schema, nextSeen);
}
// For a real implementation you would resolve external/internal pointers here
// e.g. `resolvePointer(schema, target)`.
return false; // no cycle detected for unknown refs in this thin version
}
// No $ref here → continue depth‑first traversal
return traverseSchema(schema, seenRefs);
}
/**
* Recursively walks every object/array property looking for $ref nodes.
*/
function traverseSchema(schema: JSONSchema, seenRefs: Set<string>): boolean {
if (typeof schema !== 'object' || schema === null) return false;
for (const key in schema) {
if (key === '$ref') continue; // already processed above
const value = schema[key];
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === 'object' && item !== null) {
if (hasCircularRef(item, '', seenRefs)) return true;
}
}
} else if (typeof value === 'object' && value !== null) {
if (hasCircularRef(value, '', seenRefs)) return true;
}
}
return false;
}
2.3 Using the Detector
const schema: JSONSchema = {
type: 'object',
properties: {
self: { $ref: '#' } // circular reference
}
};
console.log(hasCircularRef(schema)); // → true
What it does
- Builds a
Set<string>of every$refvalue encountered. - When the same
$refappears again, the function returnstrue. - Works for simple
#references and can be extended to support JSON‑Pointer syntax (#/properties/foo) or external URLs.
3️⃣ Server‑Side Detection – json-schema-ref-parser
For robust validation you probably already have an API route that receives user‑provided schemas.
json-schema-ref-parser resolves all $refs (including external files) and throws a clear error on circular references.
3.1 Install
npm install json-schema-ref-parser
# or with yarn:
yarn add json-schema-ref-parser
3.2 API Route Example (Next.js)
// pages/api/validate-schema.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { bundle } from 'json-schema-ref-parser';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const schema = req.body; // assume JSON Schema comes in the request body
try {
// `bundle` resolves all $refs; throws if a circular reference exists
await bundle(schema);
res.status(200).json({ valid: true, message: 'Schema is OK' });
} catch (err: any) {
if (err.message?.includes('circular')) {
res.status(400).json({
valid: false,
message: 'Circular reference detected',
details: err.message,
});
} else {
// Other parsing / validation errors
res.status(400).json({
valid: false,
message: 'Invalid JSON Schema',
details: err.message,
});
}
}
}
Key points
- Only works in a Node environment (the library uses
fsfor external files). - Returns a descriptive error like
Circular $ref pointer found at "#/properties/self"when a cycle is present. - Handles complex cases:
$refto other files, JSON‑Pointer fragments, and even dereferencing ofallOf/anyOf/oneOf.
4️⃣ Choosing the Right Tool
| Situation | Recommended Approach |
|---|---|
| Simple check on a schema already in memory (client side) | Manual traversal with Set (Section 2) |
| Full validation, possibly with external refs (server side) | json-schema-ref-parser.bundle() (Section 3) |
| Need to dereference the schema for downstream processing | json-schema-ref-parser.dereference() – also catches cycles |
| Want the smallest bundle size for a SPA | Write a tiny custom detector and ship only that code |
5️⃣ Extending the Manual Detector
If you need to support full JSON‑Pointer resolution without pulling in a heavy library:
- Parse the pointer after the leading
#. Example:#/properties/child/items. - Walk the schema object according to the path fragments.
- Cache resolved fragments in a
Map<string, JSONSchema>so each fragment is resolved only once.
You can then replace the simple if (target === '#') branch with a generic resolver:
function resolvePointer(root: JSONSchema, pointer: string): JSONSchema | undefined {
const parts = pointer.replace(/^#/, '').split('/').filter(Boolean);
let current: any = root;
for (const part of parts) {
if (current && typeof current === 'object') {
current = current[decodeURIComponent(part)];
} else {
return undefined;
}
}
return current as JSONSchema;
}
Combine this resolver with the seenRefs set to detect cycles across any depth.
6️⃣ Wrap‑Up
- Circular
$refs are legitimate in some schemas (e.g., recursive data structures) but they must be detected before naïve recursion. - A lightweight client‑side implementation using a
Setis easy to drop into any React component or utility file. - For server‑side validation or when you need full
$refdereferencing, the battle‑testedjson-schema-ref-parserlibrary does the heavy lifting and surfaces clear error messages. - Choose the approach that matches your execution context, performance constraints, and validation requirements.
By adding one of these detectors to your Next.js pipeline, you’ll prevent infinite loops, give users immediate feedback on malformed schemas, and keep your application stable. Happy coding!