HTML Base URL: Relative Assets in Rendered HTML

The base URL is the address a browser resolves relative links against. See why rendered HTML loses its images and CSS, and three tested ways to fix it.

By Simon Chiu · Updated

The base URL of an HTML document is the absolute address that the browser resolves every relative URL against; when you render HTML from a string, that address is about:blank, so relative paths for images, stylesheets and scripts fail unless you set a base URL.

A page served from https://example.com/blog/post has a base URL taken from its address, so /img/logo.png becomes https://example.com/img/logo.png. HTML you pass to a renderer has no address. This entry is part of the screenshot and rendering glossary.

A plain explanation

A relative URL is half an address. /assets/card.css says "this path, on whatever site I am on". The browser completes it with the base URL. The base URL comes from, in order:

  1. the first <base href="..."> element in the document, if there is one;
  2. otherwise, the document's own URL.

For HTML rendered from a string, step 2 gives about:blank. There is no site to be on, so the browser cannot build the full address.

Without and with a base URL

Two renders of the same HTML. Top: white background, a broken image icon with the alt text logo, and unstyled serif text. Bottom: the same card styled on a dark navy background with a blue RS logo and white sans-serif text.
The same HTML string rendered with Puppeteer setContent on 2026-10-15. Top: no base URL, document.baseURI was about:blank; the stylesheet and logo did not load. Bottom: a <base href> pointing at the asset server; both loaded.

Nothing in the top render reports an error to your code. The capture succeeds, and you get an unstyled image. That is why this problem reaches production.

How it works technically

  • Resolution happens when the URL is used, with the base URL current at that moment. A <base> added by script after the <img> is parsed comes too late for that image.
  • Only the first <base href> counts. Later ones are ignored.
  • CSS files resolve against themselves. url(fonts/brand.woff2) inside https://cdn.example.com/css/site.css resolves to https://cdn.example.com/css/fonts/brand.woff2, whatever the document's base URL is.
  • Origin is separate from base. A <base> changes how URLs are completed, not which origin the document belongs to. The page is still about:blank for cookies, localStorage and CSP. If assets need cookies or the page checks location, serve the HTML at a real URL instead, as in the Playwright listing.
  • Fragment links such as href="#top" also resolve against the base, which can turn in-page anchors into links to another page. This rarely matters for a static image, but it does for PDFs with clickable links.

Three fixes in Puppeteer, Playwright and RenderScreenshot

Puppeteer: add a <base> element first in <head>.

import puppeteer from 'puppeteer';

const body = `<div class="card"><img src="/assets/logo.svg" width="96" height="96" alt="logo">
  <span>Relative assets test</span></div>`;
const head = '<link rel="stylesheet" href="/assets/card.css">';
const base = process.env.ASSET_ORIGIN ?? 'http://localhost:4630/';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 560, height: 180 });

  // 1. No base: the document URL is about:blank, so /assets/... cannot resolve.
  await page.setContent(`<!doctype html><html><head>${head}</head><body>${body}</body></html>`);
  console.log('without base:', await page.evaluate(() => document.baseURI));
  await page.screenshot({ path: 'out/base-missing.png' });

  // 2. A <base> element first in <head> gives every relative URL an origin.
  await page.setContent(
    `<!doctype html><html><head><base href="${base}">${head}</head><body>${body}</body></html>`,
    { waitUntil: 'load' });
  console.log('with base:   ', await page.evaluate(() => document.baseURI));
  await page.screenshot({ path: 'out/base-fixed.png' });
} finally {
  await browser.close();
}

Output: without base: about:blank, then with base: http://localhost:4630/.

Playwright: serve the HTML at a real address with page.route. The browser thinks it loaded pageUrl, so relative URLs, cookies and CSP behave as on that site, with no change to your markup.

import { chromium } from 'playwright';

// Serve your HTML as if it lived at a real address, so relative URLs, cookies
// and CSP behave as they would on that origin. No <base> tag needed.
const pageUrl = 'http://localhost:4630/render/card';
const html = `<!doctype html><html><head><link rel="stylesheet" href="/assets/card.css"></head>
  <body><div class="card"><img src="/assets/logo.svg" width="96" height="96" alt="logo">
  <span>Relative assets test</span></div></body></html>`;

const browser = await chromium.launch();
try {
  const page = await browser.newPage({ viewport: { width: 560, height: 180 } });
  await page.route(pageUrl, (route) =>
    route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: html }));
  await page.goto(pageUrl, { waitUntil: 'load' });
  console.log('baseURI:', await page.evaluate(() => document.baseURI));
  console.log('logo loaded:', await page.locator('img').evaluate((img) => img.naturalWidth > 0));
  await page.screenshot({ path: 'out/base-playwright.png' });
} finally {
  await browser.close();
}

Output: baseURI: http://localhost:4630/render/card and logo loaded: true. Puppeteer can do the same with page.setRequestInterception(true).

RenderScreenshot: send html_base_url with the html. It resolves relative URLs, loads external stylesheets and scripts from that site and sets document.baseURI. See content input.

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<!doctype html><html><head><link rel=\"stylesheet\" href=\"/assets/card.css\"></head><body><div class=\"card\"><img src=\"/assets/logo.svg\" width=\"96\" height=\"96\" alt=\"\"><span>Relative assets test</span></div></body></html>",
    "html_base_url": "https://assets.example.com",
    "viewport": { "width": 1200, "height": 630 }
  }' \
  --output card.png

The asset host must be reachable from the public internet, because the renderer fetches from there. For assets that live only on your laptop or in a private network, inline them as data URIs instead.

Tested on 2026-10-15 with Node 22.18, Puppeteer 24.43.1 (headless shell, Chrome 148) and Playwright 1.64.1 (Chromium 156) against a local asset server. The RenderScreenshot request follows the API docs and was not run for this page.

Common mistakes

  • <base> after the assets. Put it first in <head>. URLs parsed before it use the old base.
  • A base URL without a trailing slash. https://example.com/assets as base turns logo.svg into https://example.com/logo.svg, because the last path segment is treated as a file. Use https://example.com/assets/. Root-relative paths (/logo.svg) are not affected.
  • localhost as the base for a cloud renderer. The API's browser is not on your machine. Use a public host or data URIs.
  • Expecting CSS url() to follow the base. Paths inside stylesheet files resolve against the stylesheet.

For escaping, fonts and payload size in HTML templates, read the HTML to image guide. To render your own templates, get a free API key.

Frequently asked questions

Why do images load in my browser but not when I render the same HTML?
In your browser the HTML has an address, so /img/logo.png resolves against your site. Rendered from a string, the document's address is about:blank, so the relative path has nothing to resolve against and the request never happens.
Does the base element affect URLs inside CSS files?
No. url() values inside an external stylesheet resolve against the stylesheet's own URL. The base element only affects URLs written in the HTML document, including inline style attributes and style elements.
Can I have more than one base element?
Only the first base element with an href counts. Put it at the top of head, before any link, script or img that uses a relative URL.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.