Skip to content

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:

  1. Using eval() or new Function() - catastrophic security risk
  2. Building overly complex UI that requires programming knowledge
  3. No preview/validation - users create broken rules they cannot debug
  4. 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.

rule-evaluator.js
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, prototype blocked 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.

rule-store.js
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.

FilterBuilder.jsx
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.

rule-tester.js
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.

json-logic-builder.js
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;
}
}
// Usage
const 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:

  1. 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.

  2. 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.

  3. User confidence - Real-time preview and natural language translation let users understand exactly what their rules do before they apply them.

  4. 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() or new 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, prototype blocked
  • 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments