Preserve images during conversion

Available in Start planBetav0.12.3

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.

 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:

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 reference for all available options.

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.
  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.

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.

PartValue
MethodPOST, or the method you configured
BodyThe raw image bytes. Not multipart/form-data, and there is no file field
Content-TypeThe MIME type of the image, such as image/png
File-NameThe 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, and it needs to handle concurrent requests, since several images can be in flight at once.

What the response must contain

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

{ "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.

Either way you get a message naming the file in the import's verbose output. When your endpoint answered but its url could not be used, that message also carries one of these reasons:

ReasonTypical cause
the response did not contain a url fieldThe 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 stringurl holds a number or an object
the url field was emptyurl is "", or only whitespace
the URL contains a space or a control character, so it cannot be used as-isAn 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 againstA 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) URLA missing slash, as in https:/my-cdn.com/image1.png, or no host at all
only http and https URLs can be usedA 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.
  • 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". 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.

 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:

 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,
     })
   },
 })