Skip to page content
Quasar CLI with Vite - @quasar/app-vite v3

SSR Webserver

Notice that your generated /src-ssr contains a file named server.js. This file defines how your SSR webserver is created, managed and served. You can start listening to a port or provide a handler for your serverless infrastructure to use. It’s up to you.

Anatomy

The /src-ssr/server.js file is a simple JavaScript/TypeScript file which boots up your SSR webserver and defines what how your webserver starts & handles requests and what it exports (if exporting anything).

WARNING

The /src-ssr/server.js file is used for both DEV and PROD, so please be careful on how you configure it. To differentiate between the two states you can use import.meta.env.QUASAR_DEV and import.meta.env.QUASAR_PROD.

For Hono, set public cache headers only in serveStatic’s onFound callback, as shown below. The maxAge option is in milliseconds; the HTTP max-age value is in seconds. Requests that fall through to SSR must keep the application’s own cache policy.

Now let’s see what it contains, for JS projects first and then for TypeScript. Pick the one you want to use based on the webserver of your choice:

Javascript
/src-ssr/server.js

/**
 * Runs in Node.js context.
 *
 * Make sure to pnpm/yarn/npm/bun install (in /src-ssr folder)
 * anything you import here.
 */

import { lstatSync } from 'node:fs'
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import {
  defineSsrClose,
  defineSsrCreate,
  defineSsrInjectDevMiddleware,
  defineSsrListen,
  defineSsrRenderPreloadTag,
  defineSsrServeStaticContent
} from '#q-app'

/**
 * Create your webserver and return its instance.
 */
export const create = defineSsrCreate(async (/* { ... } */) => {
  const app = new Hono()

  if (import.meta.env.QUASAR_PROD) {
    const { compress } = await import('hono/compress')
    app.use(compress())
  }

  return app
})

/**
 * Used by Quasar SSR dev server to inject middleware into the webserver.
 * It uses it to handle Vite dev server, handle public paths, etc.
 * The given middleware is compatible with `node:http`'s Server, Express, Connect, etc.
 *
 * Can be async: defineSsrInjectDevMiddleware(async ({ app }) => { ... })
 */
export const injectDevMiddleware = defineSsrInjectDevMiddleware(
  ({ app }) =>
    middleware => {
      app.use('*', async (c, next) => {
        const req = c.env.incoming
        const res = c.env.outgoing

        const { promise, resolve, reject } = Promise.withResolvers()

        const onDone = () => resolve(false)
        res.once('finish', onDone)
        res.once('close', onDone)

        middleware(req, res, err => {
          res.off('finish', onDone)
          res.off('close', onDone)

          if (err) reject(err)
          else resolve(true)
        })

        const passed = await promise

        if (passed) {
          /**
           * Vite skipped the request, so we let Hono continue down the chain
           */
          return next()
        }

        /**
         * Vite handled the request natively!
         *
         * Monkey-patch the native Node.js response methods.
         * The Hono Node adapter will still try to write headers and end the stream
         * when we return the dummy response. We neutralize these methods
         * so it silently does nothing instead of crashing.
         */
        const noop = () => res
        res.writeHead = noop
        res.setHeader = noop
        res.end = noop

        /**
         * Return a dummy Response.
         * This satisfies Hono's strict requirement that every branch
         * either returns a Response or calls `await next()`.
         */
        return new Response(null)
      })
    }
)

/**
 * You need to make the server listen to the indicated port
 * and return the listening instance or whatever you need to
 * close the server with.
 *
 * The "listenResult" param for the "close()" definition below
 * is what you return here.
 *
 * For production, you can instead export your
 * handler for serverless use or whatever else fits your needs.
 */
