Moving an existing site onto Forumulate

Embed
Preview of the card
<a href="https://forumulate.com/guides/moving-an-existing-site"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/7d8697ff-3792-4d14-a8f9-5899c8c462c9/social-card.svg?v=3e7ae33eec8b" alt="Moving an existing site onto Forumulate" width="600" height="315"></a>

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.txt

That 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

PUT — it replaces the whole list. Read with GET first and send the union, or you will drop the ones you are not thinking about.

Limit

500 entries per space.

Fields

from and to, both required. Either may end in * — see below. status, match, passQuery and type are optional.

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 to that ends in * carries the part the from matched. 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

status

301, 302, 307 or 308 (default). 301/308 are permanent and search engines move the old address's ranking to the new one; 302/307 are temporary. 307/308 keep the request method.

match.query

A map of query keys to the exact value each must have: { "version": "2" } matches /docs?version=2 and /docs?version=2&x=1, not /docs or /docs?version=3. "ignore" (the default) matches whatever the query is. At most 10 keys.

passQuery

true carries the incoming query to the target, merged under any query the target already has; false drops it. Unset: kept on a same-host redirect, dropped on an off-site one — what every rule did before this key existed.

type

redirect (default) or rewrite. A rewrite serves to at the address that was asked for: the URL in the reader's bar does not change, and the response is whatever to answers. to must be a path of this space — an absolute URL is a 422, because a rewrite is not a proxy and fetches nothing from anywhere else. A rewrite takes no status.

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

docs.yourcompany.com

docs.yourcompany.com, a separate site in Google's eyes

yourcompany.com/docs

yourcompany.com itself

yourname.forumulate.com

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 …/domains with {"hostname": "yourcompany.com", "basePath": "/docs"}.

  • Your proxy forwards /docs and /docs/* to origin.forumulate.com, passing the original Host. Not to forumulate.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 the location / that serves index.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 /docs links. A framework router that captures same-origin anchor clicks will try to render /docs/foo itself. 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

https://docs.yourcompany.com/sitemap.xml

The space's published pages, on your domain.

https://yourcompany.com/docs/sitemap.xml

The same, under a path mount.

https://docs.yourcompany.com/robots.txt

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:

  1. Keep your own robots.txt and the mounted one from colliding. Under a path mount, yourcompany.com/robots.txt is still yours — ours is only served at the mount. Make sure your robots.txt does not Disallow: /docs, which is a surprisingly common leftover from staging.

  2. 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/docs

Its robots, sitemap and crawler-access checks are precisely the three things that silently remove a site from search.

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 canonical check in doctor catches 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.

  1. Import the content and check the slugs. Nothing is pointed at us; a wrong slug costs nothing yet.

  2. Write the redirects from old-urls.txt. Before cutover, so the first request after cutover is already handled.

  3. 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.

  4. Point DNS, or add the proxy rule. For a whole hostname: the CNAME (a subdomain) or A record (an apex) the panel shows. For a path mount: the forward to origin.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.

  5. 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: true is the signal. A path mount skips this entirely — TLS terminates at your proxy, on your certificate.

  6. Activate. POST …/domains/{domainId}/activate. This is the cutover. From here we answer the address.

  7. 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, 307 or 308) nor a page — which should be an empty list. If you meant every move to be permanent, a 302 or 307 in the unfiltered output is a rule with the wrong status.

  8. Submit the sitemap in Search Console, and only now.

  9. 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.

  10. 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 forumulate.com instead of origin.forumulate.com, or does not pass the original Host.

Your old app shell at a docs URL

The SPA catch-all is winning over the /docs forward.

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

crawlerBlocked: true from …/domains/{domainId}/setup — a CDN rule of yours is refusing crawler User-Agents.

A page 404s that should redirect

It is missing from the redirects list, or a later PUT replaced the list.

The Forumulate teamUpdated