Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Short answer: if Rails is returning an HTML string, put the CSS text inside a <style> element in that document, then call render html:. Use render inline: only when the string is an ERB template that must be evaluated. If the target is a PDF or image, pass the CSS string through the renderer’s inline-style option (for example, Grover’s style_tag_options). Nokogiri can parse or modify the markup, but it does not calculate browser-style CSS layout.

Start with the output you actually need

“Rendering HTML” can mean several different operations in Ruby. The correct way to load a CSS string depends on whether you are returning a web response, evaluating an ERB template, generating a PDF or image, or merely parsing markup.

Need Use What it does
Return a small HTML document from Rails render html: Returns HTML as text/html; a plain string is escaped unless marked safe.
Evaluate ERB tags held in a string render inline: Runs the string as an ERB template, rather than returning it literally.
Apply raw CSS text to browser HTML Embed it in a <style> element Creates a self-contained document the browser can style.
Produce a PDF, PNG or JPEG Grover or another document renderer Uses a browser engine; pass CSS through that library’s inline-style API.
Inspect or transform HTML structure Nokogiri Parses a tree; it is not a visual CSS renderer.

Return HTML with a CSS string in Rails

Embed the CSS in the document head

The simplest route is to interpolate the CSS into a <style> block before rendering. This produces one HTML string containing both markup and styles:

