Moving an existing site onto Forumulate
You have documentation somewhere else, on a URL people link to and Google ranks. This page is the order of operations for moving it here without losing either.
The short version: import the content, map every old URL to a redirect, verify the domain without activating it, point the proxy or DNS, activate, then submit the sitemap. Doing those in a different order is what costs you rankings.
Before anything
Make a list of the URLs you are keeping. Export it from your current platform's sitemap:
curl -s https://docs.yourcompany.com/sitemap.xml \
| grep -o '<loc>[^<]*</loc>' | sed 's/<[^>]*>//g' > old-urls.txt
wc -l old-urls.txtThat file is the acceptance test for the whole migration. At the end, every line in it must answer 200 or a redirect — 308 is what a rule answers unless you give it a status, and a rule may also say 301, 302 or 307 — and nothing must answer 404.
1. Import the content
Bring the pages in first, while nothing is pointed at us. A ZIP or a Git-backed import is usually the fastest route; see Importing documentation. Check the slug each page landed on, because that is what step 2 maps onto.
A forum moves with POST /orgs/{orgId}/import: categories, threads, authors and dates come across in one document. See Importing a forum.
2. Map the old URLs
Redirects are per space, and the whole set is written in one call:
PUT /orgs/{orgId}/spaces/{spaceId}/redirects
{
"redirects": [
{ "from": "/docs/v1/install", "to": "/installing-the-cli" },
{ "from": "/getting-started.html", "to": "/getting-started" },
{ "from": "/api/reference/webhooks","to": "/webhooks" }
]
}Method |
|
Limit | 500 entries per space. |
Fields |
|
Precedence | A real page wins. A redirect only fires for a path that has no page, so you cannot accidentally shadow live content. |
Where it applies | Everywhere the space is served — your custom domain, a path mount, and the platform address. |
Generate the body from old-urls.txt rather than typing it. A path you forget is a 404 that a crawler finds before you do.
Moving a whole subtree: *
A from that ends in * matches everything below it, so one rule can retire a whole section of an old site:
{ "redirects": [
{ "from": "/docs/v1/*", "to": "/*" }, # /docs/v1/a/b → /a/b
{ "from": "/legacy/*", "to": "/getting-started" } # the whole tree → one page
] }A
tothat ends in*carries the part thefrommatched. Without it, every match lands on the one address.The longest matching wildcard wins, so
/docs/v1/*beats/docs/*.An exact rule always beats a wildcard, which is how you except one page from a subtree rule.
A
*anywhere other than the end is not a pattern — it is a literal path.
Status codes, query strings and rewrites
Every key below is optional. A rule that sets none of them behaves exactly as rules always have: a 308, the query string ignored when matching, and the query carried along to a target on the same host.
{ "redirects": [
{ "from": "/pricing-old", "to": "/pricing", "status": 301 },
{ "from": "/beta", "to": "/launch", "status": 302 },
{ "from": "/docs", "to": "/v2/docs", "match": { "query": { "version": "2" } } },
{ "from": "/docs", "to": "/v1/docs" },
{ "from": "/search", "to": "https://help.example.com/find", "passQuery": true },
{ "from": "/start", "to": "/getting-started", "type": "rewrite" }
] }Key | Meaning |
|---|---|
|
|
| A map of query keys to the exact value each must have: |
|
|
|
|
Several rules may share a from when their match.query differs. The one with more query conditions is tried first, so in the example /docs?version=2 goes to /v2/docs and every other /docs to /v1/docs. Two rules with the same from and the same conditions are refused, because only the first could ever fire.
A rewrite is followed once. If its target is itself a rewrite, the second one is not applied (two rewrites pointing at each other would otherwise never finish); a redirect on the target still is. A table where rules lead back to where they started — through redirects, rewrites or a mix — is refused with a 422 when it is written. Rewrites apply to page reads (GET/HEAD) and to a page's .md form: a rewrite from /start also serves /getting-started.md at /start.md.
Renaming a collection
Renaming a collection moves exactly one address: its hub, /c/<slug>. We write that redirect for you, and the hub's introduction moves with it. The pages filed under the collection are addressed by their own slug within the space, so they do not move and need no rules.
A whole domain can also be redirected elsewhere with PUT /orgs/{orgId}/spaces/{spaceId}/domains/{domainId}/redirect — that is for retiring an old hostname entirely, not for per-page mapping.
3. Decide the address shape
Three choices, and this one decides who gets the search authority:
Shape | Authority accrues to |
|---|---|
|
|
|
|
| us — fine for internal docs, wrong for anything public |
If your old docs were already on yourcompany.com/docs, keep them there. Moving from a path to a subdomain throws away domain-level signals that took years to earn, and no redirect gets them back.
Publish on your own domain has the mechanics of all three.
4. If your site is a single-page app
This is the case that needs care. Your SPA's client router owns every path below the origin, and we are about to take one of them.
What has to be true:
The mount is declared.
POST …/domainswith{"hostname": "yourcompany.com", "basePath": "/docs"}.Your proxy forwards
/docsand/docs/*toorigin.forumulate.com, passing the originalHost. Not toforumulate.com— that is behind a CDN which answers 403 to a Host it does not serve.The forward happens before your SPA's catch-all. In nginx a
location /docs/block beats thelocation /that servesindex.html, but check the order in whatever you actually run. If your SPA's fallback wins, readers get your app shell at a documentation URL and so does Googlebot.Your client router must not intercept
/docslinks. A framework router that captures same-origin anchor clicks will try to render/docs/fooitself. Mark those links as external —<a href="/docs/foo" data-native>or whatever your router's escape hatch is — or they work on a hard load and break on a click.
A request below the mount that we have no page for gets our 404, with the section index and a working search — not your app shell, and not a pass-through to your origin. That is correct behaviour (you forwarded the path; we own it), but it means the mount boundary has to be exactly the paths you intend.
5. Sitemaps and robots.txt
You get these without configuring anything.
URL | What it serves |
|---|---|
| The space's published pages, on your domain. |
| The same, under a path mount. |
| Allows the reader surfaces, excludes the console, account, auth and API paths, and names the sitemap. |
When several spaces share one address it is a site, and sitemap.xml at the address root is an index pointing at each space's own sitemap. Submit the index, once.
Two things to do by hand:
Keep your own
robots.txtand the mounted one from colliding. Under a path mount,yourcompany.com/robots.txtis still yours — ours is only served at the mount. Make sure yourrobots.txtdoes notDisallow: /docs, which is a surprisingly common leftover from staging.Submit the new sitemap and keep the old one reachable for a few weeks. Google rediscovers through the old URLs; a 301 from a URL it cannot fetch is a 301 it never sees.
Verify the robots side from outside:
npx --yes @forumulate/cli doctor https://yourcompany.com/docsIts robots, sitemap and crawler-access checks are precisely the three things that silently remove a site from search.
6. Canonical links
Every page declares one canonical URL, and it is on your address — the space's primary custom domain when it has one, the platform path when it does not. Platform copies of the same page cross-canonicalise to the custom domain, so forumulate.com/p/… points at docs.yourcompany.com/… rather than competing with it.
What to check, and what goes wrong:
If the primary domain is unset, pages canonicalise to our address. That is an instruction to Google to rank us for your content. The
canonicalcheck indoctorcatches it in one command.Verify, do not assume. View source on any page and read
<link rel="canonical">. It must name your host.
7. The cutover, in order
Each step is where it is for a reason.
Import the content and check the slugs. Nothing is pointed at us; a wrong slug costs nothing yet.
Write the redirects from
old-urls.txt. Before cutover, so the first request after cutover is already handled.Add the domain and prove ownership, with
POST …/domains/{domainId}/verify {"activate": false}. This is the important detail: verifying proves the name without switching traffic, so your existing docs keep serving while you finish.Point DNS, or add the proxy rule. For a whole hostname: the
CNAME(a subdomain) orArecord (an apex) the panel shows. For a path mount: the forward toorigin.forumulate.com. Do not point DNS before verifying. A hostname resolving to us that we have no proof you own cannot be served, so the window between pointing and verifying is a window of 404s — and TLS issuance has not started either, so it is a window of certificate errors too.Wait for the certificate. For a whole hostname, a Let's Encrypt certificate is issued once the name is verified and the DNS points here. It is usually under a minute and occasionally a few. Check with
GET …/domains/{domainId}/setup:served: trueis the signal. A path mount skips this entirely — TLS terminates at your proxy, on your certificate.Activate.
POST …/domains/{domainId}/activate. This is the cutover. From here we answer the address.Check from outside, as a stranger.
npx --yes @forumulate/cli doctor https://docs.yourcompany.com while read u; do printf '%s ' "$u"; curl -so /dev/null -w '%{http_code}\n' \ -A 'migration-check/1.0' "$u"; done < old-urls.txt | grep -v ' 30[1278]$\| 200$'That second line prints only the URLs that are neither a redirect (
301,302,307or308) nor a page — which should be an empty list. If you meant every move to be permanent, a302or307in the unfiltered output is a rule with the wrongstatus.Submit the sitemap in Search Console, and only now.
If you have Cloudflare in front, add the two rules — Email Obfuscation off, and a cache rule that respects the origin's
Cache-Control. Both are documented with their failure modes; the cache one in particular can serve a signed-in author's page to the public if you get it wrong.Leave the old platform running for a month. Then turn it off.
If something is wrong after cutover
Symptom | Where to look |
|---|---|
403 from the edge | The proxy targets |
Your old app shell at a docs URL | The SPA catch-all is winning over the |
Everything 404s | The space is private, or its pages are unpublished. A private space answers anonymously with nothing, which looks identical to a routing fault. |
Canonical names our domain | The space's primary custom domain is not set. |
Pages invisible to Google but fine in a browser |
|
A page 404s that should redirect | It is missing from the |