---
title: "Preserve images during conversion"
description: "Preserve images in your converted documents by providing an image upload callback url. Learn more in the docs."
canonical_url: "https://tiptap-docs.lin52025i.qzz.io/conversion/import/docx/image-handling"
---

# Preserve images during conversion

Preserve images in your converted documents by providing an image upload callback url. Learn more in the docs.

- **1. Activate trial or subscribe**

  Start a [free trial](https://cloud.tiptap.dev/v2?trial=true) or [subscribe to the Start plan](https://cloud.tiptap.dev/v2/billing) in your account.
- **2. Install from private registry**

  To install this frontend extensions, authenticate to Tiptap’s private npm registry by following the [setup guide](https://tiptap-docs.lin52025i.qzz.io/guides/pro-extensions.md).

Some documents that you're importing may include images that you may want to preserve in the converted document.

> **Note:**
>
> Tiptap does not provide an image upload service. You will need to implement your own server to
> handle image uploads.

## Import images

If you import a DOCX file that has images, the conversion service can include those images in the resulting Tiptap JSON only if you provide an image upload configuration.

Use the `imageUploadConfig` option to specify an endpoint on your server where the conversion service will upload images during the import process.

```js
 import { Editor } from '@tiptap/core'
 import { ImportDocx } from '@tiptap-pro/extension-import-docx'

 const editor = new Editor({
   // ... other editor options,
   extensions: [
     ImportDocx.configure({
       token: '<your-jwt>',
       imageUploadConfig: {
         url: 'https://your-server.com/upload-image',
       },
     })
   ]
 })
```

In this configuration, `imageUploadConfig.url` is set to an endpoint on your server that will handle receiving image files. If this is not provided, the importer will strip out images from the document.

When an import is triggered, the conversion service will upload each embedded image to the URL you provided.

### Authenticated image uploads

If your upload endpoint requires authentication or custom headers, you can configure them directly:

```js
ImportDocx.configure({
  token: '<your-jwt>',
  imageUploadConfig: {
    url: 'https://your-server.com/upload-image',
    headers: {
      Authorization: 'Bearer your-upload-token',
    },
    method: 'PUT',
    queryParams: {
      bucket: 'my-bucket',
    },
  },
})
```

See the [Image upload configuration](https://tiptap-docs.lin52025i.qzz.io/conversion/import/docx/editor-extension.md#image-upload-configuration) reference for all available options.

When the upload headers carry a short-lived token, pass `imageUploadConfig` to the `importDocx` command instead, so each import sends a fresh one. See [Pass a fresh token on each import](https://tiptap-docs.lin52025i.qzz.io/conversion/import/docx/editor-extension.md#pass-a-fresh-token-on-each-import).

### Callback process

This endpoint can be implemented with any web framework or cloud function. The key steps you need to integrate are:

1. **Receive the file:** Read the raw request body, and take the filename from the `File-Name` header. See [what the request looks like](#what-the-request-looks-like).
2. **Store the image:** Save the image to a location that is accessible via URL. This could be an AWS S3 bucket, a storage service like Cloudinary, or a public folder on your server. Generate a public URL for the saved file.
3. **Return the URL:** Send back a `2xx` response with a JSON body containing the image’s absolute URL. For example: `{ "url": "https://my-cdn.com/uploads/unique-image-name.png" }`. See [what the response must contain](#what-the-response-must-contain).

The Tiptap conversion service then takes that URL and inserts it into the Tiptap JSON as the `src` of an image node.

### What the request looks like

You get one request per image, with the image itself as the raw request body.

| Part           | Value                                                                        |
| -------------- | ---------------------------------------------------------------------------- |
| Method         | `POST`, or the `method` you configured                                       |
| Body           | The raw image bytes. Not `multipart/form-data`, and there is no `file` field |
| `Content-Type` | The MIME type of the image, such as `image/png`                              |
| `File-Name`    | The filename of the image, such as `image1.png`                              |

So read the body as a blob or a buffer, not as form data.

Two things worth knowing:

- **Your `headers` and `queryParams` are sent as well**, so you can authenticate the request. `Content-Type` and `File-Name` are set by the conversion service, so you cannot replace them with your own.
- **`File-Name` comes from inside the DOCX**, where Word numbers pictures `image1`, `image2` and so on, restarting at 1 in every document. So the same name turns up across imports, which makes it a label rather than a unique key. If you build your storage key from it, add something per document to keep separate imports apart.

Your endpoint should answer within 10 seconds. A slower answer counts as a failed upload for that image. Up to three uploads run at the same time, so it needs to handle concurrent requests.

Every image in the document is posted, whatever its type. `Content-Type` is set from the file extension for `png`, `jpeg`, `gif`, `bmp`, `tiff`, `webp`, `svg`, `emf` and `wmf`. Anything else is sent as `application/octet-stream`, so decide on your side which types you keep. There is no limit on the size of a single image or on the number of images in a document.

### What the response must contain

A `2xx` status, and a JSON body with a `url` string:

```json
{ "url": "https://my-cdn.com/uploads/unique-image-name.png" }
```

The `url` must be an absolute `http` or `https` URL that includes the host. It is used exactly as you return it: nothing is re-encoded or normalized, apart from whitespace around the value, so a signed URL still validates.

#### Relative URLs are rejected, not resolved

A path like `/uploads/image1.png` is rejected. Conversion runs on our servers rather than in your page, so there is no origin to resolve it against, and guessing one would produce a URL that silently points at the wrong place. Return the full URL yourself. Protocol-relative values like `//my-cdn.com/image1.png` are rejected for the same reason.

#### When a URL cannot be used

That image is skipped and the rest of the document imports normally. The same happens when the upload itself fails, times out, or returns something that is not JSON. A failed upload is not retried, and it never fails the import.

Either way you get a message naming the file in the import's [verbose output](https://tiptap-docs.lin52025i.qzz.io/conversion/import/docx/editor-extension.md#verbose-output). When your endpoint answered but its `url` could not be used, that message also carries one of these reasons:

| Reason                                                                                   | Typical cause                                                                                        |
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| the response did not contain a `url` field                                               | The JSON has no `url` key, uses a different one such as `location` or `src`, or sets `url` to `null` |
| the `url` field was not a string                                                         | `url` holds a number or an object                                                                    |
| the `url` field was empty                                                                | `url` is `""`, or only whitespace                                                                    |
| the URL contains a space or a control character, so it cannot be used as-is              | An unencoded space or newline in the value. Percent-encode it before returning it                    |
| the URL is relative, and this runs server side so there is no page to resolve it against | A path such as `/uploads/image1.png`, or a protocol-relative `//my-cdn.com/image1.png`               |
| the URL is not a well-formed absolute http(s) URL                                        | A missing slash, as in `https:/my-cdn.com/image1.png`, or no host at all                             |
| only http and https URLs can be used                                                     | A scheme such as `s3://` or `data:`                                                                  |

The reported URL is cut off before the query string, so a token in a signed URL is not written to the log.

### Important considerations

- **Public accessibility:** The endpoint URL you provide must be reachable from the internet, since Tiptap’s cloud service will call it. It cannot be localhost or behind a firewall. Likewise, the returned image URL should be publicly accessible (or at least accessible to anyone who needs to view the document)
- **Correct response format:** Your endpoint must return a JSON object with a `url` field holding an absolute `http` or `https` URL. Anything else means that image is skipped, with the reason in the import’s verbose output. See [what the response must contain](#what-the-response-must-contain).
- **Security:** Tiptap doesn’t restrict what endpoint you use. You can pass custom headers (e.g., `Authorization: Bearer ...`) and query parameters via the `imageUploadConfig` option for authentication. The conversion service will forward these when uploading images. Implement any necessary auth on your side (for instance, verifying a Bearer token or API key in the request headers).
- **Persistence of images:** The URLs you return will be used in your editor’s content going forward. For example, after import, your editor will have image nodes with src: "[https://my-cdn.com/uploads/unique-image-name.png](https://my-cdn.com/uploads/unique-image-name.png)". Anyone who later exports or views that content will attempt to load that URL. Make sure the images remain available at those URLs (don’t delete them immediately)​

### Server implementation example

This example shows a simple server implementation that accepts image uploads & uploads them to an S3 bucket configured by environment variables.

```ts
 import { serve } from '@hono/node-server'
 import { Hono } from 'hono'
 import { Upload } from '@aws-sdk/lib-storage'
 import { S3Client } from '@aws-sdk/client-s3'

 const {
   AWS_ACCESS_KEY_ID,
   AWS_SECRET_ACCESS_KEY,
   AWS_REGION,
   AWS_S3_BUCKET,
   PORT = '3011',
   AWS_ENDPOINT,
   AWS_FORCE_STYLE,
 } = process.env

 if (!AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY || !AWS_S3_BUCKET) {
   console.error('Please provide AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_S3_BUCKET')
   process.exit(1)
 }

 const s3 = new S3Client({
   credentials: {
     accessKeyId: AWS_ACCESS_KEY_ID,
     secretAccessKey: AWS_SECRET_ACCESS_KEY,
   },

   region: AWS_REGION,
   endpoint: AWS_ENDPOINT,
   forcePathStyle: AWS_FORCE_STYLE === 'true',
 })

 const app = new Hono() as Hono<any>

 app.post('/upload', async (c) => {
   const file = await c.req.blob()
   const filename = c.req.header('File-Name')
   const fileType = c.req.header('Content-Type')
   // Word restarts at image1 in every document, so add something unique
   // when building the storage key.
   const key = `${crypto.randomUUID()}-${filename}`

   if (!file) {
     return c.json({ error: 'No file uploaded' }, 400)
   }

   try {
     const data = await new Upload({
       client: s3,
       params: {
         Bucket: AWS_S3_BUCKET,
         Key: key,
         Body: file,
         ContentType: fileType,
       },
     }).done()

     return c.json({ url: data.Location })
   } catch (error) {
     console.error(error)
     return c.json({ error: 'Failed to upload file' }, 500)
   }
 })

 serve({
   fetch: app.fetch,
   port: Number(PORT) || 3000,
 })
```

Here is another implementation using bun with no dependencies:

```ts
 const s3Client = new Bun.S3Client({
   accessKeyId: process.env.AWS_ACCESS_KEY_ID,
   secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
   region: process.env.AWS_REGION,
   bucket: process.env.AWS_BUCKET,
   endpoint: process.env.AWS_ENDPOINT,
 })

 Bun.serve({
   port: 8081,
   async fetch(req) {
     const url = new URL(req.url)

     // Handle file uploads on the /upload endpoint
     if (url.pathname === '/upload') {

       const file = await req.blob()
       const filename = req.headers.get('File-Name')!
       const fileType = req.headers.get('Content-Type')!
       // Word restarts at image1 in every document, so add something unique
       // when building the storage key.
       const key = `${crypto.randomUUID()}-${filename}`

       if (!file) {
         return new Response(JSON.stringify({ error: 'No file uploaded' }), {
           status: 400,
           headers: {
             'content-type': 'application/json',
           },
         })
       }

       try {
         // Store under our own key, with the type from the request
         const s3File = s3Client.file(key, { type: fileType })
         // Write the file to S3
         await s3File.write(file)

         return new Response(
           JSON.stringify({
             // Send the URL of the uploaded file back to the client to insert it into the editor
             url: new Response(s3File).headers.get('location'),
           }),
           {
             headers: {
               'content-type': 'application/json',
             },
           },
         )
       } catch (error) {
         return new Response(
           JSON.stringify({
             error: error instanceof Error ? error.message : 'Failed to upload file',
           }),
           {
             status: 500,
             headers: {
               'content-type': 'application/json',
             },
           },
         )
       }
     }

     return new Response(JSON.stringify({ error: 'Not found' }), {
       status: 404,
     })
   },
 })
```
