URL Pattern for Collection Editing
Clean, semantic URL structure that reflects internal UUID-based operations
On this page
Overview
SveltyCMS implements a GUI-First navigation pattern where user interactions are instant and URLs passively reflect state changes. This architecture prioritizes 90% of navigation (GUI clicks) over 10% of navigation (manual URL edits), resulting in lightning-fast mode switching.
Key Principle
“GUI actions drive state, URLs reflect state”
- User clicks a row →
setMode()instantly → URL updates (no reload)- User hovers a row →
preloadEntry()schedules SvelteKitpreloadData('?edit=uuid')- User edits the URL → translated to a mode change → server loads if needed
Architecture Pattern
The Performance Rule
“Optimize for the common case (GUI), support the edge case (URL edits)”
90% of navigation: GUI clicks (FAST)
Hover → preloadEntry() schedules SvelteKit preloadData('?edit=uuid')
Click → setMode() + setCollectionValue(row) → Reflect in URL
Result: INSTANT mode switch (the row is already in memory)
10% of navigation: Manual URL edits (SLOWER - acceptable)
User types URL → Parse → Filter by UUID → Load data → setMode()
Result: SSR reload (~300-500ms) - Only when manually editing URL
Why GUI-First?
| Aspect | GUI-First Benefit |
|---|---|
| Speed | Instant mode switching (0ms if cached) |
| User Experience | No loading spinners, no page flashes |
| Cache Efficiency | Hover preloading means data ready before click |
| Browser History | URLs in history are bookmarkable |
| Manual URLs | Still supported, just slightly slower (acceptable) |
Internal Flow (GUI-First)
┌──────────────────────────────────────────────────────────┐
│ PRIMARY PATH (90% - GUI Clicks) │
└──────────────────────────────────────────────────────────┘
User Hovers a Row
↓
preloadEntry(entryId, pathname) ──→ SvelteKit preloadData('?edit=uuid')
↓
Load data cache (SvelteKit-owned)
↓
User Clicks the Row
↓
modeTransitionGuard.setMode('edit') ──→ INSTANT mode switch
↓
setCollectionValue(row) ──→ list row already in memory
↓
reflectModeInURL('edit', uuid) ──→ Passive URL update (no reload)
↓
User sees edit form (0-100ms total)
┌──────────────────────────────────────────────────────────┐
│ SECONDARY PATH (10% - Manual URL Edits) │
└──────────────────────────────────────────────────────────┘
User types: /en/posts?edit=abc123
↓
$effect detects URL change
↓
parseURLToMode(url) ──→ Extract mode + UUID
↓
edit param changed? ──→ yes
↓
entries.length === 1 ? ──→ data already loaded by the server load function
│ ↓
│ setMode('edit') + setCollectionValue(entries[0])
No ↓
↓ User sees edit form (instant)
refreshAll() → setMode('edit')
↓
User sees edit form (one SSR round trip — acceptable for the rare manual edit)
Current URL Structure
View Mode (List)
http://localhost:5173/de/Collections/Names
Format: /{language}/{collection-path}
Edit Mode
http://localhost:5173/de/Collections/Names?edit=8429c375ca7948d99fbcf423b7c0a8c3
Format: /{language}/{collection-path}?edit={entry-uuid}
Create Mode
http://localhost:5173/de/Collections/Names?create=true
Format: /{language}/{collection-path}?create=true
Performance Characteristics
Speed Comparison
| Action | Old (goto-based) | New (GUI-First) | Improvement |
|---|---|---|---|
| Click edit (after hover) | ~500ms | ~0ms (cached) | Instant |
| Click edit (no hover) | ~500ms | ~100ms | 5x faster |
| Click create | ~300ms | ~0ms | Instant |
| Click cancel | ~300ms | ~0ms | Instant |
| Save entry | ~500ms | ~200ms | 2.5x faster |
| Manual URL edit | ~500ms | ~500ms | Same (acceptable) |
Cache Hit Rates
With proper hover preloading:
- Edit mode: 70-80% cache hits (instant)
- Create mode: 100% instant (no data needed)
- Cancel: 100% instant (no data needed)
Implementation Details
1. Navigation Utils (src/utils/navigation.ts)
// src/utils/navigation.ts:89
// Preload on hover: schedules a SvelteKit preload of ?edit=<entryId>, then cancels
// itself when the pointer leaves before `delay` ms elapses.
export function preloadEntry(entryId: string, currentPath: string, delay = 200): void {
cancelPreload(entryId);
const timer = setTimeout(async () => {
try {
const url = new URL(currentPath, window.location.origin);
url.searchParams.set("edit", entryId);
await preloadData(url.pathname + url.search);
} catch (error) {
logger.warn(`[Preload] Failed for ${entryId}`, error);
} finally {
preloadTimers.delete(entryId);
}
}, delay);
preloadTimers.set(entryId, timer);
}
export function cancelPreload(entryId: string): void {
const timer = preloadTimers.get(entryId);
if (timer) {
clearTimeout(timer);
preloadTimers.delete(entryId);
}
}
// Passive URL reflection (no reload)
export function reflectModeInURL(mode: ModeType, entryId?: string): void {
const url = new URL(window.location.href);
url.searchParams.delete("edit");
url.searchParams.delete("create");
if (mode === "edit" && entryId) {
url.searchParams.set("edit", entryId);
} else if (mode === "create") {
url.searchParams.set("create", "true");
}
window.history.pushState({}, "", url.toString());
}
The module also exports slugify(), parseURLToMode() and the NavigationManager singleton (navigationManager.toList() / toEdit() / toCreate()), which own locking, mode transitions and loading state.
2. Client-Side Navigation (entry-list.svelte)
GUI-FIRST PATTERN:
// src/components/collection-display/entry-list.svelte:486
function handleRowHoverStart(entryId: string) {
// Slow connections skip preloading entirely
if (!isPreloadEnabled) return;
// SvelteKit preloadData for ?edit=<entryId>
preloadEntry(entryId, page.url.pathname);
// Track hover patterns for predictive preloading
const currentCount = hoverPatterns.get(entryId) || 0;
hoverPatterns.set(entryId, currentCount + 1);
if (currentCount >= 2) predictivePreloadQueue.push(entryId);
}
// Click → edit: state first, URL second (no server round trip for the list row)
function openEntry(entry: CollectionEntry) {
modeTransitionGuard.setMode("edit");
setCollectionValue(entry);
reflectModeInURL("edit", entry._id as string);
}
The list row already holds the language-projected entry, so edit mode renders instantly; the URL is reflected passively so SSR/refresh can load the full multilingual document.
3. Save/Cancel Actions (header-edit.svelte)
// src/components/header-edit.svelte:197
async function save(): Promise<void> {
if (!isFormValid) {
toast.warning(validation_fix_before_save());
return;
}
const success = await saveEntry({ ...currentEntry! });
if (!success) return;
// Back to the list: NavigationManager resets mode + value and refreshes load data
await navigationManager.toList();
}
// src/components/header-edit.svelte:239
function cancel(): void {
document.dispatchEvent(new CustomEvent("cancelEdit", { bubbles: true }));
setCollectionValue({});
ui.toggle("rightSidebar", "hidden");
ui.toggle("leftSidebar", isDesktop ? "full" : "collapsed");
ui.toggle("pageheader", "hidden");
navigationManager.toList();
}
navigationManager.toList() (src/utils/navigation.ts:181) clears pending changes, transitions through modeTransitionGuard.transitionTo("view") and then calls goto(page.url.pathname, { refreshAll: true }) — one place owns the “leave edit mode” sequence.
4. URL-to-Mode Translation (+page.svelte)
Only for manual URL edits:
// src/routes/(app)/[language]/[...collection]/+page.svelte:277
// Detect URL changes (manual edits by user)
$effect(() => {
const currentUrl = page.url.toString();
if (currentUrl !== lastUrlString) {
lastUrlString = currentUrl;
const parsed = parseURLToMode(page.url);
// Edit mode from a manual URL change
if (parsed.mode === "edit" && parsed.entryId && editParamChanged) {
// Clear errors from the previous entry — a stale key would block Save
validationStore.clearAllErrors();
if (entries && entries.length === 1) {
// Data already loaded by the server load function
collections.setMode("edit");
collections.setCollectionValue(entries[0]);
} else {
// Need a server round trip for the full entry
refreshAll().then(() => collections.setMode("edit"));
}
} else if (parsed.mode === "view" && collections.mode === "edit") {
collections.setMode("view");
} else if (parsed.mode === "create") {
collections.setMode("create");
collections.setCollectionValue(newEntry); // null-initialised from the schema
}
}
});
6. Manual URL Editing (URL → UUID Translation)
When users manually edit the URL:
// Server turns the URL param into a query filter
// src/routes/(app)/[language]/[...collection]/+page.server.ts:177
const editEntryId = url.searchParams.get("edit");
const { entries, pagination, revisions, contentLanguage, collectionSchema } =
await collectionService.getCollectionData({
collection: currentCollection,
page: listQuery.page,
pageSize: listQuery.pageSize,
sort: listQuery.sort,
filter: listQuery.filter,
search: listQuery.search,
language,
user: typedUser,
tenantId,
editEntryId: editEntryId || undefined, // ← pinned by _id inside the service
});
// src/services/core/collection-service.ts:300
if (editEntryId) {
baseWhere._id = editEntryId; // translate ?edit=UUID into an exact-id read
}
6. Data Population
+page.svelte loads entry into store:
// Populate collectionValue when editing an entry
$effect(() => {
const editParam = page.url.searchParams.get("edit");
if (editParam && entries.length > 0) {
// Server loaded the specific entry by UUID
untrack(() => {
collectionValue.value = entries[0];
});
}
});
UUID-First Design Benefits
1. Internal Consistency
- All code works with UUIDs (database IDs)
- No ambiguity about what operation to perform
- Type-safe UUID references throughout codebase
2. Clean Separation
- Internal logic: UUID-based
- External representation: Clean URLs
- Clear boundary between internal operations and user-facing URLs
3. Flexibility
- Users can bookmark/share URLs
- Manual URL editing works (translated to UUID operations)
- Browser back/forward buttons work correctly
4. Security
- UUIDs are non-sequential (harder to guess)
- Collection UUIDs can be validated
- Invalid UUIDs return 404 errors
Operation Flow Examples
Example 1: User Clicks Edit Button
// 1. User clicks row in entry-list.svelte
onClick={() => handleRowClick(entry)}
// 2. Internal: Code uses UUID
const entryUUID = entry._id; // "8429c375ca7948d99fbcf423b7c0a8c3"
// 3. External: Update URL
await goto(`?edit=${entryUUID}`);
// 4. Browser URL changes
// FROM: /de/Collections/Names
// TO: /de/Collections/Names?edit=8429c375ca7948d99fbcf423b7c0a8c3
// 5. Server reads URL parameter
const editId = url.searchParams.get('edit'); // "8429c375..."
// 6. Server translates to UUID filter
finalFilter._id = editId;
// 7. Database query uses UUID
dbAdapter.queryBuilder('collection_xyz').where({ _id: editId })
Example 2: User Manually Edits URL
// 1. User bookmarks or manually types URL
// http://localhost:5173/de/Collections/Names?edit=abc123
// 2. Server receives request
const editId = url.searchParams.get("edit"); // "abc123"
// 3. Server validates UUID format
const isValidUUID = /^[a-f0-9]{32}$/i.test(editId);
// 4a. Valid UUID: Translate to filter
if (isValidUUID) {
finalFilter._id = editId;
}
// 4b. Invalid UUID: Return 400 error
else {
throw error(400, "Invalid entry ID format");
}
// 5. Rest of flow identical to button click
Example 3: Save After Create
// 1. User in create mode
// URL: /de/Collections/Names?create=true
// 2. User clicks Save button
await prepareAndSaveEntry();
// 3. Internal: Save to database, get new UUID
const result = await createEntry(collectionId, data);
const newUUID = result.data._id; // New UUID from database
// 4. External: Navigate to list
const url = new URL(page.url);
url.searchParams.delete("create");
await goto(url);
// 5. Browser URL changes
// FROM: /de/Collections/Names?create=true
// TO: /de/Collections/Names
Performance Optimizations
Predictive Preloading Integration
The URL pattern enables multi-strategy predictive preloading via declarative data-preload attributes:
<!-- entry-list row with smart preloading -->
<tr>
<a href="?edit={entry._id}" data-preload="smart" class="hidden" aria-hidden="true"></a>
<td onclick={() => goto(`?edit=${entry._id}`)}>{entry.title}</td>
</tr>
Four strategies (see Predictive Preloading):
| Strategy | Trigger | Best for |
|---|---|---|
smart |
Physics cone + behavioral priority | Collection entry rows |
predict |
Cursor heading toward link | Dashboard widgets |
viewport |
Link enters viewport | Media gallery |
hover |
Mouseenter + 150ms | Sidebar navigation |
Benefits: 0ms perceived edit latency, browser cache pre-populated, behavioral learner prioritizes hot collections.
Server-Side Rendering
Two-Tier Data Loading:
-
View Mode (
no edit param):- Load paginated list
- Project to single language
- Reduce payload size
-
Edit Mode (
?edit=UUID):- Load single entry
- Keep full multilingual data
- Enable translation editing
See Multilingual Data Loading for details.
Benefits
1. Semantic Clarity
?edit=UUIDclearly indicates editing intent- No redundant parameters
- Self-documenting URLs
2. Cleaner URLs
- Shorter, more readable
- Professional appearance
- Single parameter reduces cognitive load
3. Better Caching
- Clear cache key distinction
- Efficient cache invalidation
- Language-specific caching
4. Simplified Logic
- Single parameter to check
- Less conditional branching
- Easier maintenance
5. Preloading Support
- Enables hover preloading
- Predictable URL structure
- Browser cache friendly
Edge Cases
Missing Entry
If ?edit=UUID references non-existent entry:
// Server returns empty entries array
if (editEntryId && entries.length === 0) {
throw error(404, "Entry not found");
}
Invalid UUID Format
Server validates UUID format:
const isValidUUID = /^[a-f0-9]{32}$/i.test(editEntryId);
if (editEntryId && !isValidUUID) {
throw error(400, "Invalid entry ID format");
}
Concurrent Edits
Cache includes entry ID, preventing conflicts:
- User A edits entry X:
?edit=X(cached separately) - User B edits entry Y:
?edit=Y(cached separately)
Language Switching
Edit parameter is preserved:
// translation-status.svelte
const newPath = `/${selectedLanguage}/${collectionId}${page.url.search}`;
// Preserves ?edit=UUID when switching languages
Related Systems
- Hover Preloading: Uses URL structure for prefetching
- Multilingual Data Loading: Two-tier strategy based on URL
- Collection Store Dataflow: Mode management based on URL
- Cache System: Cache keys include edit parameter
Testing
Manual Testing
-
View Mode:
- Navigate to
/en/Collections/Names - Verify list displays
- Check URL has no
editparameter
- Navigate to
-
Edit Mode:
- Click an entry row
- Verify URL shows
?edit=UUID - Verify fields component loads
- Check full multilingual data available
-
Language Switch:
- Switch language in edit mode
- Verify URL preserves
?edit=UUID - Verify entry data reloads in new language
-
Hover Preload:
- Hover over entry for 600ms
- Check Network tab for prefetch request
- Click entry immediately after
- Verify instant load (< 50ms)
Automated Testing
// Example test
describe("URL Pattern", () => {
it("sets edit parameter on entry click", async () => {
const { page } = await render(entry - list);
await page.click('[data-entry-id="123"]');
expect(page.url.search).toContain("?edit=123");
});
it("loads single entry when edit param present", async () => {
const response = await load({
url: new URL("?edit=123", base),
params: { language: "en", collection: "Names" },
});
expect(response.entries).toHaveLength(1);
});
});