Skip to content
weftGitHub

LearnFull-stack apps

Uploads and media

Images you ship with the app are files in your project: you import them with ?media and render them with <Media>, and the build makes every size and format ahead of time. Text, media and shapes covers that. This page is about the other kind: files your users give you, such as an avatar, a photo for a listing or a video for a post. Nobody saw those at build time, so the server has to do the same work when they arrive.

In React you'd pick an upload library, a storage service and an image CDN, and glue the three together. weft has one path for it: a <Form> sends the file to a mutation, ctx.media.ingest stores it and answers with a record, you save the record in a column, and <Media src={record}> renders it with the right sizes, formats and placeholder.

Check it before it leaves the browser
import { derived, state } from '@weft-js/core'import { Alert, Card } from '@weft-js/warp'// The same numbers the server enforces: `media({ server: { maxBytes } })` and the mutation's `z.file()`.const LIMIT = 2 * 1024 * 1024const TYPES = ['image/jpeg', 'image/png', 'image/webp']type Picked = { name: string; bytes: number; type: string }const size = (n: number) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`)export default function UploadCheck() {  const picked = state<Picked | undefined>(undefined)  const problem = derived(() => {    const f = picked.value    if (!f) return ''    if (!TYPES.includes(f.type)) return `${f.name} is ${f.type || 'not an image'}: pick a JPEG, PNG or WebP.`    if (f.bytes > LIMIT) return `${f.name} is ${size(f.bytes)}, over the ${size(LIMIT)} limit.`    return ''  })  const pick = (e: Event & { currentTarget: HTMLInputElement }) => {    const f = e.currentTarget.files?.[0]    picked.value = f && { name: f.name, bytes: f.size, type: f.type }  }  return (    <Card title="Check it before it leaves the browser">      <label>        Photo <input type="file" accept={TYPES.join(',')} onChange={pick} />      </label>      {picked.value && (        <Alert tone={problem.value ? 'critical' : 'positive'}>          {problem.value || `${picked.value.name}, ${size(picked.value.bytes)}: this would be sent.`}        </Alert>      )}    </Card>  )}

Try this: Pick a large photo, then a PDF, then a small JPEG. Only the last would be sent, and the server would still check it again.

Turning on the media server

The work happens in a plugin, @weft-js/plugin-media, which provides the media.server slot. Add it once:

bash
weft add media

That puts media() in plugins in weft.config.ts. Its options are the same ones that shape build-time images, plus server for uploads:

<!-- example: skip the scratch app doesn't install @weft-js/plugin-media -->

ts
// weft.config.tsimport { defineConfig } from '@weft-js/core/config'import { media } from '@weft-js/plugin-media'import { warp } from '@weft-js/warp/plugin'export default defineConfig({  plugins: [warp(), media({ server: { maxBytes: 10 * 1024 * 1024 } })],})

The encoder is sharp, so the app needs sharp in its dependencies for the built server to load it. A build that can't find it warns with M017, and so does any request that reaches the media code with no media.server provider: ctx.media.ingest throws M017 rather than lose the file quietly.

Uploading a file with a form

A file goes through <Form>. A mutation called from code sends its input as JSON, which can't carry a file; a form sends a FormData with the file in it, and the server decodes the fields into the mutation's input as it would any form. Forms and validation covers <Form> itself.

Start with where the record will live. It's JSON, so a JSON text column holds it. Here's a table in a file of its own, the way weft add pay writes its tables:

ts
// src/db/photos.schema.tsimport type { MediaAsset } from '@weft-js/start/media'import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'export const photos = sqliteTable('photos', {  id: text('id').primaryKey(),  userId: text('user_id').notNull(),  alt: text('alt').notNull(),  image: text('image', { mode: 'json' }).$type<MediaAsset>().notNull(),  createdAt: integer('created_at').notNull(),})

Add export * from './photos.schema' to src/db/schema.ts and run weft db generate, as in Database.

