ScreenshotNeo

BlogHow-to

How to Generate Website Previews for a Directory Built with Ruby on Rails

Generate website screenshot previews for Rails directory listings with background jobs, image storage, safe URL handling, fallbacks, and refresh rules.

By the ScreenshotNeo team4 October 202611 min read

To show a screenshot preview for every website in a Ruby on Rails directory, capture each listing’s public URL in background work, store the resulting image, and render that stored image in the listing card. Rails renders your directory pages; it does not automatically browse arbitrary URLs to make thumbnails. Keep capture failures separate from listing creation, validate destinations before fetching them, and show a fallback until a thumbnail is ready.

This guide builds that workflow with Active Job, Active Storage, and a screenshot API. The Ruby example uses ScreenshotNeo’s API; the same architecture works with a browser-rendering service you operate. API request details and options are documented in the ScreenshotNeo API documentation.

1. Decide what the preview should show

A directory thumbnail and a social sharing card solve different problems. A thumbnail is a screenshot of the destination site, helping visitors recognize or assess where a listing leads. Open Graph metadata describes how your directory page may appear when shared elsewhere. A directory can use both.

Approach Use it when Trade-offs
Live destination screenshot Visitors should see the listed site’s current appearance. It can become stale or show a consent banner, error state, or design change.
Branded directory card Consistent directory branding matters more than destination fidelity. You render the card from your listing data; it does not show the remote site’s actual appearance.
Open Graph metadata You want control over how the directory listing page looks when shared. Metadata is not a screenshot thumbnail inside your directory.

Choose based on fidelity, visual consistency, freshness, failure handling, storage and bandwidth, and how much browser infrastructure you want to own. For a branded card, render a reusable card from the listing name, category, and other directory data. Rasterize it only if another part of your system requires an image file.

2. Add a URL and attached preview to the listing

Store a canonical public URL on each listing and attach its generated image. Active Storage handles attachments; it does not visit a remote site to create a screenshot. Rails Action View renders the HTML around the preview, using templates and layouts. See the Rails Action View overview and Active Storage overview.

For an existing app, adapt the migration and model to its naming conventions. This example assumes a Listing model:

bin/rails generate migration AddWebsiteUrlToListings website_url:string
bin/rails active_storage:install
bin/rails db:migrate
# app/models/listing.rb
class Listing < ApplicationRecord
  has_one_attached :website_preview

  validates :website_url, presence: true
  validate :website_url_must_be_public_http_url

  after_commit :enqueue_website_preview, on: [:create, :update], if: :saved_change_to_website_url?

  private

  def website_url_must_be_public_http_url
    return if website_url.blank?

    uri = URI.parse(website_url)
    unless uri.is_a?(URI::HTTP) && uri.is_a?(URI::HTTPS) || uri.is_a?(URI::HTTP)
      errors.add(:website_url, "must be an HTTP or HTTPS URL")
      return
    end

    if uri.host.blank? || uri.userinfo.present?
      errors.add(:website_url, "must have a host and must not contain user information")
    end
  rescue URI::InvalidURIError
    errors.add(:website_url, "is not a valid URL")
  end

  def enqueue_website_preview
    GenerateWebsitePreviewJob.perform_later(id)
  end
end

Require uri if it is not already loaded in your Rails environment. The example validation is a basic syntax and scheme check, not a complete defense for server-side URL fetching. Production URL capture needs additional controls for private addresses, redirects, DNS changes, and outbound network access; see the security section.

The condition above is deliberately explicit in intent: accept HTTP and HTTPS URIs, reject credentials embedded in the URL, and require a host. In applications with custom URL rules, use a dedicated value object or validator so create and update paths share the same policy.

3. Capture asynchronously and attach the image

Enqueue capture after the record commits so a slow or unavailable destination does not hold up a form submission. Configure Active Job with a queue adapter appropriate for your deployment. The following job makes a GET request to ScreenshotNeo, checks the response, and attaches the returned bytes. Set the API key in your secret manager or encrypted Rails credentials as SCREENSHOTNEO_API_KEY; do not put it in source control.

# app/jobs/generate_website_preview_job.rb
require "net/http"
require "uri"

class GenerateWebsitePreviewJob < ApplicationJob
  queue_as :default

  retry_on Net::OpenTimeout, Net::ReadTimeout, wait: :polynomially_longer, attempts: 3

  def perform(listing_id)
    listing = Listing.find_by(id: listing_id)
    return unless listing

    uri = URI("https://api.screenshotneo.com/v1/shot")
    uri.query = URI.encode_www_form(
      access_key: ENV.fetch("SCREENSHOTNEO_API_KEY"),
      url: listing.website_url,
      format: "webp",
      width: 1200,
      height: 800
    )

    response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
      open_timeout: 10, read_timeout: 90) do |http|
      http.get(uri.request_uri)
    end

    unless response.is_a?(Net::HTTPSuccess) && response["Content-Type"].to_s.start_with?("image/")
      Rails.logger.warn("Website preview capture failed for listing #{listing.id}: HTTP #{response.code}")
      return
    end

    listing.website_preview.attach(
      io: StringIO.new(response.body),
      filename: "listing-#{listing.id}-preview.webp",
      content_type: response["Content-Type"]
    )
  end