export const listen = defineSsrListen(
  async ({ app, devHttpsOptions, port }) => {
    const opts = {
      fetch: app.fetch,
      port
    }

    /**
     * For production HTTPS you can use the /src-ssr/server-assets folder
     * to place your certificates and then read them here to create the server.
     *
     * Use resolve.serverAssets('path-to-file') to get the absolute path to the file
     * or directly play with folders.serverAssets.
     */

    if (import.meta.env.QUASAR_DEV && devHttpsOptions) {
      const { createServer } = await import('node:https')
      opts.createServer = createServer
      opts.serverOptions = { ...devHttpsOptions }
    } else {
      const { createServer } = await import('node:http')
      opts.createServer = createServer
    }

    return serve(opts, info => {
      if (import.meta.env.QUASAR_PROD) {
        console.log(`🚀 Server listening at port ${info.port}`)
      }
    })
  }
)

/**
 * Should close the server and free up any resources.
 * Will be used on development only when the server needs
 * to be rebooted.
 *
 * Should you need the result of the "listen()" call above,
 * you can use the "listenResult" param.
 *
 * Can be async: defineSsrClose(async ({ listenResult }) => { ... })
 */
export const close = defineSsrClose(({ listenResult }) => listenResult.close())

const maxAge = import.meta.env.QUASAR_DEV ? 0 : 1000 * 60 * 60 * 24 * 30

/**
 * Should return a function that will be used to configure the webserver
 * to serve static content at "urlPath" from "pathToServe" folder/file.
 *
 * Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
 *
 * Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
 * Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
 */
export const serveStaticContent = defineSsrServeStaticContent(
  ({ app, resolve, publicPath }) =>
    ({ urlPath, pathToServe, opts = {} }) => {
      const pubPath = resolve.public(pathToServe)
      const isDir = lstatSync(pubPath).isDirectory()

      const resolvedUrlPath = resolve.urlPath(urlPath)
      const routePath = isDir
        ? resolvedUrlPath.endsWith('*')
          ? resolvedUrlPath
          : `${resolvedUrlPath}*`
        : resolvedUrlPath

      const { maxAge: localMaxAge, ...serveOpts } = opts
      const cacheAge = localMaxAge ?? maxAge

      if (cacheAge > 0) {
        const { onFound } = serveOpts
        const cacheControlValue = `public, max-age=${Math.floor(cacheAge / 1000)}`
        serveOpts.onFound = async (path, c) => {
          c.header('Cache-Control', cacheControlValue)
          await onFound?.(path, c)
        }
      }

      if (publicPath !== '/') {
        serveOpts.rewriteRequestPath = p => p.replace(publicPath, '/')
      }

      app.use(
        routePath,
        serveStatic({
          [isDir ? 'root' : 'path']: pubPath,
          ...serveOpts
        })
      )
    }
)

const jsRE = /\.js$/
const cssRE = /\.css$/
const woffRE = /\.woff$/
const woff2RE = /\.woff2$/
const gifRE = /\.gif$/
const jpgRE = /\.jpe?g$/
const pngRE = /\.png$/

/**
 * Should return a String with HTML output
 * (if any) for preloading indicated file
 */
export const renderPreloadTag = defineSsrRenderPreloadTag(
  (file /* , { ssrContext } */) => {
    if (jsRE.test(file)) {
      return `<link rel="modulepreload" href="${file}" crossorigin>`
    }

    if (cssRE.test(file)) {
      return `<link rel="stylesheet" href="${file}" crossorigin>`
    }

    if (woffRE.test(file)) {
      return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
    }

    if (woff2RE.test(file)) {
      return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
    }

    if (gifRE.test(file)) {
      return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`
    }

    if (jpgRE.test(file)) {
      return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`
    }

    if (pngRE.test(file)) {
      return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
    }

    return ''
  }
)
TypeScript
/src-ssr/server.ts

/**
 * Runs in Node.js context.
 *
 * Make sure to pnpm/yarn/npm/bun install (in /src-ssr folder)
 * anything you import here.
 */

import { lstatSync } from "node:fs";
import { Hono } from "hono";
import type { IncomingMessage, ServerResponse } from "node:http";
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import {
  defineSsrClose,
  defineSsrCreate,
  defineSsrInjectDevMiddleware,
  defineSsrListen,
  defineSsrRenderPreloadTag,
  defineSsrServeStaticContent
} from "#q-app";

