Build a Dynamic Experience Gallery
Let creators manage experiences in Studio while your app updates automatically.
Goal
Configure your project once, fetch published experiences from Liforma, render <ExperienceThumbnail> cards from creator-managed slugs, and start sessions
with the returned experienceId.
1. Fetch the catalog on the server
Use your API key only on the server. Catalog rows include galleryThumb when catalogReady is true.
// src/lib/server/liformaCatalog.ts
import { env } from '$env/dynamic/private';
const API_BASE_URL = env.LIFORMA_API_URL?.replace(/\/$/, '') ?? 'https://api.liforma.ai';
const PROJECT_ID = env.LIFORMA_PROJECT_ID;
type GalleryThumb = {
avatarImage: string;
backgroundImage?: string;
foregroundImage?: string;
};
export type CatalogExperience = {
experienceId: string;
slug: string;
title: string;
catalogReady?: boolean;
galleryThumb?: GalleryThumb;
};
export async function fetchProjectCatalog(fetchFn: typeof fetch) {
const apiKey = env.LIFORMA_API_KEY?.trim();
if (!apiKey || !PROJECT_ID) {
throw new Error('LIFORMA_API_KEY and LIFORMA_PROJECT_ID are required.');
}
const response = await fetchFn(
`${API_BASE_URL}/v1/projects/${encodeURIComponent(PROJECT_ID)}/experiences`,
{
headers: { Authorization: `Bearer ${apiKey}` }
}
);
if (!response.ok) {
throw new Error('Could not load project catalog.');
}
const payload: { experiences: CatalogExperience[] } = await response.json();
return payload.experiences;
}
export async function fetchProjectExperienceBySlug(fetchFn: typeof fetch, slug: string) {
const apiKey = env.LIFORMA_API_KEY?.trim();
if (!apiKey || !PROJECT_ID) {
throw new Error('LIFORMA_API_KEY and LIFORMA_PROJECT_ID are required.');
}
const response = await fetchFn(
`${API_BASE_URL}/v1/projects/${encodeURIComponent(PROJECT_ID)}/experiences/${encodeURIComponent(slug)}`,
{
headers: { Authorization: `Bearer ${apiKey}` }
}
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error('Could not load project catalog experience.');
}
const payload: { experience: CatalogExperience } = await response.json();
return payload.experience;
} 2. Render thumbnail cards by slug
Display size comes from your CSS (the thumb fills its parent). See ExperienceThumbnail for hosted-player launch and other click modes.
experiences/+page.svelte
<!-- src/routes/experiences/+page.svelte -->
<script>
import { ExperienceThumbnail } from '@liforma/client/svelte/thumbnail';
let { data } = $props();
<\/script>
<div class="gallery">
{#each data.experiences as experience (experience.slug)}
{#if experience.galleryThumb}
<a class="card" href="/experiences/{experience.slug}" aria-label={experience.title}>
<div class="thumb">
<ExperienceThumbnail galleryThumb={experience.galleryThumb} alt="" />
</div>
<span>{experience.title}</span>
</a>
{/if}
{/each}
</div>
<style>
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 1rem;
}
.thumb {
width: 100%;
aspect-ratio: 1;
}
</style> 3. Resolve a slug route
// src/routes/experiences/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import { fetchProjectExperienceBySlug } from '$lib/server/liformaCatalog';
export async function load({ params, fetch }) {
const experience = await fetchProjectExperienceBySlug(fetch, params.slug);
if (!experience) error(404, 'Experience not found');
return { experience };
} 4. Embed with sessionEndpoint
<Experience
experienceId="exp_T0I7ACMQLBMPG6K"
sessionEndpoint="/api/liforma-session"
/> What creators control in Studio
- Experience title and public URL slug
- Publish / unpublish
- Gallery thumbnail and card imagery (
galleryThumb) - Sort order within the project
Your developer code stays generic. When creators add or reorder experiences, your gallery updates on the next server fetch.