end

The request parameters shown are a focused example. ScreenshotNeo supports additional capture controls including full-page capture, CSS selector capture, device and viewport presets, retina scale, dark mode, custom CSS and JavaScript, clicking or hiding elements, wait conditions, request blocking, headers, cookies, user agent, timezone, geolocation, transparency, resizing, cache TTL, signed links, async jobs, bulk capture, and PDF output. Consult the documentation for exact parameter names and response behavior before adding options to this job.

For a robust production job, also consider making attachment replacement idempotent, recording capture status and timestamps, and ensuring an older job cannot overwrite a newer URL’s screenshot. One simple approach is to pass the URL value or a URL-change version into the job and compare it with the listing’s current value before attaching.

4. Render a preview with a useful fallback

In the listing partial, render the attached image when available and a neutral placeholder while the background job is pending or has failed. Use meaningful alternative text, and keep the destination link independent of whether a preview exists.

<!-- app/views/listings/_listing.html.erb -->
<article class="listing">
  <a href="<%= listing.website_url %>" rel="noopener noreferrer">
    <% if listing.website_preview.attached? %>
      <%= image_tag listing.website_preview,
        alt: "Preview of #{listing.name}",
        loading: "lazy",
        width: 600,
        height: 400 %>
    <% else %>
      <div class="listing__preview-placeholder" aria-hidden="true"></div>
    <% end %>
    <h2><%= listing.name %></h2>
  </a>
</article>

Configure your Active Storage service for the storage destination you use, commonly object storage. Keep the image dimensions in the markup close to the rendered card ratio to reduce layout shifts. Lazy loading can reduce initial image transfer for long directory pages, while a real placeholder avoids broken-image icons and makes the pending state intentional.

Rails templates and layouts can also provide page metadata. Set listing-specific titles, descriptions, and Open Graph tags in the HTML head when needed; that metadata complements the in-page screenshot rather than replacing it. Rails documents template and layout rendering in Layouts and Rendering in Rails.

5. Refresh previews without creating needless work

Regenerate when a listing’s URL changes, when a prior capture failed, or on a schedule that matches how quickly the listed sites tend to change. There is no universal refresh interval. A periodic refresh workflow is one way directory operators keep thumbnails current, but pick a cadence based on the content and your cost and freshness needs.

  • Do not recapture on unrelated listing edits; enqueue only when the URL changes.
  • Track the last successful capture time and surface stale previews for selective refresh.
  • Use a capture cache where appropriate, with a TTL chosen for your freshness requirement.
  • When deleting a listing, decide whether its attached image should be purged as part of the deletion flow.
  • For large directories, use a queue with bounded concurrency and consider bulk capture if your chosen provider supports it.

ScreenshotNeo offers caching with a chosen TTL, async jobs with signed webhooks, and bulk capture of up to 100 URLs per call. These can be useful as the directory grows; check the current API documentation for request and lifecycle details.

6. Protect the server when capturing user-submitted URLs

A URL submitted by a user becomes a server-side fetch boundary once your backend asks a browser service to visit it. The research sources do not provide a complete safe-fetch recipe, so treat this as a production security design requirement and obtain authoritative security guidance for your stack. In particular, decide how you will handle:

  • Private, loopback, link-local, and internal network destinations.
  • Redirects that move from an allowed public URL to a restricted destination.
  • DNS resolution changes between validation and the actual fetch.
  • Non-HTTP schemes, embedded credentials, unusual ports, and oversized or malformed input.
  • Limits on outbound destinations, capture duration, response size, and stored image size.

Basic URI validation in Rails does not solve these cases by itself. Apply destination restrictions at the capture boundary as well as in form validation, and make sure the browser service’s network isolation and terms fit your threat model. Avoid treating a successful syntax check as proof that a URL is safe to visit.

7. Choosing managed capture or a browser you operate

A managed screenshot API avoids maintaining the browser-rendering layer yourself. A browser service you operate gives your team control over the runtime and network setup, but also means owning browser updates, concurrency, timeouts, cleanup, and operational monitoring. Compare the options on fidelity, freshness, failure behavior, storage and bandwidth, security controls, and infrastructure ownership.

ScreenshotAPI describes a directory workflow that captures listing URLs in Chromium, stores returned images on a CDN, displays them in the listing, and periodically refreshes them. That is the vendor’s description of its service, not an independent performance or cost comparison. Confirm a provider’s current capabilities, limits, security model, pricing, and terms before choosing.

