For those looking for a quick answer, here's the main code:
await Promise.all([page.waitForNavigation(), el.click()]);
...where el is a link that points to another page in the SPA and click can be any event that causes navigation. See below for details.
I agree that waitFor isn't too helpful if you can't rely on page content. Even if you can, in most cases it seems like a less desirable approach than naturally reacting to the navigation. Luckily, page.waitForNavigation does work on SPAs. Here's a minimal, complete example of navigating between pages using a click event on a link (the same should work for a form submission) on a tiny vanilla SPA mockup which uses the history API (index.html below). I used Node 10 and Puppeteer 5.4.1.
index.html:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <script>
      const nav = `<a href="/">Home</a> | <a href="/about">About</a> | 
                   <a href="/contact">Contact</a>`;
      const routes = {
        "/": `<h1>Home</h1>${nav}<p>Welcome home!</p>`,
        "/about": `<h1>About</h1>${nav}<p>This is a tiny SPA</p>`,
      };
      const render = path => {
        document.body.innerHTML = routes[path] || `<h1>404</h1>${nav}`;
        document.querySelectorAll('[href^="/"]').forEach(el => 
          el.addEventListener("click", evt => {
            evt.preventDefault();
            const {pathname: path} = new URL(evt.target.href);
            window.history.pushState({path}, path, path);
            render(path);
          })
        );
      };
      window.addEventListener("popstate", e =>
        render(new URL(window.location.href).pathname)
      );
      render("/");
    </script>
  </body>
</html>
index.js:
const puppeteer = require("puppeteer");
let browser;
(async () => {
  browser = await puppeteer.launch();
  const page = await browser.newPage();
  // navigate to the home page for the SPA and print the contents
  await page.goto("http://localhost:8000");
  console.log(page.url());
  console.log(await page.$eval("p", el => el.innerHTML));
  // navigate to the about page via the link
  const [el] = await page.$x('//a[text()="About"]');
  await Promise.all([page.waitForNavigation(), el.click()]);
  // show proof that we're on the about page
  console.log(page.url());
  console.log(await page.$eval("p", el => el.innerHTML));
})()
  .catch(err => console.error(err))
  .finally(async () => await browser.close())
;
Sample run:
$ python3 -m http.server &
$ node index.js
http://localhost:8000/
Welcome home!
http://localhost:8000/about
This is a tiny SPA
If the await Promise.all([page.waitForNavigation(), el.click()]); pattern seems strange, see this issue thread which explains the gotcha that the intuitive
await page.waitForNavigation(); 
await el.click();
causes a race condition.
The same thing as the Promise.all shown above can be done with:
const navPromise = page.waitForNavigation({timeout: 1000});
await el.click();
await navPromise;
See this related answer for more on navigating SPAs with Puppeteer including hash routers.