Node.js & Express

Server-Side Rendering with Node.js

35 min Lesson 31 of 40

Introduction to Server-Side Rendering

Server-Side Rendering (SSR) is a technique where HTML content is generated on the server before being sent to the client, rather than rendering everything in the browser using JavaScript. This approach offers significant benefits for performance, SEO, and user experience.

Key Difference: In Client-Side Rendering (CSR), the browser receives a minimal HTML shell and JavaScript that builds the page. In SSR, the server sends fully-rendered HTML that's immediately visible to users and search engines.

Why Use Server-Side Rendering?

SSR provides several advantages:

  • Improved SEO: Search engines can easily crawl and index fully-rendered HTML content
  • Faster Initial Load: Users see content immediately without waiting for JavaScript to execute
  • Better Performance on Slow Devices: Less client-side processing required
  • Social Media Sharing: Meta tags and Open Graph data are immediately available
  • Progressive Enhancement: Content is accessible even if JavaScript fails

Trade-offs: SSR increases server load, requires more complex deployment, and can be more challenging to implement than pure client-side rendering.

Basic SSR with Node.js

Let's implement a simple SSR system using Express and template engines:

// app.js - Basic SSR with EJS
const express = require('express');
const app = express();

// Set view engine
app.set('view engine', 'ejs');
app.set('views', './views');

// Simulate data fetching
async function getUserData(userId) {
  // In production, this would be a database query
  return {
    id: userId,
    name: 'John Doe',
    email: 'john@example.com',
    posts: [
      { id: 1, title: 'First Post', content: 'Hello World' },
      { id: 2, title: 'Second Post', content: 'Learning SSR' }
    ]
  };
}

// SSR route
app.get('/user/:id', async (req, res) => {
  try {
    const userId = req.params.id;
    const userData = await getUserData(userId);

    // Render HTML on server
    res.render('user', {
      user: userData,
      pageTitle: \`Profile - ${userData.name}\`,
      timestamp: new Date().toISOString()
    });
  } catch (error) {
    console.error('SSR Error:', error);
    res.status(500).send('Server Error');
  }
});

app.listen(3000, () => {
  console.log('SSR server running on port 3000');
});

Create the EJS template (views/user.ejs):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title><%= pageTitle %></title>
  <meta name="description" content="Profile page for <%= user.name %>">
  <style>
    body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
    .post { border: 1px solid #ddd; padding: 15px; margin: 10px 0; }
  </style>
</head>
<body>
  <h1>User Profile</h1>

  <div class="user-info">
    <h2><%= user.name %></h2>
    <p>Email: <%= user.email %></p>
    <p>User ID: <%= user.id %></p>
  </div>

  <h3>Posts</h3>
  <% user.posts.forEach(post => { %>
    <div class="post">
      <h4><%= post.title %></h4>
      <p><%= post.content %></p>
    </div>
  <% }); %>

  <footer>
    <small>Rendered at: <%= timestamp %></small>
  </footer>
</body>
</html>

React Server-Side Rendering

React provides built-in support for SSR through the ReactDOMServer API:

// server.js - React SSR
const express = require('express');
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const App = require('./App');

const app = express();

// Serve static files
app.use(express.static('public'));

// Component to render
const UserProfile = ({ user }) => (
  React.createElement('div', null,
    React.createElement('h1', null, user.name),
    React.createElement('p', null, `Email: ${user.email}`),
    React.createElement('div', null,
      React.createElement('h2', null, 'Posts'),
      user.posts.map(post =>
        React.createElement('div', { key: post.id, className: 'post' },
          React.createElement('h3', null, post.title),
          React.createElement('p', null, post.content)
        )
      )
    )
  )
);

app.get('/user/:id', async (req, res) => {
  const userData = await getUserData(req.params.id);

  // Render React component to string
  const html = ReactDOMServer.renderToString(
    React.createElement(UserProfile, { user: userData })
  );

  // Send complete HTML
  res.send(`
    <!DOCTYPE html>
    <html>
    <head>
      <title>${userData.name} - Profile</title>
      <link rel="stylesheet" href="/styles.css">
    </head>
    <body>
      <div id="root">${html}</div>
      <script src="/bundle.js"></script>
    </body>
    </html>
  `);
});

Pro Tip: Use renderToStaticMarkup() instead of renderToString() if you don't need client-side hydration - it generates smaller HTML without React-specific attributes.

Hydration in React SSR

Hydration is the process where React "attaches" to server-rendered HTML, making it interactive:

// client.js - Hydration
import React from 'react';
import ReactDOM from 'react-dom';
import UserProfile from './UserProfile';

// Get initial data from server
const initialData = window.__INITIAL_DATA__;

// Hydrate instead of render
ReactDOM.hydrate(
  <UserProfile user={initialData} />,
  document.getElementById('root')
);

// Server-side: inject data into HTML
app.get('/user/:id', async (req, res) => {
  const userData = await getUserData(req.params.id);
  const html = ReactDOMServer.renderToString(
    <UserProfile user={userData} />
  );

  res.send(`
    <!DOCTYPE html>
    <html>
    <head>
      <title>${userData.name}</title>
    </head>
    <body>
      <div id="root">${html}</div>
      <script>
        window.__INITIAL_DATA__ = ${JSON.stringify(userData).replace(/</g, '\\u003c')};
      </script>
      <script src="/bundle.js"></script>
    </body>
    </html>
  `);
});

Security: Always sanitize data before injecting into HTML. Replace < with \u003c to prevent XSS attacks when embedding JSON in script tags.

Introduction to Next.js

Next.js is a React framework that makes SSR simple with built-in features:

// Installation
npm install next react react-dom

// package.json scripts
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  }
}

// pages/user/[id].js - Next.js page with SSR
export async function getServerSideProps(context) {
  const { id } = context.params;

  // Fetch data on server
  const res = await fetch(`https://api.example.com/users/${id}`);
  const user = await res.json();

  return {
    props: { user } // Passed to component
  };
}

export default function UserProfile({ user }) {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>

      <h2>Posts</h2>
      {user.posts.map(post => (
        <div key={post.id} className="post">
          <h3>{post.title}</h3>
          <p>{post.content}</p>
        </div>
      ))}
    </div>
  );
}

Next.js Rendering Strategies

Next.js supports multiple rendering strategies:

// 1. Server-Side Rendering (SSR) - on every request
export async function getServerSideProps(context) {
  const data = await fetchData();
  return { props: { data } };
}

// 2. Static Site Generation (SSG) - at build time
export async function getStaticProps() {
  const data = await fetchData();
  return {
    props: { data },
    revalidate: 60 // Incremental Static Regeneration
  };
}

// 3. Static with dynamic routes
export async function getStaticPaths() {
  const users = await fetchUsers();
  const paths = users.map(user => ({
    params: { id: user.id.toString() }
  }));

  return {
    paths,
    fallback: 'blocking' // or true, false
  };
}

export async function getStaticProps({ params }) {
  const user = await fetchUser(params.id);
  return { props: { user } };
}

// 4. Client-Side Rendering (CSR)
import { useEffect, useState } from 'react';

export default function Dashboard() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('/api/dashboard')
      .then(res => res.json())
      .then(setData);
  }, []);

  if (!data) return <div>Loading...</div>;
  return <div>{data.content}</div>;
}

