quasarframework/quasar

Feature request: ability to generate a static website

MohammedAl-Mahdawi opened this issue ยท 56 comments

I think Quasar is almost perfect and I think adding the ability to generate a static website like Nuxt is better than maintaining a "Quasar + Nuxt starter kit" because I think Quasar gave us a freedom better than Nuxt and capabilities much better than Nuxt.

I don't expect this feature to be available in the next release I just want to see it someday available in the most perfect framework Quasar!

@ExNG You are right, Vue renders page in browser - what static generators do is that they just run app, virtually visit selected routes and save rendered output. Then you can serve prerendered pages and let javascript take over that later when it loads.

Hexo can do that (used for Quasar docs), Vue-press, Vue-prerender plugin etc..

@ExNG @panstromek I'm here talking about how Nust generate(prerender) a static website, it will not affect at all how Quasar currently works, it just an extra feature(maybe mode) that makes Quasar in my opinion "complete solution".

It just extra feature/mode so if you don't need it then don't use it, however regarding all the available tools(like Vue-press) that generate(prerender) a static website it is recommended that your website must not contain that much pages(I believe several hundred is ok) however it depends on the system specifications that you will generate the static files on, from its name it is good for "static websites".

Quasar is very responsive by design. It takes into account platform, screen size and more. When you prerender / create a static website you can't know beforehand anything about the client who will consume the page and that's a problem.

We've just launched SSR. Let's take it step by step. At some point I'll find a solution for this too. But don't expect it to be too soon as the focus will gradually become shipping v1.0.

@rstoenescu cool ;) It would definitely be nice - especially with latest shift to JAMstack and sites that are heavily prerendered. JAMstack philosophy is actually against SSR.

Now I am not sure if I am talking about same subject - is this considered the same as what prerender SPA plugin does? There used to be a page in docs for that and I guess it should still work.. I need to try.

Or is this about generating static-only (no SPA) website like hexo does?

Another point is that Vue-press generate all routes but still runs as SPA then, so it is the first case.

Nuxt generate feature works like Hexo

I would really appreciate features like gatsby or react-static . pre-rendered and then take over by spa.

@asarkar1990 I think this is done by that prerender-spa plugin, but I am not sure about its limitations, I haven't got a chance to try it, yet.

Vue-press also does it. They do it by leveraging SSR which I think is the way to go in Quasar case, too. They simply run SSR server, visit each route and save the output. In other words client can't recognize if the app uses SSR or is built statically.

I think that with some custom code you could already do similar thing with Quasar right now.

I agree this would be a great feature. I am using Jekyll at the moment to generate static websites,as it's one of the more mature and widely supported SSGs out there. Relatively simple to use and lots of plugins and services such as headless CMS, shopping carts, headless eCommerce engines and much more.

Been thinking about VuePress, but then it only does static. For me if Quasar could add in a SSG channel, then just one tool to learn....

I like the idea a lot. We are currently stabilizing and documenting the app-extension system - so I could envision a few ways to do this - probably using phantomJS or something similar

I would really like this feature to be added in Quasar. It would help me use it for netlify-cms later.

There are some reasons why making a static site is complicated (especially regarding final bundle size), but we think that there is hope for this. @codenamezjames has said he will be working on a possible solution.

Is there a way to integrate gridsome into the quasar build system?

@mckelveygreg - currently, there isn't.

Scott

@mckelveygreg I think it would actually be easier to do it the other way around - Integrate Quasar into Gridsome. Still a lot of glue work probably.

Can't say when it'll be done exactly, but I am currently working on a solution - QPublisher

Hmm, probably too much glue :)
Well, I guess the things I love about tools like Gatsby or Gridsome is having graphql be a central place for data and the ease of image optimisation.
I looked at the suggestion for prerender-spa which looked promising, but a little out of date?
I'm sure I'm missing some similar tools for quasar?

@mckelveygreg prerender-spa-plugin is actually fine, I use it now. It works well and it's very easy to setup. The only problem is that it's pretty slow compared to SSR based prerendering, because it spins up a browser to render the page. But it's usable for smaller number of pages (like few hundreds, I'd say). It has some caveats, some of them similar to SSR, so if your page is SSR compliant, you should be mostly fine.

UPDATED TO INTEGRATE FOLLOWING ANSWER

I confirm that with prerender-spa-plugin everything is working as expected.
Here's an example of webpack configuration.

src/boot/ssg.js

import { Quasar } from 'quasar';
const { ssrUpdate } = Quasar;

export default ({ app }) => {
  ssrUpdate({ app });
};

quasar.conf.js

const PrerenderSPAPlugin = require("prerender-spa-plugin");
const path = require("path");

// ...

boot: [
  ...(ctx.prod && ctx.mode.spa ? ['ssg'] : []),
  // ... other boot files
],

// ...
extendWebpack(cfg) {
  if (process.env.NODE_ENV === "production") {
    // ...
    cfg.plugins.push(
          new PrerenderSPAPlugin({
            // Required - The path to the webpack-outputted app to prerender.
            staticDir: path.join(__dirname, "dist/spa"),
            // Required - Routes to render.
            routes: [
              '/', // Homepage
              // ...other routes
              '/error-404' // 404 page, it works because this route doesn't actually exist
            ],
            postProcess: context => {
                context.html = context.html
                  // Defer scripts
                  .replace(/<script (.*?)>/g, '<script $1 defer>')
                  .replace('id="app"', 'id="app" data-server-rendered="true"');
                return context;
            }
          })
        );
  }
}

Like this it adds a folder for each page into dist/spa and updates its index.html.
It's possible to configure it to emit to another folder (eg prerendered) but you'd need to copy all assets in it afterwards because it's not done automatically.

Remember that you must use it with router history mode.
Also, as Razvan pointed out, Quasar is extremely responsive and it's Screen plugin enable this. DO NOT add data-server-rendered="true" on <div id="q-app"></div> if you are using responsiveness (eg. $q.screen.xs and similar) or the app will assume it doesn't have to re-render the app and responsiveness will be lost.

This Vue plugin con also be an interesting source of insights, but watch out because as of today it uses an old prerender-spa-plugin version (prior to 3.4.0) and some options specified in the README doesn't seem to be in sync.

Production environment check is made because prerendering is an intensive task and we probably don't want it to happen during development.

Note that prerender-spa-plugin hasn't seen a release since September 2018, even if a maintainer is still somewhat active into answering and closing issues.
A Quasar-native solution would still be preferable.

@IlCallo
I found a way to hydrate components with data-server-rendered="true" on <div id="q-app"></div> while keeping the responsiveness of the framework.

Put this in a new boot file:

// src/boot/static.js

import { Quasar } from 'quasar'

const { ssrUpdate } = Quasar

export default ({ app }) => {
  ssrUpdate({ app })
}

In "quasar.conf.js" you can include this boot file only for production in spa mode like this:

// quasar.conf.js

module.exports = function (ctx) {
  return {
    // ...

    boot: ['your_boot_files'].concat(ctx.prod && ctx.mode.spa ? ['static'] : []),

    // ...
  }
}

Interesting, can you expand on why it works? To me it seems it is forcing Vue to update after not having updated because it found data-server-rendered, is there a benefits versus just avoid adding the attribute?

From vuejs ssr documentation:

Since the server has already rendered the markup, we obviously do not want to throw that away and re-create all the DOM elements. Instead, we want to "hydrate" the static markup and make it interactive.

The data-server-rendered special attribute lets the client-side Vue know that the markup is rendered by the server and it should mount in hydration mode

Hydration mode reduces the time to interactive (input latency) by not rerendering the DOM.

"ssrUpdate" is a function from Quasar which is called at boot time in ssr mode. We just do the same with spa prerendered.

At client side ssrUpdate inject this global mixin:

mounted () {
  queues.takeover.forEach(run => {
    run(this.$q)
  })
}

For responsiveness if we look at the Quasar Screen plugin, we can see that the "start" function is pushed to queues.takeover if we're coming from SSR.
This start function handles the responsiveness of the layouts and is called only at client side in ssr mode or in spa mode without prerendered markup.

fromSSR is true if we are in hydration mode by looking for the data-server-rendered attribute in the DOM.

If we don't call ssrUpdate while Vuejs is in hydration mode, the start function is never called and we loose responsiveness from Quasar Screen plugin.

I hope my english is enough good to understand my explanations.

Yeah, I more or less understood, I'll try this on my projects, thanks!

@freddy38510 I tryed your solution and it actually works, I updated my previous comment to include your.

While trying everything out, I noticed something strange with network downloads, so I dug deeper.

My website must serve the homepage both on / and /home routes.
If I add the empty route to the ones processed by prerender-spa-plugin, the root index.html is statically generated too as the homepage.
This results into all homepage resources (or most of them) being loaded for every page which is accessed when I use quasar serve --history dist/spa/. I think this is due to quasar serve redirecting all traffic to the root index.html, rightfully behaving as a SPA.

If you really want to check how your SSG is performing, a custom node server should be added to your package and run, much like how it works with SSR (except this is a much easier server, it should just point to the right folder and everything should work).

Apart from this, I think the last missing step before SSG could be added to Quasar seamlessly (either into core or with an AE) is to automatically get the routes to presender from the router.js file or by crawling the website.

@IlCallo
From the Quasar Doc about param --history

Use history api fallback;
All requests fallback to /index.html,
unless using "--index" parameter

I actually using my own Quasar app extension to generate static pages from SSR (much faster to prerender pages than using a browser).
My extension also generate a "404.html" file which is an SPA fallback. Most of static webhostings are using this filename for custom 404 page.

I could share this extension with the community after some polishing. Some help could be great to write the documentation and review the code.

--index param just allow to specify a different file name for the root index.html, but all requests will be redirected to that file anyway, from what I'm reading in the docs.

> --index, -i History mode (only!) index url path (default: index.html)

It won't directly serve the page (I just tried), you'll still load the prerendered index.html file and its resources first and then the router will kick in and move to the right page, unless I'm missing something.

For development, quasar serve dist/spa (without --history parameter, even if you set build > vueRouterMode: 'history') is good enough.


@freddy38510 I can help you with the AE if you like, I was thinking of writing it myself actually.


If you want to add a node server to the distributable for production use, here's an example.

src-ssg/index.js

/* eslint-env node */
import cors from 'cors';
import express from 'express';
import { join, resolve, extname } from 'path';
import { existsSync } from 'fs';

const pagePath = urlPath => join(resolve(), urlPath, 'index.html');
const resourcePath = urlPath => join(resolve(), urlPath);

const PORT = 4000; // Insert your port
const app = express();

app.use(cors({ origin: `http://localhost:${PORT}` }));

app.get('*', (req, res) => {
  // eslint-disable-next-line no-console
  console.log(`Requested path: ${req.path}`);

  let filePath =
    extname(req.path) === '' ? pagePath(req.path) : resourcePath(req.path);

  // Page not found path
  if (!existsSync(filePath)) {
    filePath = pagePath('error-404');
  }

  res.sendFile(filePath);
});

app.listen(PORT, () => {
  // eslint-disable-next-line no-console
  console.log(`Server is listening on localhost:${PORT}...`);
});

src-ssg/package.json

{
  "name": "src-ssg",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "serve": "node index.js"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^4.17.1"
  }
}

Then make sure that those files are copied over to dist folder using afterBuild hook into quasar.conf.js

  build: {
      afterBuild({ quasarConf }) {
        const distDir = quasarConf.build.distDir;
        const composeDistPath = src => path.join(distDir, src);

        const composeSsgPath = src => path.join(__dirname, 'src-ssg/' + src);
        const ssgFileToCopy = ['index.js', 'package.json'];
        for (const fileName of ssgFileToCopy) {
          fs.copyFileSync(composeSsgPath(fileName), composeDistPath(fileName));
        }
     }
  }

Finally, you can add a script into the root level package.json to test out the server+SSG.

"scripts": {
  "serve:ssg": "cd dist/spa && yarn install && yarn serve"
}

When developing, go with yarn build && yarn serve:ssg.
In production just run yarn install and yarn serve where you copied the dist folder.

@IlCallo I mean, don't use --history param if you want to serve "index.html" from each pre-rendered pages.

The extension i talked about is for generating pre-rendered pages from SSR like Nuxt does in universal mode. This is not the same behavior than prerender-spa-plugin which generate prerendered pages from headless browser like Puppeteer.

As far as I know, --history param just changes from using hash based URLs to not hash based ones, it doesn't change the "redirect on a single file" behaviour, but maybe I'm wrong.

If the output is the functionally the same, I guess how you get there isn't really important. As you told it, it seems like it, if it's faster it's not a problem I guess ๐Ÿ˜‚

If instead the output is actually different, I'll just create my own AE :)

@IlCallo --history param is using connect-history-api-fallback which is a

Middleware to proxy requests through a specified index page, useful for Single Page Applications that utilise the HTML5 History API.

If you don't use this middleware every request are looking for an "index.html" at the requested location (from url path). This is what we want for prerendered pages.

You're right, the output is more or less the same. One advantage for SSR over browser based rendering is that we don't need to make some workaround for Quasar responsiveness. This is already handle by Quasar in ssr mode.

We also don't need to defer scripts manually as it is already done by vue-server-renderer. Same thing for preloading and prefetching resources.

We could also prefetching data and inlining the initial state of the app at server side. Quasar Prefetch feature becomes useful for that.

See all the possibilites in the vue ssr guide.

A last advantage i can think of is the CSS management.

I thought that quasar serve without --history option only worked when in combo with with vueRouterMode: 'hash', but now I see the bigger picture.

Your SSR-based AE seems way more advanced than the one I proposed, I can help if you want to open source it

Another (small) problem of prerender-spa-plugin is that's it has some problems with Meta plugin description meta tag.
It will inline the right description, but when changing page it will create a second one and update that one fron that point onward. Crawlers will only see the first one, so not a big deal.

There are more problems - If you prerender with a browser like prerender-spa-plugindoes , it will run more code than it should - it will run mounted hooks, adds event listeners to DOM etc and it's just a pain to debug when there is a problem. SSR prerendering correctly runs only created hooks and related stuff, but mounted hooks and browser specific stuff only runs in the browser.

I am very intersted in SSR based app extension, too ;) I wanted to have this for a long time.

@freddy38510 any news?

@IlCallo
I'm sorry, i've been very busy. I will try to find some time this week to polish a little the extension and make the repo publicly available.

@IlCallo, @panstromek
The extension is online at https://github.com/freddy38510/quasar-app-extension-static. For now, i didn't publish it to the npm repository, because i think it needs some tests and improvements.

So, to test it, clone the repo then follow the instructions on the Quasar doc like if you were developing it locally.

After installing it, you can set the routes to render in the file "src-static/route.js" present at the root of your project. This file simply export an array of routes to render the static pages. This is the best way i found to have the ability to generate static pages from dynamic routes.

I didn't add a command to generate static pages, you should just run quasar build -m ssr. It works only in production and ssr mode.

I will enjoy to have some feedbacks, new ideas and PR to the repo.

@IlCallo , @panstromek
I forgot to specify where you could find the generated files for static pages. The answer is in dist/ssr/www folder by default. If you change the option "distDir" in the property "build" of your "quasar.conf.js" file the folder will be your_configured_distDir/www.

So, to test your website you can use this command quasar serve ./dist/ssr/www
You should not use the "--history" parameter.

You can generate a fallback SPA file with the name of your choice. By default this is "404.html". This will be useful to replace the default 404 page of your static website server.

I also recommend to create a route for catching 404 since our website will operate as a SPA for subsequent navigations.

// src/router/routes.js
{ path: '*', component: () => import('pages/error404.vue') }

Thank you! I'll check it out next week

I added some issues into https://github.com/freddy38510/quasar-app-extension-static where I think things could be improved.
If anyone wants to jump in and provide feedback, you're welcome!

That can be an awesome feature... any news?

Freddy open sourced it's app extensions but I had some problems and couldn't help him polish it yet.
Do you want to jump in and try to test it/provide feedback? :)

@eladcandroid Did you test the app extension ?

I'm looking forward to implementing it in a Quasar based website!
Unfortunately I can't help you with development of the extension but I'l give you some beta feedback as soon as @freddy38510 thinks is stable enough :)

@cabassi
You can already use the app extension as is, actually i have no issues. But i prefer to warn developpers, i have not enough feedbacks (close to 0) to say that the app extension is "stable".
Also, i should write a README in my repository, i didn't find the time yet.

Looking forward to the AE.

Would it be possible to add @freddy38510 's work into the repository.

What is 'AE'? It has been referred to many times.

Edit: AE = App extension

Hi @chintan-mishra, you could use freddy work as an app extension and contribute to that project

@IlCallo Since after finding this project, I have been using the app extension project.

I mentioned adding this project to quasar-framework repo as Static Site Generation and JAMstack are introducing faster experience.

It is hard to find this plugin. I have a large but mostly static website with some dynamic content. I have been using server side rendering(SSR) for my use case. With advent of static site generator and JAMstack, I can finally move my complete website to CDN.

Searching for static site generator or JAMstack on search engine and Quasar Framework forums leads to very few results relevant to Quasar Framework.

I believe there is some value to be gained by including this plugin in official repo and adding static site generator in one of the ways to deliver Quasar Framework website.

Something as simple as mentioning Freddy's Static Site Generator plugin in docs with its own section can help show Quasar Framework in search results when someone looks up for 'JAMstack framework for VueJS'

I would love to see Quasar be able to generate a fully static site. I was recently trying to get Quasar working in gridsome as a plugin and ran into troubles around the $q. various properties. Priorities shifted for now so I have stopped working on that, but it would be great to see a static option.

To all guys that are using the mentionned app extension, i made a big update.

What i did:

  • refactor the code
  • put configuration in quasar-conf.js
  • write a complete documentation
  • add PWA support (with precaching for generated pages)
  • add a caching feature inspired by Nuxt.

To all of them, i suggest you to read the upgrading section.

I hope to have more testing to make this extension reaching a stable version. And obviously i encourage all of you to contribute to this project.

Per Vue's Official SSR Guide, you can give a shot to prerender-spa-plugin.

Setting it up is as easy as adding 5 lines in quasar.conf.js and it gives all you might need -- one .HTML file per page.

// quasar.conf.js

const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')

      ...
      extendWebpack (cfg) {
        cfg.plugins.push(
          new PrerenderSPAPlugin({
            staticDir: path.join(__dirname, 'dist/spa'),
            routes: ['/', '/about', '/path/to/prerender']
          })
        )
        ...

@eyedean #2299 (comment)
That route has already been followed, but SSR-based version should return a cleaner code, even if I found much more problems with it than the first one (due to the code needing to be SSR compatible mainly)

@IlCallo SSR versus SSG is a huge debate. One should consider so many factors before making a decision.

In my case, I was open to both but found that this quick Webpack plugin suffices for my need. Not to mention that Quasar's SSR output is a whole "server" (not a "function", "controller", or "module") so merging it into an existing server with its own configurations (e.g. Typescript, custom middlewares and logging, etc.), which was the case for me, is rather challenging per see.

PS. Quasar is great! :)

@eyedean I meant that there is an App Extension which helps you to create a SSG app by leveraging Quasar SSR mode behind the curtains. Which is a different path to the same result of using PrerenderSPA plugin, but should generate more cleaner and deterministic code

@IlCallo I actually finished working with the PrerenderSPA Plugin and then switched to the SSG extension. Here are what I found that I can share for the future folks who come here.

PrerenderSPA Plugin (a Webpack tool, not Vue-specific, nor Quasar-specific)