8. Performance, reliability, and cost

Keep page requests fast

Do not capture synchronously during listing creation or directory rendering. Queue the work, cap worker concurrency, set connection and read timeouts, and avoid retrying permanent failures indefinitely. Render the last successful image while a replacement is being generated, if that behavior suits your product.

Plan for failure

Remote sites can be unavailable, slow, blocked, or visually different from what you expect. A missing preview should never make the listing unusable. Keep a fallback, log capture outcomes without exposing secrets, retry transient network problems with a limit, and provide a way to requeue stale or failed items.

Control image and transfer costs

Storage and bandwidth depend on image format, dimensions, refresh frequency, listing count, and traffic. Choose a card-sized viewport rather than capturing full pages when the extra content is not useful. WebP can reduce image bytes for supported consumers, but verify the content type returned and your delivery requirements. Avoid refreshing every listing too frequently when a smaller selective refresh policy will meet the freshness need.

No independent performance or cost benchmarks were identified in the research for this implementation. Measure queue wait time, capture duration, failure rate, image byte size, storage growth, and delivered bandwidth in your own application before setting service-level targets.

Or skip the browser setup

Use the DIY job above when you want to manage capture yourself. If you prefer a single screenshot API call from Rails, ScreenshotNeo provides a website screenshot API and MCP server for developers. One GET request returns PNG, JPEG, WebP, or PDF. Its consent cleanup accepts cookie banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each cleanup step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status.

Store the response bytes using the same Active Storage attachment flow shown above. See the ScreenshotNeo API documentation for parameters and response details.

# app/jobs/generate_website_preview_job.rb (request portion)
require "net/http"
require "uri"

uri = URI("https://api.screenshotneo.com/v1/shot")
uri.query = URI.encode_www_form(
  access_key: ENV.fetch("SCREENSHOTNEO_API_KEY"),
  url: listing.website_url,
  format: "webp"
)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
  open_timeout: 10, read_timeout: 90) { |http| http.get(uri.request_uri) }
raise "Screenshot request failed: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
# Attach response.body to listing.website_preview after validating the response.

ScreenshotNeo also has an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.

Sign up free for 1,000 screenshots a month with no card.

9. Troubleshooting

Symptom Likely cause What to do
No job runs after creating a listing The queue adapter is not running, or the URL did not change. Check the configured Active Job backend and worker process; confirm the callback condition matches the field you update.
Job succeeds but no image appears The response may be an error body, the attachment may not have committed, or the template may check the wrong association. Check HTTP status and content type before attaching; inspect job logs and confirm website_preview.attached?.
Screenshot shows a consent banner or popup The destination presents an overlay during capture. Use a capture service with consent cleanup, or configure a site-specific wait, click, or hide rule when appropriate.
Preview is blank or shows an error page The site may block automated visits, fail to load, or require authentication. Check capture verdict and response headers where available; retain the fallback and avoid presenting the failure image as a successful preview.
Repeated retries never succeed The issue may be permanent, such as an invalid URL or blocked destination. Bound retries, record a failed status, and offer a manual or scheduled requeue after the URL is corrected.
Old screenshot replaces a newer one An earlier queued job completed after the URL changed. Associate each job with the URL value or version it was created for, and discard its result if that value is no longer current.
Directory pages load slowly Images are too large or loaded eagerly in large lists. Use appropriately sized previews, lazy-load below-the-fold images, and serve attachments through a storage/CDN setup suited to your app.

10. Frequently asked questions

Does Active Storage generate a screenshot of a URL?

No. It stores and serves attached files. A separate browser-rendering step must visit the remote site and produce the image.

Should I use a screenshot as the Open Graph image?

You can, but decide based on the share preview you want. A destination screenshot and a branded social card serve different purposes and can coexist.

Should every directory listing use a live screenshot?

Only if the destination’s current appearance helps visitors. For directories where visual uniformity is more useful, a branded card may be a better preview.

How often should previews refresh?

Choose a cadence based on how quickly the sites change and how much freshness matters to visitors. Refresh on URL changes, then use selective or scheduled updates as needed.

Implementation checklist

  • Validate and normalize listing URLs according to a documented destination policy.
  • Generate captures in background jobs with bounded timeouts, retries, and concurrency.
  • Store images and render a fallback while capture is pending or unavailable.
  • Prevent stale jobs from replacing screenshots for changed URLs.
  • Plan destination restrictions, redirects, DNS behavior, resource limits, and deletion.
  • Track freshness and failures; refresh selectively and measure storage and bandwidth.
  • Add page-specific Open Graph metadata separately if social sharing previews are needed.

With this structure, Rails owns listing data and presentation, while a separate capture step creates the remote-site thumbnail. That separation keeps directory requests responsive and gives you a clear place to manage freshness, failures, and security.