Choosing a Strategy: Use SSR for personalized content, SSG for static content, ISR for frequently-updated static content, and CSR for private/user-specific data.

Performance Optimization in SSR

Optimize SSR performance with these techniques:

// 1. Streaming SSR (React 18+)
import { renderToPipeableStream } from 'react-dom/server';

app.get('/user/:id', async (req, res) => {
  const userData = await getUserData(req.params.id);

  res.setHeader('Content-Type', 'text/html');

  const { pipe } = renderToPipeableStream(
    <UserProfile user={userData} />,
    {
      onShellReady() {
        res.statusCode = 200;
        pipe(res);
      },
      onError(err) {
        console.error(err);
        res.statusCode = 500;
      }
    }
  );
});

// 2. Component-level caching
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 300 });

async function getCachedUser(userId) {
  const cacheKey = `user_${userId}`;

  // Check cache
  let user = cache.get(cacheKey);
  if (user) {
    console.log('Cache hit');
    return user;
  }

  // Fetch and cache
  user = await fetchUserFromDB(userId);
  cache.set(cacheKey, user);
  return user;
}

// 3. Selective hydration (React 18)
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <Header /> {/* Hydrates immediately */}

      <Suspense fallback={<Spinner />}>
        <HeavyComponent /> {/* Hydrates when ready */}
      </Suspense>

      <Footer /> {/* Hydrates before HeavyComponent */}
    </div>
  );
}

SSR with Data Fetching

Handle data fetching efficiently in SSR:

// Parallel data fetching
export async function getServerSideProps({ params }) {
  const [user, posts, comments] = await Promise.all([
    fetchUser(params.id),
    fetchUserPosts(params.id),
    fetchUserComments(params.id)
  ]);

  return {
    props: { user, posts, comments }
  };
}

// Error handling
export async function getServerSideProps({ params }) {
  try {
    const user = await fetchUser(params.id);

    if (!user) {
      return { notFound: true };
    }

    return { props: { user } };
  } catch (error) {
    console.error('SSR Error:', error);
    return {
      props: { error: 'Failed to load user' }
    };
  }
}

// Redirects
export async function getServerSideProps({ req }) {
  const session = await getSession(req);

  if (!session) {
    return {
      redirect: {
        destination: '/login',
        permanent: false
      }
    };
  }

  return { props: { session } };
}

SSR Debugging and Monitoring

Debug and monitor SSR applications:

// Performance monitoring
const perfMonitor = (componentName) => {
  return async (req, res, next) => {
    const start = Date.now();

    res.on('finish', () => {
      const duration = Date.now() - start;
      console.log(`${componentName} SSR: ${duration}ms`);

      if (duration > 1000) {
        console.warn(`Slow SSR detected for ${componentName}`);
      }
    });

    next();
  };
};

app.use('/user/:id', perfMonitor('UserProfile'));

// Error boundary for SSR
class SSRErrorBoundary extends React.Component {
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  render() {
    if (this.state?.hasError) {
      return (
        <div>
          <h1>Something went wrong</h1>
          <p>{this.state.error.message}</p>
        </div>
      );
    }

    return this.props.children;
  }
}

// Use in SSR
const html = ReactDOMServer.renderToString(
  <SSRErrorBoundary>
    <App />
  </SSRErrorBoundary>
);

Exercise: Build an SSR Blog

Create a server-side rendered blog with the following features:

  • Homepage listing all blog posts (SSR with caching)
  • Individual post pages (SSR with hydration)
  • Comment system (client-side only)
  • SEO meta tags for each page
  • Performance monitoring

Implement using either vanilla React SSR or Next.js. Include error handling and loading states.