How to Build User-Facing Rule and Filter Builders in JavaScript
I tried letting users define custom business rules in my admin panel. The naive approach was simple: let them write JavaScript expressions and use eval() to execute them. I got a security nightmare. Users could access process, require, window, or any global object.
I needed a way to let non-technical users create rules like “order.total > 100 AND customer.tier == ‘gold’” without exposing my application to arbitrary code execution.
The Problem: Why User-Facing Rules Are Dangerous
The core issue is trust. Users need to define rules, filters, and template logic, but giving them unconstrained code execution is never an option.
Common failure modes I encountered:
- Using
eval()ornew Function()- catastrophic security risk - Building overly complex UI that requires programming knowledge
- No preview/validation - users create broken rules they cannot debug
- Performance issues with complex rule evaluation on large datasets
The Reddit discussion I found confirmed this is a common pain point. One developer mentioned: “if order.total > 100 AND customer.tier == ‘gold’” type stuff where you absolutely cant let them run arbitrary code but still need decent performance.”
The Solution: Three-Layer Architecture
I implemented a three-layer architecture that keeps users safe while giving them powerful rule-building capabilities.
Layer 1: Safe Expression Evaluator (bonsai-js, Jexl, json-logic-js)Layer 2: Intermediate Representation (JSON-Logic or custom AST)Layer 3: User Interface (Query Builder with real-time preview)Layer 1: Safe Expression Evaluator
I chose bonsai-js for my evaluator. It runs at 30M ops/sec with zero dependencies and blocks dangerous property access at every level.
import { evaluate, validate } from 'bonsai-js';
class SafeRuleEvaluator { constructor(options = {}) { this.allowedProperties = new Set(options.allowedProperties || []); this.maxDepth = options.maxDepth || 10; this.timeout = options.timeout || 100; }
evaluate(rule, context) { // Sanitize context - remove prototype chain const safeContext = this.sanitizeContext(context);
// Validate expression first const validation = validate(rule.expression); if (!validation.valid) { throw new Error(`Invalid expression: ${validation.error}`); }
// Check property access against allowlist for (const ref of validation.references) { if (!this.isPropertyAllowed(ref)) { throw new Error(`Property '${ref}' is not allowed`); } }
return evaluate(rule.expression, safeContext); }
sanitizeContext(obj, depth = 0) { if (depth > this.maxDepth) return undefined; if (obj === null || obj === undefined) return obj; if (typeof obj !== 'object') return obj;
// Create object with no prototype const safe = Object.create(null); for (const [key, value] of Object.entries(obj)) { if (this.allowedProperties.has(key)) { safe[key] = this.sanitizeContext(value, depth + 1); } } return safe; }
isPropertyAllowed(prop) { return this.allowedProperties.has(prop); }}
export const ruleEvaluator = new SafeRuleEvaluator({ allowedProperties: ['order', 'customer', 'product', 'user', 'total', 'tier', 'status', 'items'], maxDepth: 15, timeout: 200});The key security measures:
__proto__,constructor,prototypeblocked at every access level- Max depth and max array length limits
- Property allowlists
- Objects created with
Object.create(null)to eliminate prototype chain
Layer 2: Intermediate Representation
I store rules as JSON structures. This makes them serializable, versionable, and safe to store in a database.
class RuleBuilderStore { constructor() { this.conditions = []; this.logic = 'AND'; }
addCondition(condition) { const validated = this.validateCondition(condition); this.conditions.push(validated); return this; }
validateCondition(condition) { const { field, operator, value } = condition;
// Validate field against allowlist const allowedFields = ['total', 'status', 'tier', 'items', 'createdAt']; if (!allowedFields.includes(field)) { throw new Error(`Field '${field}' is not allowed`); }
// Validate operator const allowedOperators = ['==', '!=', '>', '<', '>=', '<=', 'contains', 'startsWith', 'endsWith']; if (!allowedOperators.includes(operator)) { throw new Error(`Operator '${operator}' is not allowed`); }
return { field, operator, value: this.sanitizeValue(value) }; }
toExpression() { if (this.conditions.length === 0) return 'true';
const expressions = this.conditions.map(c => this.conditionToExpression(c)); return expressions.join(this.logic === 'AND' ? ' && ' : ' || '); }
conditionToExpression(condition) { const { field, operator, value } = condition;
switch (operator) { case '==': return `${field} == ${JSON.stringify(value)}`; case '!=': return `${field} != ${JSON.stringify(value)}`; case '>': return `${field} > ${value}`; case '<': return `${field} < ${value}`; case '>=': return `${field} >= ${value}`; case '<=': return `${field} <= ${value}`; case 'contains': return `${JSON.stringify(value)}.includes(${field})`; case 'startsWith': return `${field}.startsWith(${JSON.stringify(value)})`; case 'endsWith': return `${field}.endsWith(${JSON.stringify(value)})`; default: throw new Error(`Unknown operator: ${operator}`); } }
toJSON() { return { logic: this.logic, conditions: this.conditions }; }
static fromJSON(json) { const store = new RuleBuilderStore(); store.logic = json.logic || 'AND'; store.conditions = json.conditions || []; return store; }}
export { RuleBuilderStore };This intermediate representation lets me switch evaluators later without changing the UI layer.
Layer 3: User Interface
The UI layer translates user interactions into the intermediate representation. I built a React component that shows users what their rules match in real-time.
import { useState, useCallback, useMemo } from 'react';import { evaluate } from 'bonsai-js';
const OPERATORS = [ { value: '==', label: 'equals' }, { value: '!=', label: 'not equals' }, { value: '>', label: 'greater than' }, { value: '<', label: 'less than' }, { value: '>=', label: 'greater or equal' }, { value: '<=', label: 'less or equal' }, { value: 'contains', label: 'contains' },];
const FIELDS = [ { value: 'total', label: 'Order Total', type: 'number' }, { value: 'status', label: 'Status', type: 'string' }, { value: 'tier', label: 'Customer Tier', type: 'string' }, { value: 'items', label: 'Item Count', type: 'number' },];
function FilterBuilder({ data, onApply, initialRules }) { const [conditions, setConditions] = useState(initialRules?.conditions || []); const [logic, setLogic] = useState(initialRules?.logic || 'AND');
// Convert conditions to expression const expression = useMemo(() => { if (conditions.length === 0) return 'true';
const parts = conditions.map(c => { const field = c.field; const op = c.operator; const val = typeof c.value === 'string' ? `"${c.value}"` : c.value;
switch (op) { case '==': return `${field} == ${val}`; case '!=': return `${field} != ${val}`; case '>': return `${field} > ${val}`; case '<': return `${field} < ${val}`; case '>=': return `${field} >= ${val}`; case '<=': return `${field} <= ${val}`; case 'contains': return `${field}.includes(${val})`; default: return 'true'; } });
return parts.join(logic === 'AND' ? ' && ' : ' || '); }, [conditions, logic]);
// Preview matched records const matchedRecords = useMemo(() => { if (!data || conditions.length === 0) return data || [];
try { return data.filter(record => { try { return evaluate(expression, record); } catch { return false; } }); } catch { return []; } }, [data, expression, conditions]);
// Natural language preview const naturalLanguage = useMemo(() => { if (conditions.length === 0) return 'Show all records';
const parts = conditions.map(c => { const field = FIELDS.find(f => f.value === c.field)?.label || c.field; const op = OPERATORS.find(o => o.value === c.operator)?.label || c.operator; return `${field} ${op} ${c.value}`; });
return `Show records where ${parts.join(logic === 'AND' ? ' and ' : ' or ')}`; }, [conditions, logic]);
const addCondition = useCallback(() => { setConditions(prev => [...prev, { id: Date.now(), field: 'total', operator: '>', value: 0 }]); }, []);
const updateCondition = useCallback((id, updates) => { setConditions(prev => prev.map(c => c.id === id ? { ...c, ...updates } : c )); }, []);
const removeCondition = useCallback((id) => { setConditions(prev => prev.filter(c => c.id !== id)); }, []);
const handleApply = useCallback(() => { const rules = { logic, conditions: conditions.map(({ id, ...rest }) => rest), expression }; onApply(rules, matchedRecords); }, [logic, conditions, expression, matchedRecords, onApply]);
return ( <div className="filter-builder"> <div className="logic-toggle"> <button className={logic === 'AND' ? 'active' : ''} onClick={() => setLogic('AND')}> All conditions (AND) </button> <button className={logic === 'OR' ? 'active' : ''} onClick={() => setLogic('OR')}> Any condition (OR) </button> </div>
<div className="conditions-list"> {conditions.map((condition, index) => ( <div key={condition.id} className="condition-row"> {index > 0 && <span className="logic-label">{logic}</span>}
<select value={condition.field} onChange={(e) => updateCondition(condition.id, { field: e.target.value })} > {FIELDS.map(f => ( <option key={f.value} value={f.value}>{f.label}</option> ))} </select>
<select value={condition.operator} onChange={(e) => updateCondition(condition.id, { operator: e.target.value })} > {OPERATORS.map(o => ( <option key={o.value} value={o.value}>{o.label}</option> ))} </select>
<input type={FIELDS.find(f => f.value === condition.field)?.type === 'number' ? 'number' : 'text'} value={condition.value} onChange={(e) => updateCondition(condition.id, { value: e.target.type === 'number' ? parseFloat(e.target.value) : e.target.value })} />
<button onClick={() => removeCondition(condition.id)}>Remove</button> </div> ))} </div>
<button onClick={addCondition}>+ Add Condition</button>
<div className="preview-section"> <div className="natural-language">{naturalLanguage}</div> <div className="match-count"> {matchedRecords.length} of {data?.length || 0} records match </div> </div>
<button onClick={handleApply}>Apply Filter</button> </div> );}
export default FilterBuilder;The UI shows users a natural language preview like “Show records where Order Total greater than 100 and Customer Tier equals gold” and a live count of matching records.
Testing Rules Against Sample Data
I built a tester that lets users validate their rules against sample data before deploying.
import { evaluate } from 'bonsai-js';
class RuleTester { constructor(rule, sampleData) { this.rule = rule; this.sampleData = sampleData; }
test() { const results = { passed: [], failed: [], errors: [] };
for (const record of this.sampleData) { try { const matches = evaluate(this.rule.expression, record); if (matches) { results.passed.push(record); } else { results.failed.push(record); } } catch (error) { results.errors.push({ record, error: error.message }); } }
return { ...results, summary: { total: this.sampleData.length, passed: results.passed.length, failed: results.failed.length, errors: results.errors.length, passRate: `${((results.passed.length / this.sampleData.length) * 100).toFixed(1)}%` } }; }
debug(record) { try { const result = evaluate(this.rule.expression, record); return { record, expression: this.rule.expression, result, error: null }; } catch (error) { return { record, expression: this.rule.expression, result: null, error: error.message }; } }}
export { RuleTester };Alternative: JSON-Logic Approach
For applications that prefer pure JSON representation, json-logic-js provides a declarative alternative.
import jsonLogic from 'json-logic-js';
class JsonLogicBuilder { constructor() { this.rules = { and: [] }; }
addCondition(field, operator, value) { const logicOp = this.mapOperator(operator); this.rules.and.push({ [logicOp]: [{ var: field }, value] }); return this; }
mapOperator(op) { const map = { '==': '==', '!=': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=', 'contains': 'in', }; return map[op] || '=='; }
evaluate(context) { return jsonLogic.apply(this.rules, context); }
filter(data) { return data.filter(record => this.evaluate(record)); }
toJSON() { return this.rules; }
static fromJSON(json) { const builder = new JsonLogicBuilder(); builder.rules = json; return builder; }}
// Usageconst builder = new JsonLogicBuilder() .addCondition('total', '>', 100) .addCondition('tier', '==', 'gold');
const orders = [ { total: 150, tier: 'gold' }, { total: 50, tier: 'gold' }, { total: 200, tier: 'silver' }];
const matches = builder.filter(orders);// Result: [{ total: 150, tier: 'gold' }]Why This Works
I think the key reason this architecture is successful:
-
Security by default - Users never write raw JavaScript. The UI generates safe expressions from structured input, and the evaluator blocks any attempt to access dangerous properties.
-
Separation of concerns - The three layers are independent. I can swap the evaluator from bonsai-js to Jexl without touching the UI. I can change the UI from React to Alpine.js without touching the evaluator.
-
User confidence - Real-time preview and natural language translation let users understand exactly what their rules do before they apply them.
-
Performance - bonsai-js runs at 30M ops/sec with cached expressions. Even complex rules on large datasets perform well.
Security Checklist Before Deployment
Before shipping user-facing rule builders, I verify:
- No
eval()ornew Function()for user input - Field names validated against an allowlist
- All values sanitized before expression compilation
- Timeouts implemented for complex rule evaluation
- Context objects created with
Object.create(null) -
__proto__,constructor,prototypeblocked - Max depth and array length limits enforced
- Error messages do not leak internal details
Summary
In this post, I built a secure user-facing rule builder using a three-layer architecture. The key point is never expose raw JavaScript to users - use a structured intermediate format that your evaluator converts to executable logic. Start with a simple condition builder, then progressively add features like grouping, nested conditions, and saved templates.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 bonsai-js GitHub
- 👨💻 json-logic-js Documentation
- 👨💻 React Query Builder
- 👨💻 OWASP Input Validation Cheat Sheet
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments