Skip to Content
FeaturesAssets

Assets & Images

Hoikka stores uploaded images behind a storage seam and serves them through a route that resizes on demand. The same /uploads/... URLs work on both deployment targets.

Storage Seam

src/lib/server/storage/{index,fs,r2}.ts mirror the db seam: when the R2 binding (ASSETS_BUCKET) is present on the request (Cloudflare target) it is used; otherwise files go to the local filesystem under data/uploads (node target).

src/lib/server/storage/index.ts
export const upload = /* → { url } */; export const list = /* ... */; export const remove = /* ... */; export const get = /* → { body, contentType, size } | null */;

Serving & Resizing

/uploads/[...path] serves the file from the active backend and resizes it on demand via ?w=<width>&q=<quality>:

  • Node targetsharp with a disk cache (src/lib/server/images/node.ts).
  • Cloudflare target — the Cloudflare Images binding (IMAGES), falling back to the original when the binding isn’t configured, plus edge caching of transformed variants.
/uploads/products/shirt-abc123.jpg?w=400&q=80

Width is clamped to 16–2400, quality to 30–95 (default 80). Responses are immutable (filenames carry a random id).

The <Img> Component

Prefer src/lib/components/storefront/Img.svelte over raw <img> — it emits src plus a 1x/2x srcset, lazy-loads by default, and positions the crop on the asset’s focal point.

<script> import Img from "$lib/components/storefront/Img.svelte"; let { asset, name } = $props(); </script> <Img src={asset.source} alt={name} width={400} sizes="(max-width: 640px) 50vw, 400px" focalX={asset.focalX} focalY={asset.focalY} class="h-full w-full object-cover" />

Helpers

src/lib/image.ts
imageUrl(source, width?, quality?); // appends ?w=&q= for /uploads URLs; passes external URLs through imageSrcset(source, width, quality?); // "url 1x, url@2x 2x" for /uploads; undefined for external focalPosition(focalX, focalY); // CSS object-position from 0–1 coords

Only /uploads/... sources are transformed; external URLs are returned untouched.

Uploading

Uploads go through POST /api/assets/upload (src/routes/api/assets/upload/+server.ts), restricted to admin/staff and blocked in demo mode. It accepts JPEG, PNG, GIF, WebP, SVG, or AVIF up to 10 MB, writes to the storage backend via upload(), and returns the /uploads/... URL plus metadata.

1. Admin ImagePicker selects a file 2. Client POSTs it to /api/assets/upload 3. Server writes it to the active backend (fs or R2) 4. Server returns { url, name, width, height, size } 5. Client saves the asset record to the database

Database Schema

Assets are stored in the assets table:

src/lib/server/db/schema.ts
assets ├── id ├── name // file name ├── type // image | video | document | other ├── mimeType // image/jpeg, image/png, ... ├── width, height, fileSize ├── source // /uploads/... URL ├── alt // alt text ├── focalX // 0–1 horizontal focal point (default 0.5) ├── focalY // 0–1 vertical focal point (default 0.5) └── createdAt

Products reference assets by featuredAssetId:

products.featuredAssetId → assets.id product_variants.featuredAssetId → assets.id
Last updated on