Building a Server-Side Rendered (SSR) App with Vite

Building a Server-Side Rendered (SSR) App with Vite
Photo by Artboard Studio / Unsplash

Building a Server-Side Rendered (SSR) app involves orchestrating three main components: the frontend app, the SSR render entry, and the server to serve assets and handle backend APIs. In this guide, we'll leverage Vite for the frontend, Node.js and Express for the server, and demonstrate a streamlined development workflow using Nx.

Prerequisites

  • Node.js and npm installed
  • Basic understanding of JavaScript, React.js (or your preferred frontend framework), and Express.js

Project Structure

Before diving into the setup, let's outline the project structure:

frontend app src/main.tsx
SSR src/main.server.tsx
backend app src/server.ts

3 vite configs:

vite.config.ts for frontend
vite.config.ssr.ts for SSR
vite.config.server.ts for backend

project.json: define build targets

you can checkout the full example from github.

GitHub - lengerrong/nx-vite-ssr-react-app
Contribute to lengerrong/nx-vite-ssr-react-app development by creating an account on GitHub.

vite.config.ts fronend app config

just a normal Vite default config.
export default defineConfig({
plugins: [
react(),  // import your frontend framework plugin
nxViteTsPaths(),
],
});

vite.config.ssr.ts SSR config

export default defineConfig({
plugins: [
react(),  //yes, we need the same plugin here
nxViteTsPaths(),
],

build: {
target: "esnext",
rollupOptions: {
external: ["fsevents", "esbuild", "vite", "fs", "path", "https", "http", "tls", "net", "zlib", "stream", "tty", "os", "crypto", "util"],
output: {
dynamicImportInCjs: true
}
},
copyPublicDir: false, // not copy public assets
minify: false
},

optimizeDeps: {
esbuildOptions: {
target: "esnext"
},
force: true
},

ssr: {
// for production standalone deployment. bundle everything except the above external
// so that await import main.server.mjs no need any dpes under node_modules
noExternal: process.env["NX_TASK_TARGET_CONFIGURATION"] === "production" ? true : undefined
}
});

vite.config.server.ts backend server config

same as vite.config.ssr.ts, but remove frontend plugin. Perhaps more externals need to specify not bundle for production build.

Project Build Structure: project.json

production build, depends on 3 parts

"build": {
  "dependsOn": ["browser-build", "ssr-build", "server-build"],
  "executor": "nx:run-commands",
  "options": {
    "commands": [{
      "command":"echo 'build done'"
    }]
  }
},

browser build

"browser-build": {
  "executor": "@nx/vite:build",
  "outputs": ["{options.outputPath}"],
  "defaultConfiguration": "production",
  "options": {
    "outputPath": "dist/apps/activate/browser"
  },
  "configurations": {
    "development": {
      "mode": "development"
    },
    "production": {
      "mode": "production"
    }
  }
},

SSR build: use ssr and specify configFile
"emptyOutDir" needs set to false, since we are using the same output path for backend entry.

"ssr-build": {
  "executor": "@nx/vite:build",
  "outputs": ["{options.outputPath}"],
  "defaultConfiguration": "production",
  "options": {
    "emptyOutDir": false,
    "outputPath": "dist/apps/activate/server",
    "ssr": "src/main.server.tsx",
    "configFile": "apps/activate/vite.config.ssr.ts"
  },
  "configurations": {
    "development": {
      "mode": "development"
    },
    "production": {
      "mode": "production"
    }
  }
},

Backend Build

similar to SSR, use different configFile. We also need to specify the "outputFileName" as well, otherwise you will get an error while work with @nx/js:node executor.

    "server-build": {
      "executor": "@nx/vite:build",
      "outputs": ["{options.outputPath}"],
      "defaultConfiguration": "production",
      "options": {
        "outputPath": "dist/apps/activate/server",
        
        "ssr": "src/server.ts",
        "configFile": "apps/activate/vite.config.server.ts",
        "emptyOutDir": false,
        "outputFileName": "server.mjs"
      },
      "configurations": {
        "development": {
          "mode": "development"
        },
        "production": {
          "mode": "production"
        }
      }
    },

Server.ts

Set SSR with Vite, please refer to https://vitejs.dev/guide/ssr. This server.ts is doing exactly same follow the doc.

  app.use('*', async (req, res) => {
    let template;
    if (process.env["NODE_ENV"] === "production") {
      template = fs.readFileSync(path.resolve(fileURLToPath(import.meta.url), '../../browser/index.html')).toString();
      const { ssrRender } = await import(path.resolve(fileURLToPath(import.meta.url), '../main.server.mjs'));
      ssrRender(template, req, res);
      res.status(200);
    } else {
      const url = req.originalUrl
      // always read fresh template in dev
      template = fs.readFileSync(path.resolve(vite.config.root, './index.html'), 'utf-8')
      template = await vite.transformIndexHtml(url, template)
      const { ssrRender } = await vite.ssrLoadModule('/src/main.server.tsx');
      ssrRender(template, req, res);
    }
  })

Serve

Let's server our SSR app.

"serve": {
      "executor": "@nx/js:node",
      "defaultConfiguration": "development",
      "options": {
        "buildTarget": "activate:build"
      },
      "configurations": {
        "development": {
          "buildTarget": "activate:server-build:development"
        },
        "production": {
          "buildTarget": "activate:server-build:production",
          "waitUntilTargets": ["activate:browser-build:production", "activate:ssr-build:production"]
        }
      }
    },
As you can observe, for development, you just need compile one file: server.ts. The server just started in less than 50ms. All the frontend part will be take over by Vite Dev Server and provide fast build, HMR and more.

Conclusion

Congratulations! You've successfully set up a Server-Side Rendered (SSR) app with Vite and Express, creating a powerful and efficient web application with benefits on both the frontend and backend.

By leveraging Vite's capabilities for rapid frontend development, you've harnessed features like Hot Module Replacement (HMR) for instantaneous updates during development. Combining this with an Express server allows you to achieve server-side rendering, providing improved performance and search engine optimization.

The use of Nx has streamlined your development workflow, enabling watch mode for both frontend and backend applications. This ensures that any changes made to your code trigger automatic rebuilds, enhancing productivity and reducing development time.

As you move forward, consider exploring additional features and optimizations for your SSR app, such as:

  • Optimizing SSR Render Entry: Fine-tune the SSR render entry point for performance improvements and better server-side rendering results.
  • Middleware and API Enhancements: Extend your Express server with middleware for additional functionalities and enhance your backend APIs to meet the requirements of your application.
  • Testing and Deployment Strategies: Implement thorough testing procedures for both the frontend and backend components. Explore deployment options, such as containerization or serverless architectures, based on your application's needs.

Remember to refer to official documentation and community resources for ongoing support and updates on the tools and frameworks you've used in this setup.

Now, with a robust SSR app in place, you're well-equipped to deliver a seamless user experience while taking advantage of the best features that Vite and Express have to offer. Happy coding!

Subscribe to Post, Code and Quiet Time.

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe