Skip to content

Documentation

URL Pattern for Collection Editing

Clean, semantic URL structure that reflects internal UUID-based operations

9/22/2026
11 min read Edit on GitHub

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 SvelteKit preloadData('?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:

  1. View Mode (no edit param):

    • Load paginated list
    • Project to single language
    • Reduce payload size
  2. 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=UUID clearly 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

Testing

Manual Testing

  1. View Mode:

    • Navigate to /en/Collections/Names
    • Verify list displays
    • Check URL has no edit parameter
  2. Edit Mode:

    • Click an entry row
    • Verify URL shows ?edit=UUID
    • Verify fields component loads
    • Check full multilingual data available
  3. Language Switch:

    • Switch language in edit mode
    • Verify URL preserves ?edit=UUID
    • Verify entry data reloads in new language
  4. 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);
  });
});

architectureurl-patternsnavigation
Was this page helpful?