Skip to content

Adding Direct App Uploads

Janko Marohnić edited this page Jul 6, 2026 · 28 revisions

This walkthrough shows how to add asynchronous uploads to a Rails app. The flow will go like this:

  1. User selects file(s)
  2. Files are uploaded asynchronously to an upload endpoint on your app
  3. Uploaded file JSON data is written to a hidden field
  4. Form is submitted instantaneously as it only has to submit the JSON data
  5. JSON data is assigned to the Shrine attachment attribute

1. Installation

Add Shrine to the Gemfile:

# Gemfile
# ...
gem "shrine", "~> 3.0"

and run bundle install.

2. Initializer

Create an initializer which configures your storage and loads default plugins:

# config/initializers/shrine.rb

require "shrine"
require "shrine/storage/file_system"

Shrine.storages = {
  cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
  store: Shrine::Storage::FileSystem.new("public", prefix: "uploads"),
}

Shrine.plugin :activerecord           # load Active Record integration
Shrine.plugin :cached_attachment_data # for retaining cached file on form redisplays
Shrine.plugin :restore_cached_data    # refresh metadata for cached files

3. Migration

Add the <attachment>_data text or JSON column to the table to which you want to add the attachment:

$ rails generate migration add_image_data_to_photos image_data:text # or :jsonb

This should generate the following migration:

class AddImageDataToPhotos < ActiveRecord::Migration
  def change
    add_column :photos, :image_data, :text # or :jsonb
  end
end

Run rails db:migrate to apply the migration.

4. Attachment

Create an uploader for the types of files you'll be uploading:

# app/uploaders/image_uploader.rb

class ImageUploader < Shrine
end

and add an attachment attribute to your model:

# app/models/photo.rb

class Photo < ApplicationRecord
  include ImageUploader::Attachment(:image)

  validates_presence_of :image
end

5. Form

In your form you can now add form fields for the attachment attribute, and an image tag for the preview:

<!-- app/views/photos/_form.html.erb -->

<%= form_for @photo do |f| %>
  <!-- ... -->
  <div>
    <%= f.label :image %>
    <%= f.hidden_field :image, value: @photo.cached_image_data, class: "upload-data" %>
    <%= f.file_field :image, class: "upload-file" %>
  </div>
<% end %>

<div class="upload-preview">
  <%= image_tag @photo.image_url.to_s, height: "300" %>
</div>

In your controller make sure to allow the attachment param:

# app/controllers/photos_controller.rb

class PhotosController < ApplicationController
  # ...
  def create
    @photo = Photo.new(photo_params)

    if @photo.save
      redirect_to @photo
    else
      render :new
    end
  end
  # ...

  private

  def photo_params
    params.require(:photo).permit(..., :image) # permit attachment param
  end
end

Now you should have working synchronous file uploads.

6. Direct upload

We can now add asynchronous direct uploads to the mix. We'll be using Uppy and its XHRUpload plugin, which will upload selected files to Shrine's upload endpoint.

6a. Upload endpoint

We'll first configure the upload_enpdoint plugin and mount it in our routes:

# config/initializers/shrine.rb
# ...
Shrine.plugin :upload_endpoint, url: true
# config/routes.rb

Rails.application.routes.draw do
  # ...
  mount Shrine.upload_endpoint(:cache) => "/upload"
end

6b. Uppy

Now we can setup Uppy to do the direct uploads. First we'll add the package to our bundle (we're assuming you're using Vite/esbuild/webpack, but it also works with import maps):

$ yarn add uppy
# or: npm install uppy
// app/javascript/application.js (or your bundler's entry point)
import 'uppy/dist/uppy.min.css'

Now we can setup direct uploads, where selected files will go to Shrine's upload endpoint, and upload result will be written to the hidden attachment field:

// app/javascript/fileUpload.js

import { Uppy, StatusBar, ThumbnailGenerator, XHRUpload } from 'uppy'

function fileUpload(fileInput) {
  const hiddenInput = document.querySelector('.upload-data'),
        imagePreview = document.querySelector('.upload-preview img')

  const uppy = new Uppy({
      autoProceed: true,
    })
    .use(StatusBar, {
      target: imagePreview.parentNode,
      hideUploadButton: true, // not needed since autoProceed is enabled
    })
    .use(ThumbnailGenerator, {
      thumbnailWidth: 600,
    })
    .use(XHRUpload, {
      endpoint: '/upload', // path to the upload endpoint
    })

  fileInput.addEventListener('change', (event) => {
    Array.from(event.target.files).forEach((file) => {
      try {
        uppy.addFile(file)
      } catch (error) {
        if (!error.isRestriction) throw error // ignore `restrictions` rejections
      }
    })
    event.target.value = null // allow selecting the same file again
  })

  uppy.on('thumbnail:generated', (file, preview) => {
    // show image preview while the file is being uploaded
    imagePreview.src = preview
  })

  uppy.on('upload-success', (file, response) => {
    // retrieve uploaded file data
    const uploadedFileData = response.body['data']

    // set hidden field value to the uploaded file data so that it's submitted
    // with the form as the attachment
    hiddenInput.value = JSON.stringify(uploadedFileData)
  })
}

export default fileUpload
// app/javascript/application.js
// ...
import fileUpload from 'fileUpload'

// listen on 'turbo:load' instead of 'DOMContentLoaded' if using Turbo
document.addEventListener('DOMContentLoaded', () => {
  document.querySelectorAll('.upload-file').forEach(fileInput => {
    fileUpload(fileInput)
  })
})

Note that fileInput can have the multiple attribute — the change handler above already loops over all selected files, so the same function works for both single and multiple file inputs.

And add the following CSS:

/* app/assets/stylesheets/application.css */
/* ... */
.upload-preview img {
  display: block;
  max-width: 100%;
}

.upload-preview {
  margin-bottom: 10px;
  display: inline-block;
  height: 300px;
}

img[src=""] {
  visibility: hidden;
}

And that's it, now when a file is selected it will be asynchronously uploaded to your app, accompanied by an image preview and a nice progress bar.

See also

Clone this wiki locally