Skip to main content
The media sharing system supports image and audio attachments with automatic preview generation, caption support, and file metadata tracking.

MediaAttachment interface

All media attachments use a unified interface:
lib/chat/types.ts
export interface MediaAttachment {
  id: string
  type: "image" | "audio"
  url: string
  thumbnailUrl?: string
  width?: number
  height?: number
  sizeInBytes?: number
  caption?: string
  waveform?: number[]
  localObjectUrl?: string
}

Field descriptions

  • id - Unique identifier for the attachment
  • type - Media type (image or audio)
  • url - Full resolution media URL
  • thumbnailUrl - Optional lower resolution preview URL
  • width, height - Dimensions for image attachments
  • sizeInBytes - File size for storage tracking
  • caption - Optional text caption
  • waveform - Audio visualization data (for audio type)
  • localObjectUrl - Temporary blob URL for local uploads
The localObjectUrl is used for preview before upload and should be revoked after use to prevent memory leaks.

Sending media messages

Send media attachments through the message composer:
const sendMessage = useChatStore((state) => state.sendComposerPayload)

sendMessage(chatId, {
  media: {
    id: "media-123",
    type: "image",
    url: "https://example.com/photo.jpg",
    width: 1920,
    height: 1080,
    caption: "Sunset at the beach"
  }
})

File upload handling

The MessageComposer component handles file selection and preview:

Dropzone integration

components/chat/message-composer.tsx
const onDrop = useCallback(
  (acceptedFiles: File[]) => {
    const media = acceptedFiles.map<MediaAttachment>((file) => {
      const objectUrl = URL.createObjectURL(file)
      return {
        id: `upload-${file.name}-${file.lastModified}`,
        type: "image",
        url: objectUrl,
        localObjectUrl: objectUrl,
        sizeInBytes: file.size,
        caption: file.name,
      }
    })

    setAttachments((prev) => [...prev, ...media])
  },
  []
)

const { getRootProps, getInputProps, isDragActive } = useDropzone({
  onDrop,
  multiple: true,
  accept: {
    "image/*": [],
  },
})
1

User selects files

Files are selected via drag-and-drop or file picker
2

Create object URLs

Temporary blob URLs are created for local preview
3

Display previews

Thumbnails are shown in the composer
4

Send message

Files are uploaded and message is sent
5

Clean up

Object URLs are revoked to free memory

Attachment preview

components/chat/message-composer.tsx
{attachments.length > 0 ? (
  <div className="mb-3 flex gap-2 overflow-x-auto pb-1">
    {attachments.map((attachment) => (
      <figure
        key={attachment.id}
        className="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl border border-border/60"
      >
        <img
          src={attachment.url}
          alt={attachment.caption ?? "Attachment"}
          className="h-full w-full object-cover"
        />
        <button
          type="button"
          onClick={(event) => {
            event.preventDefault()
            setAttachments((prev) =>
              prev.filter((item) => {
                if (item.id === attachment.id && item.localObjectUrl) {
                  URL.revokeObjectURL(item.localObjectUrl)
                }
                return item.id !== attachment.id
              })
            )
          }}
          className="absolute right-1 top-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-medium text-white"
        >

        </button>
      </figure>
    ))}
  </div>
) : null}
Always revoke object URLs when removing attachments or unmounting components to prevent memory leaks.

Memory management

Proper cleanup of object URLs is essential:
components/chat/message-composer.tsx
useEffect(() => {
  return () => {
    attachments.forEach((attachment) => {
      if (attachment.localObjectUrl) {
        URL.revokeObjectURL(attachment.localObjectUrl)
      }
    })
  }
}, [attachments])

Displaying media in messages

The MessageBubble component renders media attachments:
components/chat/message-bubble.tsx
{message.contentType === "image" && message.media ? (
  <button
    type="button"
    onClick={() => onMediaPreview?.(message.media as MediaAttachment)}
    className="group mb-2 block overflow-hidden rounded-2xl border border-border/40 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
  >
    <Image
      src={message.media.url}
      alt={message.media.caption ?? "Shared media"}
      width={message.media.width ?? 640}
      height={message.media.height ?? 360}
      className="h-auto max-h-[320px] w-full object-cover transition duration-500 group-hover:scale-[1.02]"
    />
    {message.media.caption ? (
      <p className="px-3 pb-2 pt-2 text-sm text-foreground/90">
        {message.media.caption}
      </p>
    ) : null}
  </button>
) : null}