Pros:

  1. Very easy to set up. Just 5 lines of code in quasar.conf.js and boom, you get what you want!
  2. Worked fine with pure SPA mode. No challenge or new issues popped up. Since it runs after the build with Quasar is done, it doesn't interact with Quasar almost at all. It just runs a Chromium headless browser to crawl and download the pages, as far I could tell.

Cons:
Initially, it worked totally fine, with no error, when I first tested it with two dummy pages. However, when I finished my project, I faced the following issues.

  1. I had an issue with the background-image CSS property of q-img elements (see #7053 (comment) for the details of how it works), that in the Prerender output was referring to localhost:8000/... and was a 404 in the Console. Note that 8000 is not a port I was using, it is that Chromium's port. Apparently, it fails to update such deep URL
  2. An issue with loading a third-party JS from /public folder popped up. It wasn't a problem during dev build or prod build of Quasar SPA.

Quasar's SSG App Extension (Quasar-only, by @freddy38510)

Pros:

  1. No errors so far! I am happy with that.
  2. Since it's quasar-specific, I am happy that internally it hooks properly into Quasar's configs and presumably does the best to optimize the result.

Cons Challenges:
It runs the project in SSR mode. My entire development was based on SPA mode. So, inherently I ran into some SSR-mode challenges.

To name the SPA -> SSR challenges:

  1. I had several errors caused by using window and platform objects in my code that I fixed one-by-one by going through Quasar's SSR docs and shifting the usage into beforeMounted. Note that I understand SSG is basically generating the content to be transported as offline stand-alone pages, regardless of the client, and consequently the use of Platform object doesn't make sense.

  2. It had a problem with packages that are using ES6 exported modules like vue2-smooth-scroll, since it's running the SSR, server code, in Node.js which only accepts common-js modules.~

  3. It had problems with third-party libraries like v-smooth-scroll (it's different from the previous one!) that is using window natively. Again, I needed to move them to only happen in Client-Side using if (!process.env.SERVER) { Vue.use(VueSmoothScroll); }. [Update: 4/3/21]: as @IlCallo pointed in the comment below, using boot files can facilitate having certain third-party libraries only load in client-side.

  4. Not a huge deal, but the build time is longer as it is building both a Server and a Client.

  5. [Update 4/3/21] Hydration can be a pain! I had some <ol> nested in a <p> and I wasn't even caring that it's not W3C valid. Apparently during hydration (i.e. given JS dynamic client-side life to the initial server-rendered HTML) things get pretty strict and if the output of the two renders don't line up, hydration is bailed. It caused certain pages of my project to be totally unresponsive in production and took me hours to debug and find that! (Also, Chrome was freezing. Also, having HTML comments in <p> tags caused the same issue.)

Other challenge(s):

  1. It's more of a risk than a challenge -- since it has only one developer and hasn't received much traction so far, I guess, there is a risk associated with its maintenance. I totally understand that a single developer cannot afford to maintain and develop an open-source project without community help and that's probably why the project hasn't had any commit in the last 3 months. It's not even listed in the Community Extensions of Quasar doc (yet), and googling for it, I found there are other efforts to build similar tool like quasar-ssg-helper. So there is a natural risk of it getting obsolete and not maintained in the future (which I truly hope doesn't happen!) probably unless the Quasar team officially backs it up as one of the official modes.

My Conclusion

I am going to keep working with Quasar SSG for now. Thanks to @freddy38510 for making it, thanks @IlCallo for your comments, and thanks to Quasar team and community for building such an awesome framework and maintaining it!

Thanks for sharing!
Only a note:

Again, I needed to move them to only happen in Client-Side

Remember you can decide to run boot files only on client, on server or both directly from quasar.conf.js, by providing an object instead of a string, eg:

boot: [
  'i18n',
  {
    server: false,
    path: 'smooth-scroll',
  },
],
h3 commented

It seems ssrUpdate has been removed from Quasar v2.. can someone point me how this can be done in v2?

src/boot/ssg.js

import { Quasar } from 'quasar';
const { ssrUpdate } = Quasar;

export default ({ app }) => {
  ssrUpdate({ app });
};