| name | filter-system-dev |
| description | Guide for working with the configuration-driven filter system. Use when adding/modifying filters, updating URL parsing, debugging filter-related issues, or working with config/filters.ts. |
| license | MIT |
Filter System Development Guide
This skill helps you navigate the most complex domain logic in this codebase: the configuration-driven URL-first filtering system for event discovery.
When to Use This Skill
- Adding a new filter (e.g., price, accessibility, venue type)
- Modifying existing filter behavior (dependencies, special cases)
- Debugging URL parsing or canonicalization issues
- Understanding filter removal chains
- Working with
config/filters.ts or utils/filter-operations.ts
- Implementing filter UI components
Core Architecture
The filter system has one canonical source of truth: config/filters.ts
All filter logic flows through FilterOperations class which auto-integrates any filter added to the config.
Key Files
config/filters.ts - Single source of truth (ADD NEW FILTERS HERE)
utils/filter-operations.ts - Auto-integration logic (DO NOT modify for new filters)
utils/url-filters.ts - URL building (uses FilterOperations)
utils/url-parsing.ts - URL parsing (uses FilterOperations)
test/filter-system.test.ts - Validation tests
IMPORTANT: Do NOT create a utils/filter-config.ts file. All filter logic must go through config/filters.ts.
Filter Configuration Anatomy
{
key: 'filterName',
defaultValue: 'all',
isEnabled: (filters) => true,
getDisplayText: (value) => getLabel(value),
getRemovalChanges: (value, segments, queryParams) => ({
queryParams: { ...queryParams, filterName: undefined }
}),
dependencies: ['lat', 'lon'],
specialCases: {
shouldRedirectToHome: (filters) => ...
}
}
Step-by-Step: Adding a New Filter
1. Define Filter Configuration
Add to config/filters.ts in the FILTER_CONFIGURATIONS array:
{
key: 'price',
defaultValue: 'all',
isEnabled: (filters) => true,
getDisplayText: (value) => {
const labels = {
all: 'Tots els preus',
free: 'Gratuït',
paid: 'De pagament',
};
return labels[value] || value;
},
getRemovalChanges: (value, segments, queryParams) => ({
queryParams: { ...queryParams, price: undefined }
}),
}
2. Auto-Integration (No Action Needed)
Once in config/filters.ts, FilterOperations automatically provides:
getAllConfigurations() - includes your filter
getConfiguration('price') - retrieves config
getRemovalUrl('price', ...) - builds removal URL
isFilterActive('price', ...) - checks active state
getDefaultValue('price') - returns default
No manual wiring needed in utils/filter-operations.ts!
3. URL Strategy Decision
Choose where your filter lives in the URL:
Path Segment (e.g., /barcelona/avui/musica)
Use when:
- Core filtering dimensions (place, date, category)
- Important for SEO
- Limited set of values
- Omitted when default for canonical URLs
Query Parameter (e.g., ?search=jazz&distance=10)
Use when:
- User input (search term, location)
- Optional filters (distance, accessibility)
- Many possible values
- Omitted when default to reduce URL bloat
Decision tree for your filter:
- Is it core to SEO? → Path segment
- Is it user-driven input? → Query param
- Does it have limited values? → Path segment
- Is it optional enhancement? → Query param
4. Implement Dependencies
If your filter depends on others:
{
key: 'distance',
defaultValue: 50,
dependencies: ['lat', 'lon'],
getRemovalChanges: (value, segments, queryParams) => ({
queryParams: {
...queryParams,
distance: undefined,
lat: undefined,
lon: undefined,
}
}),
}
Critical: When removing a filter, always remove its dependencies to avoid invalid states.
5. Special Cases (If Needed)
For complex redirect logic:
{
key: 'category',
specialCases: {
shouldRedirectToHome: (filters) =>
filters.byDate &&
filters.place === 'catalunya' &&
filters.category === 'tots'
}
}
This handles edge cases like "all categories in all of Catalunya by date" → redirect to home.
6. Run Validation Tests
yarn test test/filter-system.test.ts
If tests fail:
- Check that your
defaultValue matches expected behavior
- Verify
getRemovalChanges returns correct shape
- Update test expectations if you intentionally changed behavior
Common Pitfalls
❌ Encoding Default Values in URLs
const url = `/barcelona?price=all`;
const url = `/barcelona`;
Fix: Use buildCanonicalUrlDynamic which auto-omits defaults based on FILTER_CONFIGURATIONS.
❌ Forgetting Dependent Filters
getRemovalChanges: (value, segments, queryParams) => ({
queryParams: { ...queryParams, distance: undefined },
});
getRemovalChanges: (value, segments, queryParams) => ({
queryParams: {
...queryParams,
distance: undefined,
lat: undefined,
lon: undefined,
},
});
Why: Leaving lat/lon without distance creates invalid state.
❌ Duplicating Logic in Multiple Files
DO NOT add filter-specific logic to:
utils/url-parsing.ts (uses FilterOperations.getAllConfigurations())
utils/url-filters.ts (uses FilterOperations.getConfiguration())
- Individual page components (import
FilterOperations)
Single source of truth: config/filters.ts → everything else reads from it.
❌ Using Legacy Filter Patterns
export const PRICE_FILTER = { ... }
export const FILTER_CONFIGURATIONS = [
{ key: 'price', ... }
]
Note: Never create standalone filter config files. All filter configurations must go in config/filters.ts.
❌ Creating /tots/ URLs
const url = `/barcelona/tots/musica`;
const url = `/barcelona/musica`;
SEO impact: /tots/ segments bloat URLs and dilute SEO value. Canonical URLs omit defaults.
URL Canonicalization Rules
Critical for SEO and avoiding $300 cost spikes (see Section 12 in AGENTS.md):
-
Default omission:
date === 'tots' AND category === 'tots' → /place
date === 'tots' and category !== 'tots' → /place/category
date !== 'tots' and category === 'tots' → /place/date
-
Query params:
- Only for:
search, distance, lat, lon
- Omit
distance when default (50)
-
Legacy redirects (handled by middleware):
?category=X&date=Y → /place/date/category (301)
/place/tots/category → /place/category (301)
NEVER read searchParams in app/[place]/* pages - causes DynamoDB explosion (ISR caching per unique URL+query).
Example: Adding a "Price" Filter
See examples/add-price-filter-example.md for full walkthrough.
Quick summary:
- Add config to
config/filters.ts
- Create UI component reading
FilterOperations.getConfiguration('price')
- Build URLs with
buildCanonicalUrlDynamic({ price: 'free' })
- Test with
yarn test test/filter-system.test.ts
Testing Your Filter
Unit Tests
yarn test test/filter-system.test.ts
Validates:
- Filter configuration structure
- Default value handling
- Removal URL generation
- Active state detection
E2E Tests
yarn test:e2e e2e/filters.flow.spec.ts
Validates:
- URL construction in real navigation
- Filter UI interactions
- Canonical URL redirects
Manual Testing Checklist
Filter UI Patterns
Reading Filter State
import { FilterOperations } from "@utils/filter-operations";
const config = FilterOperations.getConfiguration("price");
const isActive = FilterOperations.isFilterActive("price", currentFilters);
const displayText = config.getDisplayText("free");
Building Removal URL
const removalUrl = FilterOperations.getRemovalUrl(
"price",
segments,
queryParams
);
Checking if Filter Enabled
const config = FilterOperations.getConfiguration("price");
const enabled = config.isEnabled(currentFilters);
Integration with API Layer
Filters must be translated to API query params:
const filters = { price: "free", place: "barcelona" };
const query = buildEventsQuery({
place: filters.place,
price: filters.price === "free" ? 0 : undefined,
});
Important: Filter keys in UI may differ from API param names. Map them in buildEventsQuery.
Resources
Templates
Examples
Reference Files
config/filters.ts - Canonical source of truth
utils/filter-operations.ts - Auto-integration logic
utils/url-filters.ts - URL building
test/filter-system.test.ts - Test suite
FAQ
Q: Should my filter be a path segment or query param?
A: Path segment if core/SEO-important, query param if optional/user-input.
Q: Do I need to modify filter-operations.ts?
A: No. Just add to config/filters.ts and everything auto-integrates.
Q: What if my filter depends on another?
A: Add dependencies: ['filterKey'] and include removals in getRemovalChanges.
Q: How do I test my filter?
A: Run yarn test test/filter-system.test.ts for unit tests, yarn test:e2e e2e/filters.flow.spec.ts for E2E.
Q: Why does URL building omit my filter?
A: It matches your defaultValue. This is correct for canonical URLs.
Q: Can I override URL building for my filter?
A: No. Canonical URL building is centralized for SEO consistency. Special cases go in specialCases config.
Troubleshooting
Filter not appearing in UI
- Check
isEnabled returns true for current filter state
- Verify UI component imports
FilterOperations.getConfiguration('yourFilter')
- Confirm filter key matches exactly (case-sensitive)
URL includes default value
- Check
defaultValue in config matches the value you're seeing
- Verify you're using
buildCanonicalUrlDynamic (not manual concatenation)
- Ensure
getRemovalChanges sets filter to undefined (not default value)
Filter removal doesn't work
- Check
getRemovalChanges returns correct shape
- Verify dependencies are also removed
- Test removal URL with
FilterOperations.getRemovalUrl()
Tests failing after adding filter
- Update test expectations if you intentionally changed behavior
- Verify config structure matches schema in tests
- Check that default value is handled correctly
Last Updated: January 15, 2026
Maintainer: Development Team
Related Skills: url-canonicalization, api-layer-patterns