Now the server functions. The input has a z.file() field with the limits you want, and run hands the file to ctx.media.ingest:

ts
// src/server/photos.tsimport { mutation, query } from '@weft-js/start'import { z } from '@weft-js/start/media'import { desc, eq } from 'drizzle-orm'import { photos } from '#/db/photos.schema'export const listPhotos = query({  tags: ['photos'],  auth: 'user',  run: ({ ctx }) => ctx.db.select().from(photos).where(eq(photos.userId, ctx.user.id)).orderBy(desc(photos.createdAt)),})export const uploadPhoto = mutation({  input: z.object({    photo: z.file().max(8 * 1024 * 1024).mime(['image/jpeg', 'image/png', 'image/webp']),    alt: z.string().trim().min(1).max(200),  }),  auth: 'user',  invalidates: ['photos'],  run: async ({ input, ctx }) => {    const image = await ctx.media.ingest(input.photo)    const [row] = await ctx.db      .insert(photos)      .values({ id: crypto.randomUUID(), userId: ctx.user.id, alt: input.alt, image, createdAt: Date.now() })      .returning()    return row  },})

z here comes from @weft-js/start/media. It's Zod's API with one addition, z.media(), which you'll meet below; import { z } from 'zod' works just as well for everything else.

The page is an ordinary <Form> with a file input. Give the form enctype="multipart/form-data", so the file is sent even before JavaScript has loaded:

tsx
import { Media } from '@weft-js/core/media'import { Form, type FormApi } from '@weft-js/start'import { listPhotos, uploadPhoto } from '#/server/photos'export function Photos() {  const photos = listPhotos()  return (    <section>      <Form of={uploadPhoto} enctype="multipart/form-data">        {(form: FormApi) => (          <>            <label>              Photo <input type="file" name="photo" accept="image/jpeg,image/png,image/webp" required />            </label>            <form.Error name="photo" />            <label>              Describe it <input {...form.field('alt')} />            </label>            <form.Error name="alt" />            <p>{form.message.value}</p>            <button type="submit" disabled={form.pending.value}>              {form.pending.value ? 'Uploading…' : 'Upload'}            </button>          </>        )}      </Form>      <ul>        {(photos.value ?? []).map(p => (          <li key={p.id}>            <Media src={p.image} alt={p.alt} width="240px" />          </li>        ))}      </ul>    </section>  )}

When the upload succeeds, invalidates: ['photos'] brings the list back in the same response, and the new photo appears. When it fails, the error lands next to the field it belongs to, or in form.message when it names no field.

Deep diveWhy the file only travels through a form

A mutation call from code is fetch with a JSON body, so its input has to be JSON. A <Form> is a real <form method="post">: before hydration the browser posts it, and after hydration weft sends the same FormData with fetch. Either way the server gets multipart fields, decodes them into the input (a.b nests, x[] makes an array, an empty file input is left out), and validates them with your schema. So one mutation serves both the no-JavaScript post and the in-page submit, and a file is just another field.

What ingest gives you

ctx.media.ingest(file) takes a File, a Blob or a Uint8Array, stores the original under a hash of its bytes, and returns a MediaAsset record:

ts
{  kind: 'photo',                     // or 'graphic', 'vector', 'video', 'audio'  src: '/_weft/m/3f9c…/1280.auto',   // the largest size  width: 1280, height: 853,          // after EXIF rotation  type: 'image/jpeg',  sources: [{ type: 'image/jpeg', srcset: '/_weft/m/3f9c…/320.auto 320w, …' }],  dominant: '#6b7f5a',               // a colour to fill the box with until it loads  placeholder: 'data:image/webp;base64,…',  name: 'IMG_2041.jpg',  quality: 'fixed',}

That's the same shape a ?media import gives you, so <Media> doesn't care where a record came from. It knows the width and height before anything loads, so the page doesn't shift, and it writes srcset and sizes so a phone fetches a phone-sized file.

The same bytes always get the same hash, so uploading a file twice stores it once. For bytes you already have, such as an image fetched from another service, pass a Uint8Array and a name: ctx.media.ingest(bytes, { name: 'cover.png' }).

Typing a record with z.media()

A record that comes from somewhere else (a CMS, an import, an old table) should be checked before <Media> trusts it. z.media() is the schema for it. Use it in an output, or anywhere you parse data you didn't write:

ts
// src/server/cms.tsimport { query } from '@weft-js/start'import { z } from '@weft-js/start/media'const Post = z.object({ slug: z.string(), title: z.string(), cover: z.media(), coverAlt: z.string() })export const featured = query({  tags: ['posts'],  output: z.array(Post),  run: async () => {    const res = await fetch('https://cms.example.com/featured.json')    return z.array(Post).parse(await res.json())  },})

Limits and refusals

Some files never get stored. ingest refuses them with a code, and the mutation fails with status 422, so <Form> shows the message:

  • M018: the file isn't an image weft can read (PNG, JPEG, GIF, WebP, AVIF, SVG), or it's over the size limit. The limit is media({ server: { maxBytes } }), 20 MB by default.
  • M012: an SVG with active content or external references, such as a script or an outside image. An SVG can carry code, so one that does isn't stored at all.

Your own schema comes first, though. z.file().max(…).mime([...]) refuses a file with a field error before ingest spends any time on it, and the error is attached to the right input. Keep the two numbers in step: the schema's limit for your users, the plugin's as the ceiling.

Checking on the client too is kind, not safe. The demo at the top of this page refuses a big file before it's sent, which saves a slow upload that would fail anyway. A client check can always be skipped, so the server's check is the one that counts.

Serving: sizes, formats and caching

The record's URLs point at one route, /_weft/m/<hash>/<width>.<format>. Nothing is resized when you upload. The first request for a size encodes it, writes it next to the original, and serves it; every later request reads the file. Two requests for the same size at once share one encode.

  • auto as the format picks the best one the browser says it accepts (AVIF when it asks for it), and falls back to the original's kind: JPEG for photos, PNG for graphics.
  • Only widths on the ladder (320, 640, 960, 1280, 1920 and 2560 by default, up to the original's own width) and formats in the config are served, so nobody can make the server encode a thousand sizes by editing a URL.
  • Responses are immutable for a year with an ETag. A new upload is a new hash, so a cached file is never wrong.
  • SVGs are cleaned with SVGO and served with a sandbox content security policy, and every file gets nosniff.

The media() options formats, widths, quality and placeholder shape uploads and build-time images alike.

Where the files live

On disk: .weft/media under the app, or the directory in WEFT_MEDIA_DIR. Each upload is a folder named by its hash, with the original, its sizes and a small meta.json. That works on every server runtime, and it means the folder has to outlive a deploy. On a container or a VPS, point WEFT_MEDIA_DIR at a volume. Runtimes and deploying has more on what a host keeps between deploys.

Video, audio and animated GIFs

Images need nothing else. Video and audio need an encoder, which weft doesn't ship: @weft-js/plugin-media-ffmpeg runs an ffmpeg you install on the server.

bash
weft add media-ffmpeg

With mediaFfmpeg() in plugins and ffmpeg on the server's PATH (or a binary path, optionally pinned with sha256), ingest takes video and audio too. It answers at once with the record, and a queue encodes the sizes one at a time in the background, so a burst of uploads doesn't swamp the server. Until a size exists, its URL serves the original when browsers can play it, marked so nothing caches it, and otherwise waits for that encode. A restart loses nothing: the first request for a missing size encodes it.

Video gets AV1 (or the codecs in media({ video: { codecs } })) with an H.264 copy for everything else, and a poster frame; audio gets Opus and AAC. An animated GIF becomes a video when that's clearly smaller, and stays a GIF when it isn't.

Without the plugin, a video upload is refused with M018, which names what to add. An ffmpeg that's missing, isn't the pinned binary, or lacks an encoder is M019.

Recap

  • weft add media turns on the media server. Without it, ctx.media.ingest fails with M017.
  • A file reaches a mutation through <Form enctype="multipart/form-data">, with a z.file() field in the input.
  • ctx.media.ingest(file) stores the original under its hash and returns a MediaAsset. Store that record in a JSON column and render it with <Media src={record} alt={…} />.
  • z.media() from @weft-js/start/media checks a record you didn't make.
  • Refusals come back as 422 with a code: M018 for not an image or too big, M012 for an unsafe SVG. Check size and type in your schema first; a client check only saves time.
  • Sizes are encoded on first request, cached forever, and limited to the ladder. Files live in .weft/media or WEFT_MEDIA_DIR, which must outlive a deploy.
  • Video and audio need @weft-js/plugin-media-ffmpeg and an ffmpeg you install.

Try it

Challenge 1

Avatars, one per user

Write a setAvatar mutation that takes one image of at most 2 MB, stores the record, and refreshes every query tagged profiles. Assume a profiles table with userId and a JSON avatar column (write it as its own schema file).

Show the solution
ts
// src/db/profiles.schema.tsimport type { MediaAsset } from '@weft-js/start/media'import { sqliteTable, text } from 'drizzle-orm/sqlite-core'export const profiles = sqliteTable('profiles', {  userId: text('user_id').primaryKey(),  avatar: text('avatar', { mode: 'json' }).$type<MediaAsset>(),})
ts
// src/server/profile.tsimport { mutation } from '@weft-js/start'import { z } from '@weft-js/start/media'import { profiles } from '#/db/profiles.schema'export const setAvatar = mutation({  input: z.object({ avatar: z.file().max(2 * 1024 * 1024).mime(['image/jpeg', 'image/png', 'image/webp']) }),  auth: 'user',  invalidates: ['profiles'],  run: async ({ input, ctx }) => {    const avatar = await ctx.media.ingest(input.avatar)    await ctx.db      .insert(profiles)      .values({ userId: ctx.user.id, avatar })      .onConflictDoUpdate({ target: profiles.userId, set: { avatar } })    return avatar  },})

The schema refuses a big or non-image file with a field error before anything is stored. onConflictDoUpdate keeps one row per user, and the old file stays on disk under its own hash, which matters if anything still points at it.

Challenge 2

Why does the photo jump?

A teammate renders uploads like this, and the page shifts every time an image loads. What's wrong, and what does weft check say?

tsx
<img src={p.image.src} alt={p.alt} />
Show the solution

A bare <img> has no width or height until the file arrives, so the browser can't reserve its space (M001), and it always fetches the largest size. The record already knows the dimensions and every size, so let <Media> use them:

tsx
<Media src={p.image} alt={p.alt} width="1/3" />

width says how wide it's drawn (here a third of the viewport, the full width on a phone), which becomes sizes, so the browser picks the smallest file that's sharp enough.

Challenge 3

Import from a URL

Users paste a link to an image instead of picking a file. Write a mutation that fetches it on the server and stores it like an upload. What should it refuse?

Show the solution
ts
// src/server/import.tsimport { FnError, mutation } from '@weft-js/start'import { z } from '@weft-js/start/media'export const importImage = mutation({  input: z.object({ url: z.url().max(2000) }),  auth: 'user',  run: async ({ input, ctx }) => {    const res = await fetch(input.url)    if (!res.ok) throw new FnError({ message: `That link answered ${res.status}` }, 422)    const bytes = new Uint8Array(await res.arrayBuffer())    return ctx.media.ingest(bytes, { name: new URL(input.url).pathname.split('/').pop() || 'image' })  },})

ingest still refuses anything that isn't an image or is over the limit (M018), and an unsafe SVG (M012). A real version should also refuse addresses inside your own network, since the server, not the user, makes the request, and cap how long it waits.