This guide covers the breaking upgrade from @convex-dev/static-hosting 0.1.x
to 0.2.x (sometimes referred to as v1 to v2).
0.2.x changes where static files are stored and which HTTP router serves them. It is not a package-only upgrade.
- Upgrade Convex to at least 1.37.0. The optional React helpers import
useQuery_experimental, which older Convex releases do not export. - You must upload every static asset again. 0.1.x put blobs in the app's storage. 0.2.x puts them in the component's private storage, and it cannot serve the old blobs.
- Plan cleanup of the old app-storage blobs. The v2 component cannot delete files owned by app storage. Capture the current v1 manifest and audit app storage for older v1 orphans before migration. Keep every verified ID through the rollback window, then delete only the explicitly approved files.
- You must choose who owns the root HTTP routes. The recommended 0.2.x mode
mounts the component at
/and moves app-owned HTTP routes under/api. Compatibility mode keeps existing auth, webhook, and API URLs at the root. - Legacy
--cdnusers must keep app-owned root routes. Both/fs/uploadand/fs/blobs/*belong to the app router. Component-owned root mode moves that router below/apiand breaks legacy ConvexFS uploads and asset URLs. - Remove
exposeUploadApi. The CLI now calls private component functions directly; app-level upload wrappers no longer exist. - Plan the production cutover. In component-owned root mode, a normal
in-place upgrade can briefly show the setup page between the backend
deployment and the first 0.2.x upload. App-owned root routing
(
registerStaticRoutes) avoids that gap: the app-side handler keeps serving the inherited v1 files from app storage until the first upload replaces them. If neither fits, use the staged cutover below. - Check the built manifest. 0.2.x accepts at most 1,800 files and 2 MiB of serialized manifest metadata per deployment so the atomic switch remains below Convex transaction limits. Reduce highly fragmented output or shorten deeply nested asset paths before starting the production cutover.
- Check same-name migration size. When v2 inherits the v1 component data, the old and new manifests must contain no more than 3,800 rows combined. The publication rejects atomically without partially replacing the inherited manifest when that read budget is exceeded. In the standard in-place path, traffic has already switched to v2 and can remain on the setup page. Shrink the manifests before the backend cutover or use the staged two-instance path.
Read this entire guide before editing the app. Do not treat the dependency upgrade as the migration.
- Inventory
convex/http.tsbefore choosing a routing mode. Preserve existing auth, webhook, and API URLs unless the user explicitly approves moving them. - Keep package changes, Convex source changes, codegen, and the asset upload as distinct steps so failures are easy to diagnose.
- Never edit
convex/_generated/*by hand. Run Convex codegen after the source migration. - Do not regenerate unrelated secrets or environment variables during an in-place migration. They belong to the existing app deployment and should survive this component upgrade unchanged.
- Capture both the current 0.1.x asset manifest and a paginated app-storage
inventory before replacing the old upload facade.
listAssetsis not a complete inventory of historical v1 orphans. Never delete a blob without proving it belongs to static hosting and getting user approval, and wait until the rollback window closes. - Report the routing mode chosen, any URL changes, the asset-less interval, current-manifest capture, historical-orphan audit state, and verification results back to the user.
| Area | 0.1.x | 0.2.x | Why |
|---|---|---|---|
| File storage | App storage | Component-private storage | The component owns its data and lifecycle |
| Upload API | exposeUploadApi wrappers in the app |
Private component functions called by the CLI | Less generated app code and no upload facade to maintain |
| HTTP serving | registerStaticRoutes in convex/http.ts |
Component-owned HTTP by default | Direct storage access and fewer internal round trips |
| Root routes | App and static catch-all shared / |
Static site owns /; app routes default to /api |
Makes route ownership explicit |
| Component name | selfHosting |
staticHosting |
Matches the package name |
--component |
App module exposing upload wrappers | Component instance name from app.use(...) |
The CLI now invokes the component directly |
| SPA fallback | Router option | Deployment setting (--spa / --no-spa) |
Serving behavior travels with the deployed assets |
registerStaticRoutes, exposeDeploymentQuery, and getConvexUrl remain
available. Only exposeUploadApi was removed from the client API.
- Confirm the app uses
convex1.37.0 or newer. - Stop any running
convex devwatcher. A watcher can observe the temporary state where the package is new but app files still import removed 0.1.x APIs, producing noisy resolution andexposeUploadApierrors. Restart it only after the package and source edits are complete. - Note every route currently registered by
convex/http.ts, especially auth callbacks and third-party webhooks. Decide whether their URLs may move under/api. - Check whether the component has a custom instance name. In 0.1.x the
generated reference was normally
components.selfHosting; in 0.2.x the default iscomponents.staticHosting. - Check whether uploads use the legacy
--cdnflag. If they do, preserve the root/fs/uploadand/fs/blobs/*routes and choose Option B below. - Make sure the current production deployment and static upload are healthy so you have a known rollback point.
- While the 0.1.x facade is still deployed, capture its current asset list and a paginated app-storage inventory as described in Clean up 0.1.x app-storage blobs. Store the output outside the repository.
npm install convex@^1.37.0 @convex-dev/static-hosting@^0.2.0Do not run the production deploy yet. Update the Convex files first.
The ^0.2.0 range intentionally selects a stable 0.2.x release. It does not
select 0.2.0-alpha.*. When testing before the stable publish, install the
exact prerelease tag or preview URL supplied by the release author.
When testing a URL-based preview with Bun, replacing the installed release can
occasionally report DependencyLoop. Remove the old dependency first, then add
the preview URL. This was only observed with the prerelease URL workflow, not a
published 0.2.x package.
This choice also decides how much downtime the cutover can cost you, so make it before, not after, the storage work. If the app serves auth, webhooks, or a public API from the root and you keep the old 0.1.x instance name, Option B plus the kept instance name is the zero-downtime path: the app-side handler keeps serving the inherited v1 files from app storage until the first 0.2.x upload replaces the manifest, so there is no setup page and no 503 window during the switch. Component-owned root mode (Option A) cannot do this, because the component cannot read the inherited app-storage blobs and shows the setup page until the first upload. See step 4 for the instance-name mechanics.
Use this when app-owned HTTP endpoints can move from /route to /api/route,
or when the app has no other HTTP endpoints. The component serves files from its
storage directly, so this is the fastest mode.
Do not use this option with the legacy --cdn flag. Its /fs/upload and
/fs/blobs/* routes must remain on the app-owned root router. Choose Option B
until the deployment no longer uses ConvexFS.
Replace the old component registration with:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import staticHosting from "@convex-dev/static-hosting/convex.config";
// Routes from convex/http.ts are now served below /api.
const app = defineApp({ httpPrefix: "/api" });
app.use(staticHosting, { httpPrefix: "/" });
export default app;Remove registerStaticRoutes(...) from convex/http.ts. Delete the file if the
static catch-all was its only route. If the file contains other routes, keep it;
those routes will now be served under /api.
Do not use this option without updating external callback URLs and clients that still call the old root paths.
Use compatibility mode when auth callbacks, webhooks, or public APIs cannot change URL. The component still owns storage and uploads, but the app's HTTP action serves the files after an internal component lookup.
// convex/convex.config.ts
import { defineApp } from "convex/server";
import staticHosting from "@convex-dev/static-hosting/convex.config";
const app = defineApp();
app.use(staticHosting); // no httpPrefix: the app owns HTTP routing
export default app;Update the existing router to reference the new component:
// convex/http.ts
import { httpRouter } from "convex/server";
import { registerStaticRoutes } from "@convex-dev/static-hosting";
import { components } from "./_generated/api";
const http = httpRouter();
// Keep existing exact routes and helper registrations here.
// auth.addHttpRoutes(http);
registerStaticRoutes(http, components.staticHosting);
export default http;Exact app routes take precedence over the static catch-all. This mode preserves their URLs, but an uncached static request adds an internal query and storage fetch compared with component-owned serving.
If you also keep the 0.1.x instance name (see step 4), this mode has no downtime window: the app-side handler serves the inherited v1 files from app storage until the first 0.2.x upload atomically replaces the manifest. This is the recommended combination for apps whose root URLs cannot move.
A typical 0.1.x convex/staticHosting.ts looked like this:
import {
exposeDeploymentQuery,
exposeUploadApi,
} from "@convex-dev/static-hosting";
import { components } from "./_generated/api";
export const {
generateUploadUrl,
generateUploadUrls,
recordAsset,
recordAssets,
gcOldAssets,
listAssets,
} = exposeUploadApi(components.selfHosting);
export const { getCurrentDeployment } = exposeDeploymentQuery(
components.selfHosting,
);Delete all exposeUploadApi imports and exports. If the app uses
<UpdateBanner /> or useDeploymentUpdates, keep only the deployment query and
point it at the new component:
// convex/staticHosting.ts
import { exposeDeploymentQuery } from "@convex-dev/static-hosting";
import { components } from "./_generated/api";
export const { getCurrentDeployment } = exposeDeploymentQuery(
components.staticHosting,
);If the app does not use deployment notifications, delete
convex/staticHosting.ts entirely unless it also contains the app action used
by legacy --cdn cleanup. Legacy CDN users must keep that action, remove only
the old upload facade, and continue passing its function path with
--cdn-delete-function. Otherwise the old blob IDs leave the live manifest
without being deleted.
Existing explicit getCurrentDeployment props still work. In 0.2.x the React
helpers also default to api.staticHosting.getCurrentDeployment when the query
is exported from the file above.
The recommended production script is:
{
"scripts": {
"deploy": "npx @convex-dev/static-hosting deploy"
}
}An existing legacy CDN deployment must preserve both flags and its app-level
delete action. For example, if that action is exported as
staticHosting:deleteCdnBlobs:
{
"scripts": {
"deploy": "npx @convex-dev/static-hosting deploy --cdn --cdn-delete-function staticHosting:deleteCdnBlobs"
}
}In 0.2.x, --component names the component instance from app.use(...); it no
longer names the app module that exposed exposeUploadApi.
The default is staticHosting. If you deliberately keep another instance name,
use it consistently:
app.use(staticHosting, { name: "selfHosting", httpPrefix: "/" });npx @convex-dev/static-hosting deploy --component selfHostingThe same rule applies to upload. Generated references also follow the custom
name, for example components.selfHosting. Keeping selfHosting can preserve
the old component rows during the backend switch. Those rows still point at app
storage, which the component itself cannot read. In component-owned root mode
0.2.x therefore treats them as unavailable and serves the HTTP 503 setup page
until the first v2 upload atomically replaces the manifest. In app-owned root
routing (registerStaticRoutes) the app-side handler can read app storage, so
it keeps serving those inherited v1 files with no interruption until the first
upload replaces them. Either way the upload cleanup skips those foreign IDs; it
does not delete the v1 app-storage blobs.
The commands below show the default v2 instance name, staticHosting. Replace
it with the exact chosen v2 name, such as selfHosting or staticHostingV2, in
every --component flag and generated components.* reference.
Keeping a custom name has an ongoing cost. Every
deployanduploadcommand needs the matching--componentflag for as long as the name differs from the default. Forgetting it is not silent: the CLI resolves the component by name before uploading, so an unknown name stops withCould not reach component "..."rather than writing to the wrong place. As a convenience, when you rely on the default and only the legacyselfHostinginstance exists, the CLI finds it automatically and prints a warning suggesting you rename tostaticHosting. A mistyped custom name still errors, so a typo cannot quietly publish to an empty component.
If you would rather not carry the flag indefinitely, keep the custom name only
long enough to complete the zero-downtime cutover, then rename to the default
staticHosting as a separate, non-urgent step. Renaming in place would create a
fresh empty component, so do it the same way as the staged cutover: mount both
the current instance and a new staticHosting instance, upload the assets to
staticHosting, switch serving to components.staticHosting, verify, and only
then remove the old mount. Because the new instance is fully populated before
serving moves to it, that second switch also has no downtime. See
Staged cutover for the two-instance mechanics; the only
difference is that both mounts are 0.2.x, so no static-hosting-legacy alias is
involved.
Push the new component definition and regenerate components.* references:
npx convex dev --onceDo not hand-edit convex/_generated/api.d.ts to change selfHosting to
staticHosting. Codegen makes that change from convex/convex.config.ts and
keeps the rest of the generated API consistent.
Then build and repopulate the new component storage:
npx @convex-dev/static-hosting upload --build --component staticHostingBetween these two commands, a root request may show the static-hosting setup
page. That is expected: the 0.2.x component is mounted, but its private storage
is still empty. The page returns HTTP 503 with Retry-After so monitoring sees
the deployment as unavailable. The successful upload should replace it with the
app. On a local deployment, convex dev --once may stop the local listener when
it exits. Start npx convex dev before curling the site if you need to observe
this intermediate response.
After upload, you can inspect the new component-private manifest directly:
npx convex run --component staticHosting lib:listAssets '{"limit":4096}'These are v2 component-storage IDs. Do not add them to the v1 app-storage cleanup list.
Test all of the following on the development *.convex.site URL:
/serves the current app.- A client-side route still works after a full browser refresh.
- A missing asset with an extension, such as
/missing.js, returns 404. - Existing auth, webhook, and API routes are at the URLs expected for the routing mode you chose.
- Existing backend data and an authentication session created before the migration still work afterward.
- If the app uses Convex Auth at the root, both
/.well-known/openid-configurationand/.well-known/jwks.jsonstill return JSON successfully. - Hashed assets return
Cache-Control: public, max-age=31536000, immutableand HTML returnspublic, max-age=0, must-revalidate. The longmax-ageis the load-bearing part; a CDN or proxy in front of the deployment may drop theimmutabledirective, which is harmless. - If used, the update banner detects a later upload.
After the development smoke test passes:
npx @convex-dev/static-hosting deploy --component staticHostingIf this standard migration removes or renames the old selfHosting component,
Convex may ask for confirmation before deleting its component records. Do not
approve that prompt blindly. Confirming deletion removes the v1 manifest, so a
rollback must reconstruct it from the captured app-storage inventory or run a
full v1 upload. If you require a backend-only rollback, stop here and use the
staged cutover, which keeps the legacy component mounted
through the rollback window. Rehearse and explicitly handle this confirmation in
CI.
This builds with the production Convex URL, deploys the backend, and uploads the
files into the 0.2.x component storage. Do not use a separately built dist/
unless you deliberately pass --skip-build; the CLI also supplies the correct
base path and VITE_CONVEX_URL during its build.
The ordinary command deploys the new backend before the first 0.2.x asset upload. In component-owned root mode the site shows the setup page during that interval (we observed this in a real 0.1.4 to 0.2 migration). In app-owned root routing the previous v1 files keep serving through that interval, so there is no gap. Use the staged cutover when you need the component-owned mode and even a short frontend interruption is unacceptable.
Unmounting the old component or replacing its asset records does not delete the underlying v1 blobs because those files belong to the app's storage. If you do nothing, the old files remain stored after migration.
There are two sets to account for:
- Current v1 assets. The v1
listAssetsfacade identifies these directly. - Historical v1 orphans. Older v1 uploads may already have left replaced
same-path files in app storage. They no longer appear in
listAssets.
This distinction matters in practice. In a fresh 0.1.4 rehearsal, two uploads
left seven static-hosting blobs in app storage while listAssets reported only
the four current files. The older CSS, SVG, and HTML blobs were invisible to the
manifest. Treat the manifest as the rollback set and a lower bound for cleanup,
not as a complete app-storage inventory.
Legacy ConvexFS CDN blobs need a separate cleanup path. The v2 private cleanup
queue automatically inherits v1 CDN IDs only when v2 reuses the same component
instance name and data. If v1 and v2 use different names, including the staged
two-instance cutover, invoke the retained app delete action explicitly with the
captured v1 blobId values after the rollback window closes. For example:
npx convex run staticHosting:deleteCdnBlobs \
'{"blobIds":["<captured-v1-blob-id>"]}' --prodReview the exact list and get deletion approval first. Historical ConvexFS orphans absent from the v1 manifest cannot be inferred by v2. Inventory them through the legacy storage integration before claiming CDN cleanup is complete.
While the 0.1.x listAssets facade is still deployed, save its production
output somewhere protected and outside the repository:
npx convex run staticHosting:listAssets '{"limit":4096}' --prod \
> ~/static-hosting-v1-assets.jsonIf the old facade is exported from another module, replace staticHosting with
that module path. Check the output contains the expected static paths and
storageId values. If it reaches 4096 entries, stop and arrange a paginated
export before migrating rather than accepting a truncated cleanup manifest.
The simplest path is npx convex data, which reads _storage metadata directly
with no code deploy:
npx convex data _storage --prod --limit 1000 --order asc \
> ~/static-hosting-v1-storage.jsonThis is read-only, so it works even when the repository carries unshipped work
that a temporary query would force into production. Raise --limit above the
row count npx convex data reports for _storage, and if the table is larger
than one data page can return, fall back to the paginated query below.
This lists the whole
_storagetable, not just static-hosting files. If the app uses file storage for anything else — user uploads, exports, attachments, other components' blobs — those rows appear here too, and nothing in the metadata marks a row as static hosting. Treat this output as an inventory to classify, never as a delete list. The classification and approval steps below are mandatory before removing anything.
For a _storage table larger than one npx convex data page, add this
temporary paginated internal query before migration and walk every page:
// convex/auditStaticHostingStorage.ts
import {
paginationOptsValidator,
paginationResultValidator,
} from "convex/server";
import { v } from "convex/values";
import { internalQuery } from "./_generated/server";
const storageMetadata = v.object({
_id: v.id("_storage"),
_creationTime: v.number(),
contentType: v.optional(v.string()),
sha256: v.string(),
size: v.number(),
});
export const listAppStorage = internalQuery({
args: { paginationOpts: paginationOptsValidator },
returns: paginationResultValidator(storageMetadata),
handler: async (ctx, { paginationOpts }) => {
return await ctx.db.system
.query("_storage")
.order("asc")
.paginate(paginationOpts);
},
});Deploy it while v1 is still live, then save every page outside the repository:
npx convex run auditStaticHostingStorage:listAppStorage \
'{"paginationOpts":{"numItems":100,"cursor":null}}' --prod \
> ~/static-hosting-v1-storage-page-001.jsonRepeat with each returned continueCursor until isDone is true. Remove the
temporary query after the inventory is safely stored.
The _storage table can also contain uploads owned by the rest of the app —
user-uploaded images, generated exports, attachments, or blobs written by other
components all share this one table, and none of them carry a marker
distinguishing them from static-hosting assets. The IDs from the current v1
manifest are the only rows that are definitely static-hosting files. Classify
additional historical candidates using deployment timing, content type, size,
hash matches to known static assets, and, when necessary, the blob contents. In
the rehearsal, duplicate hashes and adjacent creation times tied the older CSS
and SVG files to the first v1 upload. Never treat every _storage row as a
static-hosting blob, and never bulk-delete the table.
Build an explicitly reviewed cleanup list from the current manifest plus only the historical candidates you have verified.
Complete the migration and keep the manifest, inventory, and approved cleanup list unchanged through the rollback window. Deleting current v1 blobs earlier makes a 0.1.x rollback require another full asset upload.
After the rollback window closes, delete only the approved storageId values
from app storage. One safe option is a temporary internal mutation:
// convex/cleanupStaticHosting.ts
import { v } from "convex/values";
import { internalMutation } from "./_generated/server";
export const deleteLegacyStaticHostingBlobs = internalMutation({
args: { storageIds: v.array(v.id("_storage")) },
returns: v.object({ deleted: v.number(), alreadyMissing: v.number() }),
handler: async (ctx, { storageIds }) => {
let deleted = 0;
let alreadyMissing = 0;
for (const storageId of storageIds) {
const metadata = await ctx.db.system.get("_storage", storageId);
if (metadata === null) {
alreadyMissing++;
continue;
}
await ctx.storage.delete(storageId);
deleted++;
}
return { deleted, alreadyMissing };
},
});Deploy that mutation, then pass only the captured IDs in manageable batches:
npx convex run cleanupStaticHosting:deleteLegacyStaticHostingBlobs \
'{"storageIds":["<captured storageId>", "<captured storageId>"]}' --prodVerify the returned counts, then remove the temporary mutation. Keep or dispose of the saved audit evidence according to the app's retention policy. Do not delete unrelated app-storage files. Agents must show the user the exact approved ID list and get explicit approval before running this cleanup.
Mount the component at the final prefix:
app.use(staticHosting, { httpPrefix: "/app/" });The CLI exposes that mount as STATIC_HOSTING_BASE_PATH. Configure the bundler
so generated asset URLs include it. For Vite:
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
base: process.env.STATIC_HOSTING_BASE_PATH ?? "/",
});Then verify /app/, an asset below /app/assets/, and a refreshed SPA route
below /app/. Root-mounted apps do not need a bundler base-path change.
Use this path when production must keep serving 0.1.x until 0.2.x storage is fully populated. Both component versions run side by side, and the final route switch requires no upload.
npm install convex@^1.37.0 @convex-dev/static-hosting@^0.2.0 \
static-hosting-legacy@npm:@convex-dev/static-hosting@0.1.4Keep 0.1.x serving through the app router and stage the 0.2.x HTTP handler at a temporary prefix:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import staticHosting from "@convex-dev/static-hosting/convex.config";
import staticHostingLegacy from "static-hosting-legacy/convex.config.js";
const app = defineApp();
app.use(staticHostingLegacy, { name: "selfHosting" });
app.use(staticHosting, { httpPrefix: "/__static_v2/" });
export default app;selfHosting above must be the exact v1 instance name captured before the
migration. If the app used a custom name, pass that custom name to app.use and
replace every components.selfHosting reference in the following examples with
the matching generated property. Do not mount a fresh default legacy component,
because that leaves the real v1 manifest behind and breaks the promised
backend-only rollback.
If the v1 instance was already named staticHosting, give v2 a different name
for the whole migration. Two mounted components cannot share an instance name,
and renaming v2 at cutover would create a fresh component with empty storage:
app.use(staticHostingLegacy, { name: "staticHosting" });
app.use(staticHosting, {
name: "staticHostingV2",
httpPrefix: "/__static_v2/",
});In that case use components.staticHostingV2 for v2 integrations and pass
--component staticHostingV2 to every v2 upload or deploy command. Keep that v2
name through cutover and rollback.
Keep the 0.1.x root catch-all and upload facade importing from the alias until cutover:
// convex/http.ts
import { registerStaticRoutes } from "static-hosting-legacy";
import { components } from "./_generated/api";
// Register existing exact routes first.
registerStaticRoutes(http, components.selfHosting);// convex/staticHosting.ts: only while 0.1.x is live
import { exposeDeploymentQuery, exposeUploadApi } from "static-hosting-legacy";
import { components } from "./_generated/api";
export const {
generateUploadUrl,
generateUploadUrls,
recordAsset,
recordAssets,
gcOldAssets,
listAssets,
} = exposeUploadApi(components.selfHosting);
export const { getCurrentDeployment } = exposeDeploymentQuery(
components.selfHosting,
);Deploy this backend. The live root still uses 0.1.x:
npx convex deployBuild assets for the final base path, not the temporary staging prefix. For
a Vite app that will ultimately live at /:
npx @convex-dev/static-hosting upload --build --prod \
--build-command "npm run build -- --base=/"Add --component staticHostingV2 here when using the collision-safe v2 name
described above.
The CLI still supplies the production VITE_CONVEX_URL. The explicit Vite base
overrides the temporary /__static_v2/ mount for this build.
The staging prefix is useful for confirming that the new component is mounted
and has index.html. Because the uploaded HTML points to root asset URLs that
0.1.x still owns, it is not a complete visual preview. Use a separate
development deployment for the full smoke test.
Choose the same final routing mode described in the standard migration:
- Component-owned root: keep the legacy component mounted without its static
catch-all, change to
defineApp({ httpPrefix: "/api" }), and mount 0.2.x with{ httpPrefix: "/" }. The old component remains data-only during the rollback window. - Keep existing root routes: keep both components mounted with no
httpPrefix, and changeregisterStaticRoutesto import from@convex-dev/static-hostingand usecomponents.staticHosting. The legacy component again remains mounted only to preserve its rollback data.
For a v1 instance named staticHosting, keep using components.staticHostingV2
in the second option. Do not rename the populated v2 instance during cutover.
Also remove the legacy exposeUploadApi exports. Keep an
exposeDeploymentQuery export only if the app uses deployment notifications,
and point it at the selected v2 instance: components.staticHosting, or
components.staticHostingV2 in the collision-safe case.
Then switch traffic without rebuilding or uploading:
npx convex deployThe 0.2.x component already contains the final-path assets, so the new route owner can serve them immediately. Keep the legacy package alias, its component mount, and the captured 0.1.x app-storage IDs until the rollback window closes. That keeps rollback to a backend-only route change.
After the rollback window closes, remove the legacy app.use(...), deploy the
backend, and review any Convex prompt confirming deletion of the legacy
component data. Then uninstall the alias:
npm uninstall static-hosting-legacyFor CI-driven deploys, rehearse the component-removal step on a development deployment and make the confirmation behavior explicit in the release job. Finally follow Clean up 0.1.x app-storage blobs.
Run npx convex dev --once after changing convex/convex.config.ts. If a
custom component name is configured, use that generated property instead. Do not
patch the generated API file manually.
The watcher observed a half-migrated source tree. Stop it, finish the dependency
and source edits, confirm that exposeUploadApi and components.selfHosting
are gone, then run npx convex dev --once.
Deploy the backend first and make sure --component matches the instance name
passed to app.use(...):
npx convex deploy
npx @convex-dev/static-hosting upload --build --prod \
--component staticHostingThe 0.2.x HTTP handler is deployed but its private storage is empty. Run the 0.2.x upload or deploy command. Files uploaded by 0.1.x cannot fill this storage. The setup response is deliberately HTTP 503 until an upload succeeds.
If component-owned root routing was selected, app HTTP routes moved below
/api. Update the callers or switch to app-owned root compatibility mode.
Configure the bundler base path and rebuild through the CLI. For Vite, use
STATIC_HOSTING_BASE_PATH as shown in
Sub-path deployments.
For a standard in-place migration, reinstall 0.1.4, restore the old component
registration, registerStaticRoutes, and exposeUploadApi wrappers, then
redeploy the 0.1.x backend and assets. Do not expect 0.1.x to serve files from
0.2.x component storage. Keeping the captured 0.1.x blobs preserves the option
to re-record those files during recovery; it does not restore the old component
records by itself. If the blobs have already been cleaned up, a full 0.1.x
upload is required.
For a staged cutover, leave the aliased 0.1.4 component in place until the
rollback window closes. In app-owned compatibility mode, change
registerStaticRoutes back to the alias and components.selfHosting.
In component-owned root mode, also restore defineApp() without the /api
prefix and move v2 away from the root before restoring the legacy catch-all:
const app = defineApp();
app.use(staticHostingLegacy, { name: "selfHosting" });
app.use(staticHosting, { httpPrefix: "/__static_v2/" });Use the captured custom v1 name here too when it was not selfHosting. If the
v1 name is staticHosting, use staticHostingV2 as the v2 component name here,
matching the collision-safe mount used during preload.
That restores the pre-cutover route ownership while retaining both component mounts. The rollback is backend-only because the v1 component and its manifest never went away. Do not confirm deletion of the legacy component data before the rollback window closes.
Do not run the legacy app-storage cleanup until the rollback window has closed and the user has approved making the v1 asset set unrecoverable without another upload.