Image optimization

The component uses Next.js Image for automatic optimization:
  • Responsive sizing with max-h-[320px]
  • Lazy loading by default
  • Automatic format selection (WebP when supported)
  • Hover effect with subtle zoom
Images are constrained to a maximum height of 320px to maintain consistent message bubble sizing.

Media message content type

Messages with media use the appropriate content type:
lib/chat/types.ts
export type MessageContentType = "text" | "image" | "audio"

export interface Message {
  id: string
  chatId: string
  authorId: string
  contentType: MessageContentType
  text?: string
  media?: MediaAttachment
  // ...
}

Creating media messages

Helper function for creating inbound media messages:
lib/chat/helpers.ts
export function createInboundMediaMessage(options: {
  chatId: string
  authorId: string
  media: MediaAttachment
  createdAt?: Date
}): Message {
  const createdAt = options.createdAt ?? new Date()
  const iso = createdAt.toISOString()
  return {
    id: generateMessageId(),
    chatId: options.chatId,
    authorId: options.authorId,
    contentType: options.media.type,
    media: options.media,
    status: "read",
    createdAt: iso,
    updatedAt: iso,
  }
}

Message preview text

Generate appropriate preview text for media messages:
lib/chat/helpers.ts
export function derivePreview(message: Message) {
  if (message.contentType === "image") {
    return message.media?.caption ?? "📷 Photo"
  }
  if (message.contentType === "audio") {
    return "🎧 Audio message"
  }
  return message.text ?? "New message"
}
This preview appears in the chat list:
components/chat/sidebar.tsx
<p className="truncate text-xs text-muted-foreground">
  {isTyping ? "Typing…" : chat.lastMessagePreview ?? "No messages yet"}
</p>

Sending attachments

When the user sends a message with attachments:
components/chat/message-composer.tsx
const handleSend = useCallback(() => {
  if (!canSend) return

  attachments.forEach((attachment) => {
    onSend(chatId, { media: attachment })
    if (attachment.localObjectUrl) {
      URL.revokeObjectURL(attachment.localObjectUrl)
    }
  })

  const trimmed = text.trim()
  if (trimmed.length > 0) {
    onSend(chatId, { text: trimmed })
  }

  setText("")
  setAttachments([])
  onClearDraft(chatId)
}, [attachments, canSend, chatId, onClearDraft, onSend, text])
Each attachment is sent as a separate message, followed by any text content.

File input fallback

For browsers without drag-and-drop support:
components/chat/message-composer.tsx
<Button
  size="icon"
  variant="ghost"
  className="h-10 w-10 text-muted-foreground"
  type="button"
  onClick={(event) => {
    event.preventDefault()
    const input = document.createElement("input")
    input.type = "file"
    input.accept = "image/*"
    input.multiple = true
    input.onchange = (e) => {
      const target = e.target as HTMLInputElement
      if (target.files) {
        onDrop(Array.from(target.files))
      }
    }
    input.click()
  }}
>
  <Paperclip className="h-5 w-5" />
  <span className="sr-only">Attach media</span>
</Button>

Media in mock data

Example of seeding media messages:
lib/chat/mock-data.ts
const message: Message = {
  id: messageId,
  chatId,
  authorId,
  contentType: isMedia ? "image" : "text",
  text: isMedia ? undefined : sampleText(authorId === profile.id ? "outgoing" : "incoming", i),
  media: isMedia
    ? {
        id: nextId("media"),
        type: "image",
        url: mediaPool[i % mediaPool.length],
        thumbnailUrl: mediaPool[i % mediaPool.length] + "&w=320",
        width: 1280,
        height: 720,
        caption:
          authorId === profile.id
            ? captions[(i / (opts.includeMediaEvery ?? 1)) % captions.length]
            : undefined,
      }
    : undefined,
  // ...
}

Build docs developers (and LLMs) love