---
name: media-seo-publishing
description: Model media, SEO, and publishing workflows for website projects on this starter. Covers file fields, image validation/filtering, alt texts, social metadata, publication windows, and SSR/cache implications.
---

# media-seo-publishing

## When to use this skill

Use this skill when:

- Designing media-heavy website content models
- Adding image/file fields and image filters
- Modeling SEO fields for pages or reusable content
- Defining publication windows and how they interact with runtime and SSR
- Building authoring workflows around images, metadata, and release control

## Medialib file serving

Images uploaded to medialib (via base64 or multipart) are stored with a relative `file.src` path such as `"file/example.jpg"`. In this starter, `MedialibImage` resolves that stored path together with the medialib entry ID into the project-local URL:

```
/api/medialib/{id}/file/example.jpg
```

Responsive image filters are then applied by the shared widget as query params:

```
/api/medialib/{id}/file/example.jpg?filter=s-webp
/api/medialib/{id}/file/example.jpg?filter=m-webp
/api/medialib/{id}/file/example.jpg?filter=l-webp
```

Those `/api/...` URLs are starter-local proxy URLs. For frontend rendering in Tibi website projects, the preferred approach is **not** to hand-build medialib URLs in each block or route component.

## Collection uploadPath

For Tibi file fields, the storage root is configured per collection via top-level `uploadPath`.

Important rules:

- `uploadPath` belongs on the collection itself, not on the individual `type: file` field
- in this starter, collection YAML files live in `api/collections/`, while deploy syncs the repo-root `media/` directory
- the current starter collections can omit `uploadPath` and rely on the tibi-server default derived from project config
- if you explicitly override `uploadPath` in this starter, it should normally point to `../media/<collection-name>` so deploy and runtime stay aligned
- do not write uploads into `api/media`; that path is not the persistent deploy target used by the project

Explicit override example:

```yaml
name: medialib
uploadPath: ../media/medialib

fields:
    - name: file
      type: file
```

For hidden thumbnails stored on the `content` collection, the same rule applies when you choose an explicit override at collection level:

```yaml
name: content
uploadPath: ../media/content

fields:
    - name: _pagebuilderThumbnail
      type: file
```

Tibi then stores uploads below:

```text
{uploadPath}/{entryId}/{fieldName}/{filename}
```

So an explicitly overridden medialib upload typically lands at `../media/medialib/{entryId}/file/{filename}`, while a content thumbnail lands at `../media/content/{entryId}/_pagebuilderThumbnail/{filename}`.

## Preferred frontend integration

Use a shared media widget such as `MedialibImage` as the frontend boundary for medialib-rendered images.

### MedialibImage with minimal entry (ID only)

When only a medialib ID is available (no resolved `_lookup` entry), pass a minimal entry structure together with the `id` prop:

```svelte
<MedialibImage
    id={medialibId}
    entry={{ file: { src: "file/example.jpg", type: "image/jpeg" } }}
    class="w-full h-full object-cover"
    noPlaceholder
/>
```

`resolveFileSrc()` in `MedialibImage` kombiniert `entry.file.src` (zum Beispiel `"file/example.jpg"`) mit der `id` zur korrekten URL: `${apiBase}/medialib/{id}/{src}`. Filter werden im Widget als `?filter=...` angehängt. Wenn `_lookup`-Daten mit vollständigem Entry verfügbar sind, bevorzugt diese verwenden:

```svelte
<MedialibImage entry={resolvedEntry} class="w-full h-full object-cover" noPlaceholder />
```

The preferred flow is:

1. request medialib references with `lookup`
2. pass the resolved `MedialibEntry` to the shared widget
3. let that widget own URL resolution, filter choice, SSR markup, and admin/pagebuilder compatibility

Typical usage:

```svelte
<script lang="ts">
  import MedialibImage from "../widgets/MedialibImage.svelte"

  let { block }: { block: ContentBlockEntry } = $props()
</script>

{#if block._lookup?.image}
  <MedialibImage
    entry={block._lookup.image}
    class="w-full h-full object-cover"
    minWidth={900}
    lazy={true}
  />
{/if}
```

For repeated collection data such as galleries, teaser lists, or detail-page image arrays, also request the lookup instead of rendering from raw ID strings:

```ts
const entries = await getCachedEntries<CollectionName>("your-collection", {
    filter: { active: true },
    sort: "sortOrder",
    lookup: "imageField:medialib",
})
```

`getCachedEntries()` expects `lookup` as an option in the second arguments object.

Then consume the resolved entry from `_lookup`, for example `_lookup.imageField` or `_lookup.imageField?.[0]` depending on whether the schema stores one image or an array.

### URL resolution strategy (frontend)

At the shared widget boundary, resolve image data with this priority:

1. Prefer a resolved `MedialibEntry` from `_lookup`
2. If `entry.file.src` is already absolute, use it directly
3. Otherwise construct the file URL from `{apiBase}/medialib/{entryId}/{file.src}` inside the shared widget
4. Only fall back to raw ID-to-URL construction in legacy code paths that cannot yet pass resolved entries

Do not duplicate medialib URL logic in every block. Keep it in one widget/helper layer so SSR, admin preview, and filter behavior stay consistent.

```ts
function resolveFileSrc(src: string | undefined, entryId: string | undefined, apiBase: string): string | null {
    if (!src) return null
    if (/^(https?:)?\/\//.test(src) || src.startsWith("/")) return src
    if (!entryId) return null
    return `${apiBase.replace(/\/+$/, "")}/medialib/${entryId}/${src.replace(/^\/+/, "")}`
}
```

### Lookup parameter

Use the `lookup` API parameter to resolve medialib references automatically:

```
/api/{collection}?lookup={fieldPath}:medialib
```

The resolved data is available in the `_lookup` field of the returned object at the corresponding path.

Typical project patterns:

- pagebuilder blocks with nested image refs: `blocks.someImageField:medialib`
- collection entries with a single image field: `image:medialib`
- collection entries with image arrays: `images:medialib`
- attachment arrays or document previews: `attachments:medialib`

Prefer adding the lookup at the data-loading boundary rather than rehydrating IDs later in the component tree.

## Filter sizing strategy

Do not hardcode one fixed filter per component unless the rendered width is truly fixed.

Preferred rules:

1. explicit `filter` prop wins when the caller already knows the right output size
2. otherwise pass a meaningful `minWidth` for layout-stable contexts such as hero, card, or gallery slots
3. let the shared widget derive the final filter from the measured width on the client
4. keep the width-to-filter mapping centralized in the widget instead of repeating `xs/s/m/l/xl` logic in blocks

This keeps image payloads reasonable without forcing each block author to manually guess the correct filter.

Typical examples:

- hero/background image: `minWidth={1600}`
- card image: `minWidth={640}`
- product detail main gallery image: `minWidth={960}`
- thumbnail image: `minWidth={240}`

The exact breakpoints can vary per project, but the sizing logic should remain centralized.

## SSR and no-JS behavior

For raster images, SSR cannot always know the final client width. The shared widget should therefore render deterministically:

1. render a normal `img` with a real `src` in SSR when a filter is explicit, `minWidth` is known, or admin rendering requires a fallback filter
2. emit a `noscript` fallback for raster images so crawlers and JS-disabled clients still receive a concrete image URL
3. avoid unstable per-block SSR hacks that guess widths differently from the client

This is especially important for image-bearing blocks, teasers, galleries, and detail views where HTML must remain stable between SSR, hydration, and no-JS rendering.

## Admin and pagebuilder compatibility

Medialib rendering must also work inside admin/pagebuilder preview contexts, not only on the public website.

Important rules:

- respect `apiBaseOverride` or the admin-provided project base when constructing medialib URLs
- do not prepend frontend public paths blindly when the admin already passes an absolute file URL
- consume hydrated `_lookup` data directly in preview/runtime code instead of trying to re-fetch references inside preview components
- keep placeholder asset resolution admin-safe too, not just medialib file URLs

If a block preview loads collection data in the admin, the preview context must provide the project-specific API base and the frontend code must route requests through that base.

In practice, that means the media widget and any helper used by it should be aware of `apiBaseOverride` or an equivalent admin-provided API base so the same component works in:

- public frontend
- SSR render
- admin pagebuilder preview
- collection-driven block previews

### Base64 file upload via API

Upload images to medialib via JSON API by including base64 data inline:

```json
POST /api/medialib
{
  "file": { "src": "data:image/jpeg;base64,..." },
  "title": "Image title",
  "alt": "Alt text"
}
```

The tibi-server saves the file below the collection's `uploadPath`, for example `../media/medialib/{entryId}/file/{filename}`, and creates the medialib entry.

## Goal

The goal is to make media, SEO, and publishing part of the actual solution design instead of leaving them as late add-ons.

For real website projects, these concerns affect:

- collection schema
- admin ergonomics
- frontend rendering
- SSR/cache validity
- editorial quality

## Source of truth

Use these sources when implementing or reviewing these areas:

- `tibi-server/docs/08-file-upload-images.md`
- the project's medialib collection config
- the relevant content or domain collection configs
- `tibi-admin-nova/types/admin.d.ts`
- `tibi-admin-nova/docs/collection-config.md`
- the project's SSR/runtime config hooks
- the project's frontend media widget/helper implementation

## Media modeling

Use file fields deliberately.

Typical choices:

- `file` for a single image or asset
- `file[]` for galleries or multi-asset attachments
- foreign references to a media collection when assets need their own lifecycle or reuse

Choose between inline file fields and dedicated media references based on reuse and editorial workflow, not just convenience.

## File validation rules

For serious website builds, do not leave file fields unconstrained.

Define validators where appropriate:

- accepted mime types
- max file size
- min/max image dimensions
- whether mixed media is allowed

This should reflect actual content needs. Hero images, logos, documents, and gallery media often need different constraints.

## Image filters

If the project serves resized or transformed assets, define image filters intentionally.

Use filters for:

- thumbnails
- card images
- hero images
- OpenGraph/social images when relevant

Do not leave every consuming component to invent its own ad hoc asset sizes.

## Alt texts and captions

Accessibility and SEO-relevant image metadata should be explicit in the model.

Recommended approach:

- store alt text explicitly
- keep captions separate from alt text
- use localized fields if the site is multilingual
- optionally use AI assistance only as a suggestion flow

Do not treat filenames as acceptable alt text.

## SEO modeling

Page-like collections should usually model SEO explicitly.

Typical fields:

- `meta.title`
- `meta.description`
- social/share image
- optional canonical information if required
- optional index/follow controls for advanced projects

SEO fields should be easy to find in Nova, usually via sidebar groups or clearly named sections.

## Publishing model

If the site uses publication timing, define it intentionally.

Typical concerns:

- draft versus active state
- publication window (`from` / `to`)
- visibility of unpublished content in public reads
- SSR cache validity for time-sensitive content

Publishing is not just a boolean. If publication windows exist, they must influence runtime and cache behavior.

## SSR implications

Media, SEO, and publishing affect SSR directly.

Examples:

- page meta tags must exist in SSR HTML when relevant
- navigation or content with publication windows must invalidate cached HTML correctly
- image-driven blocks must render stable URLs/markup in SSR

If publication timing can make cached HTML stale, the relevant collections must be accounted for in SSR publish-check logic.

## Admin ergonomics

Use current Nova features to make media/SEO workflows usable:

- sidebar groups for SEO/publication fields
- `viewHint.media` for media-focused collections
- previews for image-bearing entities
- layout grouping so editors do not scroll through one long file/SEO form

Media and SEO fields are often technically present but operationally poor if the admin layout is ignored.

## Recommended modeling patterns

### Marketing page

Recommended shape:

- main content blocks
- explicit SEO object or fields
- hero/share image strategy
- publication controls in sidebar

### Media library entry

Recommended shape:

- file field
- title/name
- alt text / caption
- optional copyright/source
- image-focused admin view

### Reusable teaser or card entity

Recommended shape:

- image reference or file
- short label/title
- teaser text
- consistent image filter usage in frontend components

## Anti-patterns

- No file validators on public/editor-uploaded images
- No explicit alt field
- Mixing captions and alt text into one field
- Hardcoding image sizes only in frontend CSS/components
- Building raw `/api/medialib/{id}/{src}` URLs in every block or hardcoding `file/file` instead of using one shared widget
- Repeating filter breakpoint logic in multiple components
- Rendering public image URLs in frontend code that break in admin pagebuilder preview
- Treating publication as frontend-only logic
- Forgetting that publish windows can invalidate SSR HTML

## Publication verification matrix

If the project uses publication timing, do not verify only one happy-path entry.

Check a small matrix deliberately:

1. currently published entry
2. future-scheduled entry
3. expired entry
4. token/admin visibility when editorial or operator access should still exist

This keeps publication modeling tied to the real public-read and SSR behavior instead of to optimistic field design.

## Verification checklist

After changing media/SEO/publishing behavior, verify all of these:

1. Upload validation matches the intended asset type.
2. Image filters are named and used consistently.
3. Shared image widgets receive resolved entries instead of rebuilding URLs ad hoc.
4. Alt/caption/SEO fields are explicit and editor-friendly.
5. Publication state affects public output correctly.
6. SSR HTML still reflects the intended published state.
7. Admin/pagebuilder preview resolves medialib images correctly.
8. `yarn validate` stays clean.

## What an LLM should inspect first

When asked to work on media, SEO, or publishing on this starter, inspect in this order:

1. `tibi-server/docs/08-file-upload-images.md`
2. the relevant collection YAML
3. the shared media widget/helper layer used by the frontend
4. admin layout and previews for those fields
5. frontend components consuming the media/SEO data
6. SSR publish-check and invalidation logic if timing matters

This prevents “just add an image field” changes that break runtime, editorial UX, or caching.
