Solo's Shutdown FAQ says the export ZIP holds "an HTML export of your website and a CSV file with links to your image assets, as well as your contact form and newsletter submissions". It also says: "Image source files are not included in the ZIP."
You can't just upload that ZIP to a static host. This post covers four problem areas: images, fonts and styles, page links, and forms. For each one, you get the code our Solo export tool uses (or what it doesn't do) and the manual steps.
A caveat first: we haven't seen a real Solo export. Solo doesn't document the file names or folder layout. Our tests run on a synthetic fixture, fixtures/solo-export-example-bakery-with-assets.zip, built by scripts/build_synthetic_export.py. It assumes a layout that matches what Solo's FAQ says the ZIP contains. The code is written to cope with a different wrapper folder.
The fixture
solo-export-example-bakery-with-assets.zip ├── website/ │ ├── index.html │ └── about/index.html ├── image_links.csv ├── contact_form_submissions.csv └── newsletter_submissions.csv index.html contains the kinds of references the code has to handle:
<link rel="icon" href="/favicon/favicon.ico"> <link rel="stylesheet" href="/_next/static/css/x.css"> <script src="https://soloist.ai/_next/static/chunks/main.js"></script> ... .hero{background-image:url(https://images.unsplash.com/photo-1542653285-7f095a1183e0?crop=entropy&cs=srgb&fm=jpg&q=60&w=1600)} ... <a href="/example-bakery/about">About</a> · <a href="/about">About</a> <img src="https://images.unsplash.com/photo-...?...&w=1080" srcset="...w=400 400w, ...w=1080 1080w, ...w=2400 2400w"> <script id="__NEXT_DATA__" type="application/json">{... "url": "https://images.unsplash.com/photo-...?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026q=85" ...}</script> Image URLs show up in four places: src, srcset, inline CSS url(), and JSON. The JSON copy escapes & as \u0026.
1. Image paths
What the code does
lib/localize.ts scans every text file (html, htm, css, js, mjs, cjs, json, csv, txt, xml, svg) with one regex that tolerates both escapes:
export const IMAGE_HOSTS = new Set(["cdn.soloist.ai", "images.unsplash.com"]); /** Matches http(s) URLs, including HTML `&` and JSON `\\u0026`. */ export const URL_RE = /https?:\/\/(?:[^\s"'<>()\\,]|\\u0026)+/g; export function normalizeUrl(raw: string): string { return raw.replace(/&/g, "&").replace(/\\u0026/g, "&"); } Then it collapses size variants so each image is downloaded once:
if (host === "cdn.soloist.ai") { const m = x.pathname.match(/^(.*)_(\d+)x(\d+)(\.\w+)$/); if (m) { const fetchUrl = `https://${host}${m[1]}_2048x2048${m[4]}`; return { key: host + m[1], fetchUrl, original: url, fallbackUrl: url !== fetchUrl ? url : undefined }; } } if (host === "images.unsplash.com") { return { key: host + x.pathname, fetchUrl: `https://${host}${x.pathname}?ixlib=rb-4.0.3&fm=jpg&q=85&w=2400&fit=max`, original: url, fallbackUrl: url, }; } A cdn.soloist.ai URL ending in _NNNxNNN maps to its _2048x2048 variant. Unsplash URLs are keyed by pathname, so all the w=400, w=1080 and w=2400 variants count as one image. If the upgraded URL fails, downloadOne() in lib/fetch-images.ts retries with the original URL.
Each file is saved as images/<sha1-prefix>.<ext>, with the extension taken from Content-Type. Then every match in every text file gets rewritten to a path relative to that file:
export function posixRelative(fromFile: string, toFile: string): string { // ... const rel = [...Array.from({ length: fromParts.length - i }, () => ".."), ...toParts.slice(i)].join("/"); return rel || normalizedTo; } A root page gets images/abc.jpg. about/index.html gets ../images/abc.jpg.
In the browser, cdn.soloist.ai images can't be fetched directly. Our README notes they "have no CORS headers", so the tool routes them through a same-origin /api/img proxy. That proxy allows only cdn.soloist.ai over HTTPS and only passes image/* responses.
By hand
CORS only applies inside browsers, so a script or curl can download the images directly.
- Grep every text file for
cdn.soloist.aiandimages.unsplash.comURLs. Decode&and\u0026before you dedupe. - Dedupe on path, ignoring size suffix and query string. Download the largest variant.
- Replace every occurrence, including
srcsetentries, CSSurl()and__NEXT_DATA__, with a relative path. Calculate the path from each file's own folder, not from the site root.
2. Fonts and styles
What the code does
The exported HTML links stylesheets under /_next/. lib/assets.ts only fetches static, non-JS files from that one prefix:
const JS_PATH = /\.(?:js|mjs|cjs)(?:$|[?#])/i; const ALLOWED_STATIC_EXT = /\.(?:css|woff2?|ttf|otf|eot|svg|png|jpe?g|gif|webp|ico|avif)(?:$|[?#])/i; export function isAllowedSoloStaticAsset(url: string): boolean { const u = new URL(url); if (u.protocol !== "https:") return false; if (u.hostname !== SOLO_ASSET_HOST) return false; // "soloist.ai" if (!u.pathname.startsWith(SOLO_PATH_PREFIX)) return false; // "/_next/" if (JS_PATH.test(u.pathname)) return false; return ALLOWED_STATIC_EXT.test(u.pathname); } captureSoloAssets() starts from each <link rel="stylesheet"> (or rel="preload" as="style"). It reads @import and url() from every CSS file it downloads and queues those references up to depth 4. Each file is saved under assets/ at its original path, so https://soloist.ai/_next/static/media/bakery.woff2 becomes assets/_next/static/media/bakery.woff2. CSS url() values are then rewritten relative to the saved CSS file. Our test asserts the result:
const css = new TextDecoder().decode(result.files["assets/_next/static/css/x.css"]); assert.match(css, /url\(["']?\.\.\/media\/bakery\.woff2/); assert.doesNotMatch(css, /https:\/\/soloist\.ai\/_next\/static\/media\/bakery/); fetchAssetWithRetry() retries once on a 502 or a network error. Anything still missing gets one more sweep after a delay (ASSET_SWEEP_DELAY_MS = 2000). If it's still missing after that, it's listed as skipped in the report.
By hand
- Download each
/_next/static/css/*.cssthe pages link. -
grep -o 'url([^)]*)'and@importin each file. Download those too, and repeat for any CSS they pull in. - Keep the
_next/static/...folder structure under oneassets/folder. Most relativeurl(../media/x.woff2)references then resolve without edits. Rewrite any absolutehttps://soloist.ai/_next/...references. - Point the
<link href>in each page at the local copy, relative to that page.
3. Scripts and page links
stripSoloNextScripts() removes <script src> tags and <link rel="preload" as="script"> tags pointing at soloist.ai. It leaves __NEXT_DATA__ alone, because that tag has no src. Once scripts are removed, the report says: "Interactive parts that relied on Solo's scripts may not work."
Links in the fixture use both /<handle>/about and /about. rootAbsoluteToRelative() maps both onto files in the flattened ZIP. These are the cases from tests/assets.test.ts:
rootAbsoluteToRelative("/example-bakery/about", "index.html", files) // "about/index.html" rootAbsoluteToRelative("/about", "index.html", files) // "about/index.html" rootAbsoluteToRelative("/example-bakery/#menu-1", "index.html", files) // "#menu-1" rootAbsoluteToRelative("/example-bakery/#menu-1", "about/index.html", files) // "../index.html#menu-1" rootAbsoluteToRelative("/favicon/favicon.ico", "index.html", files) // unchanged The first path segment only counts as a handle if it isn't a known asset root (favicon, img, images, assets, static, _next, css, js, fonts), doesn't contain a dot, and doesn't match a file or folder in the ZIP.
By hand: delete the Solo <script> tags and script preloads. Then, for every root-absolute href, drop the handle segment and rewrite the rest to a relative path.
4. Forms
Our tool doesn't convert Solo forms. Our static-host guide says it plainly: "The tool strips Solo scripts, and a static host cannot send the form."
The code does handle the submissions data. lib/process-zip.ts treats every .csv (and manifest.csv / report.txt) as private and splits it into a second ZIP:
function isPrivateDataPath(path: string): boolean { if (path === "_rescue-data" || path.startsWith("_rescue-data/")) return true; const base = path.split("/").pop() ?? path; if (PRIVATE_DATA_BASENAMES.has(base)) return true; if (base.endsWith(".csv")) return true; return base.endsWith("_submissions.csv"); } countFormSubmissions() in lib/solo/gaps.ts counts non-empty data rows in contact_form_submissions.csv and newsletter_submissions.csv. Its quote-aware CSV splitter keeps a multi-line message as one row.
By hand:
- Move every CSV out of the folder you deploy. Those files hold your visitors' names and emails.
- Replace the form markup with a
mailto:link, or point it at a form backend you control. Static files can't receive a POST.
Output
From the README, the Example Bakery fixture with styles produces:
website-ready-to-host.zip index.html images/<hash>.jpg assets/_next/static/css/x.css assets/_next/static/media/... private-form-data-do-not-upload.zip image_links.csv contact_form_submissions.csv newsletter_submissions.csv manifest.csv report.txt Host only the first ZIP. If you'd rather not do this by hand, the free tool runs in the browser: https://site-export-rescue.pages.dev/?utm_source=devto&utm_medium=article&utm_campaign=solo-export-fixes
Written with AI assistance from our own source code and test exports, and checked against them.