interface NodeEnv {
  Bindings: {
    incoming: IncomingMessage;
    outgoing: ServerResponse;
  };
}

declare module "#q-app" {
  interface SsrDriver {
    app: Hono<NodeEnv>;
    listenResult: ReturnType<typeof serve>;
    request: IncomingMessage;
    response: ServerResponse;
  }
}

/**
 * Create your webserver and return its instance.
 */
export const create = defineSsrCreate(async (/* { ... } */) => {
  const app = new Hono<NodeEnv>();

  if (import.meta.env.QUASAR_PROD) {
    const { compress } = await import("hono/compress");
    app.use(compress());
  }

  return app;
});

/**
 * Used by Quasar SSR dev server to inject middleware into the webserver.
 * It uses it to handle Vite dev server, handle public paths, etc.
 * The given middleware is compatible with `node:http`'s Server, Express, Connect, etc.
 *
 * Can be async: defineSsrInjectDevMiddleware(async ({ app }) => { ... })
 */
export const injectDevMiddleware = defineSsrInjectDevMiddleware(
  ({ app }) =>
    middleware => {
      app.use("*", async (c, next) => {
        const req = c.env.incoming;
        const res = c.env.outgoing;

        const { promise, resolve, reject } = Promise.withResolvers<boolean>();

        const onDone = () => {
          resolve(false);
        };
        res.once("finish", onDone);
        res.once("close", onDone);

        middleware(req, res, err => {
          res.off("finish", onDone);
          res.off("close", onDone);

          if (err) reject(err);
          else resolve(true);
        });

        const passed: boolean = await promise;

        if (passed) {
          /**
           * Vite skipped the request, so we let Hono continue down the chain
           */
          return next();
        }

        /**
         * Vite handled the request natively!
         *
         * Monkey-patch the native Node.js response methods.
         * The Hono Node adapter will still try to write headers and end the stream
         * when we return the dummy response. We neutralize these methods
         * so it silently does nothing instead of crashing.
         */
        const noop = () => res;
        res.writeHead = noop;
        res.setHeader = noop;
        res.end = noop;

        /**
         * Return a dummy Response.
         * This satisfies Hono's strict requirement that every branch
         * either returns a Response or calls `await next()`.
         */
        return new Response(null);
      });
    }
);

/**
 * You need to make the server listen to the indicated port
 * and return the listening instance or whatever you need to
 * close the server with.
 *
 * The "listenResult" param for the "close()" definition below
 * is what you return here.
 *
 * For production, you can instead export your
 * handler for serverless use or whatever else fits your needs.
 */
export const listen = defineSsrListen(
  async ({ app, devHttpsOptions, port }) => {
    const opts: Parameters<typeof serve>[0] = {
      fetch: app.fetch,
      port
    };

    /**
     * For production HTTPS you can use the /src-ssr/server-assets folder
     * to place your certificates and then read them here to create the server.
     *
     * Use resolve.serverAssets('path-to-file') to get the absolute path to the file
     * or directly play with folders.serverAssets.
     */

    if (import.meta.env.QUASAR_DEV && devHttpsOptions) {
      const { createServer } = await import("node:https");
      opts.createServer = createServer;
      opts.serverOptions = { ...devHttpsOptions };
    } else {
      const { createServer } = await import("node:http");
      opts.createServer = createServer;
    }

    return serve(opts, info => {
      if (import.meta.env.QUASAR_PROD) {
        console.log(`🚀 Server listening at port ${info.port}`);
      }
    });
  }
);

/**
 * Should close the server and free up any resources.
 * Will be used on development only when the server needs
 * to be rebooted.
 *
 * Should you need the result of the "listen()" call above,
 * you can use the "listenResult" param.
 *
 * Can be async: defineSsrClose(async ({ listenResult }) => { ... })
 */
export const close = defineSsrClose(({ listenResult }) => listenResult.close());

const maxAge = import.meta.env.QUASAR_DEV ? 0 : 1000 * 60 * 60 * 24 * 30;

/**
 * Should return a function that will be used to configure the webserver
 * to serve static content at "urlPath" from "pathToServe" folder/file.
 *
 * Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
 *
 * Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
 * Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
 */
export const serveStaticContent = defineSsrServeStaticContent(
  ({ app, resolve, publicPath }) =>
    ({ urlPath, pathToServe, opts = {} }) => {
      const pubPath = resolve.public(pathToServe);
      const isDir = lstatSync(pubPath).isDirectory();

      const resolvedUrlPath = resolve.urlPath(urlPath);
      const routePath = isDir
        ? resolvedUrlPath.endsWith("*")
          ? resolvedUrlPath
          : `${resolvedUrlPath}*`
        : resolvedUrlPath;

      const { maxAge: maxAgeOpt, ...serveOpts } = opts;
      const cacheAge = maxAgeOpt !== void 0 ? maxAgeOpt : maxAge;

      const staticOpts: Parameters<typeof serveStatic>[0] = { ...serveOpts };
      if (cacheAge > 0) {
        const { onFound } = staticOpts;
        const cacheControlValue = `public, max-age=${Math.floor(cacheAge / 1000)}`;
        staticOpts.onFound = async (path, c) => {
          c.header("Cache-Control", cacheControlValue);
          await onFound?.(path, c);
        };
      }

      if (isDir) {
        staticOpts.root = pubPath;
      } else {
        staticOpts.path = pubPath;
      }

      if (publicPath !== "/") {
        staticOpts.rewriteRequestPath = p => p.replace(publicPath, "/");
      }

      app.use(routePath, serveStatic(staticOpts));
    }
);

const jsRE = /\.js$/;
const cssRE = /\.css$/;
const woffRE = /\.woff$/;
const woff2RE = /\.woff2$/;
const gifRE = /\.gif$/;
const jpgRE = /\.jpe?g$/;
const pngRE = /\.png$/;

/**
 * Should return a String with HTML output
 * (if any) for preloading indicated file
 */
export const renderPreloadTag = defineSsrRenderPreloadTag(
  (file /* , { ssrContext } */) => {
    if (jsRE.test(file)) {
      return `<link rel="modulepreload" href="${file}" crossorigin>`;
    }

    if (cssRE.test(file)) {
      return `<link rel="stylesheet" href="${file}" crossorigin>`;
    }

    if (woffRE.test(file)) {
      return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`;
    }

    if (woff2RE.test(file)) {
      return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`;
    }

    if (gifRE.test(file)) {
      return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`;
    }

    if (jpgRE.test(file)) {
      return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`;
    }

    if (pngRE.test(file)) {
      return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`;
    }

    return "";
  }
);

Serverless

When deploying a Quasar SSR application to a serverless architecture, you face one main architectural shift: Serverless environments do not support long-running processes.

Normally, Quasar’s SSR build spins up a Node.js webserver that listens on a specific port (e.g., app.listen(3000)). In a serverless environment, you must bypass this listening phase. Instead, your entry script (that gets built by Quasar CLI) must export a stateless request handler function that the serverless provider can invoke every time an HTTP request comes in.

Since the built SSR server is essentially a Hono/Express/Fastify/etc application under the hood, your goal is to export it in a format your specific cloud provider understands.

We will use the /quasar.config > ssr > prodScriptNamedExport property to configure what gets exported by the production generated dist/index.js file:

/**
 * The named exports to use for the production generated SSR index.js script.
 * Works with `false` (no named exports), a single string (one named export),
 * or an array of strings (multiple named exports).
 *
 * Useful for serverless environments where you might want to export the
 * handler function. It creates one or more named exports from the
 * object returned by the defineSsrListen() function in /src-ssr/server file.
 *
 * @default false
 *
 * @example
 * prodScriptNamedExport: ['handler', 'ssr']
 * export const listen = defineSsrListen(() => {
 *   if (import.meta.env.QUASAR_PROD) {
 *     return { handler, ssr }
 *   }
 * })
 *
 * This will generate an SSR index.js with the following exports:
 * const { handler, ssr } = await listen({...})
 * export { handler, ssr }
 *
 * @example
 * prodScriptNamedExport: 'default'
 * export const listen = defineSsrListen(({ app }) => {
 *   if (import.meta.env.QUASAR_PROD) {
 *     return { default: app }
 *   }
 * })
 *
 * This will generate an SSR index.js with the following exports:
 * const listenResult = await listen({...})
 * export default listenResult?.default
 *
 * @example
 * prodScriptNamedExport: 'app'
 * export const listen = defineSsrListen(({ app }) => {
 *   if (import.meta.env.QUASAR_PROD) {
 *     return { app }
 *   }
 * })
 *
 * This will generate an SSR index.js with the following exports:
 * const { app } = await listen({...})
 * export { app }
 *
 * @example 'renderSsrContext' (special case)
 *
 * This will generate an SSR index.js with the following export:
 *   export { render as renderSsrContext }
 * where "render" is the same function used in
 * the /src-ssr/middlewares/render file
 */
prodScriptNamedExport?: false | string | string[];

Below are examples for some of the major serverless suppliers:

AWS Lambda (via Serverless Framework or AWS SAM)

AWS Lambda expects a handler function with an (event, context) signature. Because Quasar outputs a Node.js webserver app, you can’t pass this directly to Lambda. You need a wrapper library like serverless-http to bridge the gap between Lambda’s event object and the webserver request/response objects.

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    const { default: serverless } = await import('serverless-http')
    return {
      // Example with Express.js;
      // Adapt to your chosen webserver
      handler: serverless(app)
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'handler'
}

Remember to install serverless-http in /src-ssr as “dependencies” (and NOT “devDependencies”).

Firebase Cloud Functions

Firebase Functions are built on top of Google Cloud Functions.

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    const { default: functions } = await import('firebase-functions')
    return {
      // Example with Express.js;
      // Adapt to your chosen webserver
      ssr: functions.https.onRequest(app)
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'ssr'
}

Remember to install firebase-functions in /src-ssr as “dependencies” (and NOT “devDependencies”).

Vercel

Vercel’s Node.js runtime natively understands standard Node HTTP request listeners (functions that take req and res parameters).

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    return {
      // Example with Express.js;
      // Adapt to your chosen webserver
      default: app
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'default'
}

Netlify Functions

Netlify Functions operate similarly to AWS Lambda (they are powered by AWS Lambda under the hood). Like AWS, you will need serverless-http to wrap your app.

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    const { default: serverless } = await import('serverless-http')
    return {
      // Example with Express.js;
      // Adapt to your chosen webserver
      handler: serverless(app)
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'handler'
}

Remember to install serverless-http in /src-ssr as “dependencies” (and NOT “devDependencies”).

Azure Functions

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    // Example with Express.js;
    // Adapt to your chosen webserver
    const { createHandler } = await import('azure-function-express')
    return {
      default: createHandler(app)
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'default'
}

Remember to install azure-function-express in /src-ssr as “dependencies” (and NOT “devDependencies”).

DigitalOcean Functions

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    const { default: serverless } = await import('serverless-http')
    return {
      // Example with Express.js;
      // Adapt to your chosen webserver
      main: serverless(app)
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'main'
}

Remember to install serverless-http in /src-ssr as “dependencies” (and NOT “devDependencies”).

Tencent Cloud

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    const { default: serverless } = await import('serverless-http')
    return {
      // Example with Express.js;
      // Adapt to your chosen webserver
      main_handler: serverless(app)
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'main_handler'
}

Remember to install serverless-http in /src-ssr as “dependencies” (and NOT “devDependencies”).