class ReportsController < ApplicationController
  def show
    css = <<~CSS
      body { font-family: sans-serif; margin: 2rem; }
      .notice { color: #176b3a; font-weight: 600; }
    CSS

    html = <<~HTML
      <!doctype html>
      <html>
        <head>
          <meta charset="utf-8">
          <style>
            #{css}
          </style>
        </head>
        <body>
          <p class="notice">Ready</p>
        </body>
      </html>
    HTML

    render html: html.html_safe
  end
end

The html_safe call is appropriate only when the complete string is trusted or has been safely constructed. Rails escapes a normal string passed to render html:; marking it safe tells Rails not to escape the document’s tags. Never use html_safe to push untrusted user input into a response. Escape user text and build markup with Rails tag helpers instead. CSS supplied by users also needs a separate policy: arbitrary CSS can inject styles, external requests or browser-specific behavior.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Keep the layout behavior explicit

An inline HTML response does not use a Rails layout by default. If you need the application layout, request it explicitly:

#1 Best Overall
render html: html.html_safe, layout: true
# or
render html: html.html_safe, layout: "print"

For a small, self-contained response, omitting the layout avoids accidentally adding a second document head or duplicate style tags. For a larger view, a normal template file is easier to maintain than a long Ruby heredoc.

Choose render inline: when the string is ERB

render html: returns literal HTML. It does not evaluate ERB delimiters. If the string itself contains template expressions, use render inline::

def greeting
  @name = "Ada"
  render inline: <<~ERB
    <!doctype html>
    <html>
      <head>
        <style>
          .name { color: #176b3a; }
        </style>
      </head>
      <body>
        <h1 class="name">Hello, <%= @name %>!</h1>
      </body>
    </html>
  ERB
end

Layouts are also off by default for inline templates; pass layout: true or a named layout when required. Rails guidance treats inline templates as a niche option: once the markup is more than a short response, keep it in a view file and load the CSS through the normal asset pipeline.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When the CSS is a file or URL instead of a raw string

Use a stylesheet link for a resource, not for a CSS string:

<%= stylesheet_link_tag "reports", media: "all" %>

That helper emits a <link> element pointing to an asset or URL. It does not take the contents of a CSS string and inline them. If you need one self-contained response, read or construct the text and place it in <style>; if caching, fingerprinting and reuse matter, keep the stylesheet as an asset and link it.

Generate a PDF or image with inline CSS

Grover: pass CSS through style_tag_options

Grover accepts inline HTML and uses Puppeteer and Chromium to create PDF, PNG or JPEG output. Its inline style option takes a content field:

style_tag_options = [
  { content: <<~CSS
      body { font-family: sans-serif; }
      .body { background: red; padding: 24px; }
    CSS
  }
]

pdf = Grover.new(
  '<html><body class="body"><h1>Heading</h1></body></html>',
  style_tag_options: style_tag_options
).to_pdf

File.binwrite("report.pdf", pdf)

Grover also accepts stylesheet entries by URL or filesystem path. A direct call must account for relative assets: Chromium resolves relative URLs against the display URL, which defaults to http://example.com when no display URL is supplied. Set a suitable display URL or rewrite image, font and stylesheet references as absolute URLs so the browser can fetch them.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
grover = Grover.new(
  html,
  display_url: "https://app.example.test/reports/42",
  style_tag_options: [{ content: css }]
)
image_bytes = grover.to_png

Chromium is a runtime dependency, so deployment must include a compatible browser and the system libraries it needs. The available material does not establish performance, JavaScript parity or universal platform compatibility; verify those against the exact Grover, Puppeteer and Chromium versions you deploy.

WickedPDF: a documented string-to-PDF route

WickedPDF documents pdf_from_string for HTML supplied in memory and provides stylesheet helpers for linked files. Its documentation example is for version 0.9.4, so check the installed version before copying options into a current application. For file-backed stylesheets, use the absolute-path approach recommended by that documentation rather than assuming a relative URL will resolve in the PDF process.

Why Nokogiri does not “load” CSS

Nokogiri’s HTML5 API is useful for parsing and changing markup:

document = Nokogiri.HTML5(html)
fragment = Nokogiri::HTML5.fragment('<p class="notice">Ready</p>')
fragment.at_css(".notice").content = "Updated"

It builds a document tree; it does not run a browser layout engine, compute computed styles, load fonts, or paint pixels. Use it before a renderer when you need to insert a <style> node or alter classes, then send the resulting HTML to a browser-based PDF or image tool. The HTML5 API is not available on JRuby according to Nokogiri’s documentation, so JRuby applications need to confirm which parser API is supported in their version.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A safe, reusable helper for CSS strings

Centralize document construction so escaping and the doctype do not vary between controllers:

module HtmlDocument
  module_function

  def with_css(body_html, css)
    <<~HTML
      <!doctype html>
      <html>
        <head>
          <meta charset="utf-8">
          <style>#{css}</style>
        </head>
        <body>#{body_html}</body>
      </html>
    HTML
  end
end

html = HtmlDocument.with_css(
  ERB::Util.html_escape("User supplied text"),
  "body { color: #222; }"
)
render html: html.html_safe

In production code, prefer Rails tags or a view for complex or user-generated body content. The helper above demonstrates the boundary: trusted structure and CSS are assembled first, while user text is escaped before insertion.

“Or skip the browser setup”

If your actual goal is a clean screenshot of a rendered page rather than building a local Chromium pipeline, ScreenshotNeo accepts a URL and returns PNG, JPEG, WebP or PDF. It removes cookie-consent banners, newsletter popups and chat widgets before capture; bot checks, blank pages, timeouts, failed loads and cache hits are not billed, and response headers report the page verdict and billing status. It also provides an MCP server with take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients.

One request is enough (see the ScreenshotNeo API documentation):

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Every feature is available on every plan. The free tier includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account to try it.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Troubleshooting common failures

The response shows escaped tags

Symptom: the browser displays <h1> as text. Cause: Rails escaped a normal string passed to render html:. Fix: mark only a trusted, correctly constructed document as safe, or return a normal template. Escape user values before interpolation; never mark raw request parameters safe.

ERB appears literally

Symptom: <%= @name %> reaches the browser unchanged. Cause: render html: returns literal text. Fix: use render inline: for a deliberately evaluated ERB string, or move the template into a view.

The page has no application layout

Cause: inline HTML and inline ERB rendering disable layouts by default. Fix: pass layout: true or a named layout, and ensure the layout does not add a second complete HTML document.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Styles work in a browser but not in a PDF or image

Check that the renderer received the CSS through its inline-style option, that the selector matches the generated markup, and that relative images and fonts resolve from the renderer’s display URL. Browser-engine output can differ by Chromium or library version, so reproduce with the same versions used in deployment.

Images or fonts are missing in Grover

Relative URLs may resolve against the default http://example.com. Supply display_url or use absolute, reachable URLs. Confirm that the rendering process has network access and permission to read local files.

Nokogiri output is structurally correct but unstyled

That is expected: parsing and serialization do not paint a page. Pass the serialized HTML to a browser renderer when visual output is required.

The PDF process fails in production

Verify that Chromium and its native dependencies are installed for the deployment image, and pin compatible Grover, Puppeteer and browser versions. The Ruby code alone cannot supply a missing browser executable.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Performance, reliability and cost decisions

  • Small HTTP responses: inline CSS avoids an extra asset request but increases every response size. Use a cached asset for large, shared stylesheets.
  • PDF/image jobs: browser startup and page loading dominate latency. Reuse a controlled rendering setup where the library supports it, and keep CSS and assets deterministic.
  • Relative assets: make URL resolution explicit; failures often look like CSS bugs but are actually missing files, blocked network access or an incorrect base URL.
  • Security: treat both HTML and CSS as code-like input. Escape user text, constrain any user-provided markup, and avoid exposing internal URLs to a renderer that can fetch arbitrary resources.
  • Billing for ScreenshotNeo: only clean shots are billed; bot checks, blank pages, timeouts, failed loads and cache hits cost nothing, with the result described by response headers.

Practical decision checklist

  1. Decide whether you are returning HTML, evaluating ERB, or producing a PDF/image.
  2. For a Rails response, put trusted CSS text in a <style> element and use render html:.
  3. Use render inline: only when ERB evaluation is intentional.
  4. For Grover, pass CSS with style_tag_options: [{ content: css }] and set display_url when relative assets exist.
  5. Use Nokogiri for parsing and transformation, never as the visual renderer.
  6. Test escaping, layout selection, asset URLs and the exact production browser/runtime before shipping.

Frequently Asked Questions

Can I pass a CSS string directly to Rails’ stylesheet_link_tag helper?

No. That helper emits a link to a stylesheet resource. Put raw text in a style element, or save it as an asset and link the asset.

Should a CSS string be marked html_safe?

Only the surrounding document should be marked safe when its markup and CSS are trusted. Escape user-provided values before interpolation; do not use html_safe as a shortcut around input validation.

What does ScreenshotNeo return when a target page fails?

Its response identifies the page verdict and billing status in headers; failed loads, blank pages, bot checks, timeouts and cache hits are not billed.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.