Pup

v2.x

ExtrasServer Side Rendering

Traditionally, applications based on the Meteor JavaScript platform only support client-side rendering. This means that when a user visits our application, instead of pages being built on the server first, the templates that comprise those pages are sent to the client and then the pages are built on the client and displayed to the user.

Contrasting this with server-side rendering, when a user visits our application, pages are fully built (meaning, the HTML, CSS, and data are combined) on the server first and then sent to the client.

See SSR in action

To see the "what" of SSR in action, load up the app and then right-click and select "View Source." The HTML you see rendered here is exactly what search engine's will see when they visit your app.

While visually there's not much of a difference for the user—this helps us to omit the loading indicator on the client but is otherwise visually identical—introducing server-side rendering can significantly improve how an application is indexed by Google and other search engines. In some situations, too, server-side rendering can improve the page load time as well.

As of Meteor 1.5.1, the ability to implement server-side rendering has been made possible via the server-render package. In Pup, we've fully integrated this package making server-side rendering the default behavior.

Thanks to the usage of Apollo for providing data from our GraphQL schema to our components, SSR requires zero-configuration in Pup. Static pages and pages with dynamic data will automatically be server-side rendered as expected. The only exception to this rule is that pages that are rendered using the Authenticated or Authorized components will block the initial SSR request, so only the navigation elements of those pages will be SSR'd (this is just how SSR works, not a limitation of Pup).

Disabling SSR

As of right now, SSR in Pup is all or nothing. If your app doesn't need SSR support, you can delete the /startup/server/ssr.js file and its import in /startup/server/index.js. Support for conditional SSR routes is planned.

Usage of Meteor.isClient may be required

In order for SSR to work, we use the same exact components and routes that are used on the client. It's common for client-side code to rely on client-only features like the window or document value. If your code contains features like this, it will trigger an error on render. Keep an eye on your server console to see where to make use of Meteor.isClient.

Adding SEO & Metadata

To aid in the process of adding SEO metadata to your server-side rendered pages, Pup includes a React component called <SEO /> which acts as a wrapper and template for the react-helmet package. Above, we can see that for each request, our SSR code attempts to call the Helmet.renderStatic() method and inject the meta tags it finds into the <head></head> of the page we're viewing.

Using the <SEO /> component, you can quickly and easily add the essential meta tags (we've pulled the list of tags in use from the popular SEO blog Moz), but also modify what's included for your own needs if you wish.

On the right, we can see the <SEO /> component as it's defined. Between the <Helmet></Helmet> tags, we can write normal HTML meta tags to describe our content. From here, React Helmet will inject these into the <head></head> of our document.

Below, we can see an example of putting the <SEO /> component to use:

<SEO
  title={doc.title}
  description={doc.body}
  url={`documents/${doc._id}`}
  contentType="article"
  published={doc.createdAt}
  updated={doc.updatedAt}
  twitter="clvrbgl"
/>
/ui/components/SEO/index.js
import React from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { Meteor } from 'meteor/meteor';
import { sample } from 'lodash';

const seoImages = {
  facebook: ['open-graph-facebook.png'],
  twitter: ['open-graph-twitter.png'],
  google: ['open-graph-google.png'],
};

const seoImageURL = (file) =>
  `https://s3-us-west-2.amazonaws.com/cleverbeagle-assets/graphics/${file}`;
const seoURL = (path) => Meteor.absoluteUrl(path);

const SEO = ({
  schema,
  title,
  description,
  images,
  path,
  contentType,
  published,
  updated,
  category,
  tags,
  twitter,
}) => (
  <Helmet>
    <html lang="en" itemScope itemType={`http://schema.org/${schema}`} />

    <title>{title}</title>
    <meta name="description" content={description} />
    <meta itemProp="name" content={title} />
    <meta itemProp="description" content={description} />
    <meta
      itemProp="image"
      content={(images && images.google) || seoImageURL(sample(seoImages.google))}
    />

    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:site" content="@clvrbgl" />
    <meta name="twitter:title" content={`${title} | Pup`} />
    <meta name="twitter:description" content={description} />
    <meta name="twitter:creator" content={`@${twitter}` || '@clvrbgl'} />
    <meta
      name="twitter:image:src"
      content={(images && images.twitter) || seoImageURL(sample(seoImages.twitter))}
    />

    <meta property="og:title" content={`${title} | Pup`} />
    <meta property="og:type" content={contentType} />
    <meta property="og:url" content={seoURL(path)} />
    <meta
      property="og:image"
      content={(images && images.facebook) || seoImageURL(sample(seoImages.facebook))}
    />
    <meta property="og:description" content={description} />
    <meta property="og:site_name" content="Pup" />

    <meta name="fb:app_id" content="196001354345637" />

    {published && <meta name="article:published_time" content={published} />}
    {updated && <meta name="article:modified_time" content={updated} />}
    {category && <meta name="article:section" content={category} />}
    {tags && <meta name="article:tag" content={tags} />}
  </Helmet>
);

SEO.defaultProps = {
  schema: null,
  path: null,
  updated: null,
  category: null,
  tags: [],
  twitter: null,
  images: {},
};

SEO.propTypes = {
  schema: PropTypes.string,
  title: PropTypes.string.isRequired,
  description: PropTypes.string.isRequired,
  path: PropTypes.string,
  contentType: PropTypes.string.isRequired,
  published: PropTypes.string.isRequired,
  updated: PropTypes.string,
  category: PropTypes.string,
  tags: PropTypes.array,
  twitter: PropTypes.string,
  images: PropTypes.object,
};

export default SEO;
/ui/pages/ViewDocument/ViewDocument.js
import React from 'react';
import PropTypes from 'prop-types';
import { graphql } from 'react-apollo';
import { Meteor } from 'meteor/meteor';
import SEO from '../../components/SEO';
import BlankState from '../../components/BlankState';
import Comments from '../../components/Comments';
import { document as documentQuery } from '../../queries/Documents.gql';
import commentAdded from '../../subscriptions/Comments.gql';
import parseMarkdown from '../../../modules/parseMarkdown';

import { StyledViewDocument, DocumentBody } from './styles';

class ViewDocument extends React.Component {
  componentWillMount() {
    const { data } = this.props;
    if (Meteor.isClient && Meteor.userId()) data.refetch();
  }

  render() {
    const { data } = this.props;

    if (!data.loading && data.document) {
      return (
        <React.Fragment>
          <StyledViewDocument>
            <SEO
              title={data.document && data.document.title}
              description={data.document && data.document.body}
              url={`documents/${data.document && data.document._id}`}
              contentType="article"
              published={data.document && data.document.createdAt}
              updated={data.document && data.document.updatedAt}
              twitter="clvrbgl"
            />
            <React.Fragment>
              <h1>{data.document && data.document.title}</h1>
              <DocumentBody
                dangerouslySetInnerHTML={{
                  __html: parseMarkdown(data.document && data.document.body),
                }}
              />
            </React.Fragment>
          </StyledViewDocument>
          <Comments
            subscribeToNewComments={() =>
              data.subscribeToMore({
                document: commentAdded,
                variables: {
                  documentId: data.document && data.document._id,
                },
                updateQuery: (existingData, { subscriptionData }) => {
                  if (!subscriptionData.data) return existingData;
                  const newComment = subscriptionData.data.commentAdded;
                  return {
                    document: {
                      ...existingData.document,
                      comments: [...existingData.document.comments, newComment],
                    },
                  };
                },
              })
            }
            documentId={data.document && data.document._id}
            comments={data.document && data.document.comments}
          />
        </React.Fragment>
      );
    }

    if (!data.loading && !data.document) {
      return (
        <BlankState
          icon={{ style: 'solid', symbol: 'file-alt' }}
          title="No document here, friend!"
          subtitle="Make sure to double check the URL! If it's correct, this is probably a private document."
        />
      );
    }

    return null;
  }
}

ViewDocument.propTypes = {
  data: PropTypes.object.isRequired,
};

export default graphql(documentQuery, {
  options: ({ match }) => ({
    variables: {
      _id: match.params._id,
    },
  }),
})(ViewDocument);

Disabling Server Side Rendering

In some cases, server side rendering is unnecessary or can be the culprit for frustrating bugs. You can disable server-side rendering in Pup on a route-by-route basis using the checkIfBlacklisted module.

This module is automatically run for each request that comes into the server (inside of /startup/server/ssr.js). If the route requested matches one of the URL patterns defined inside of /modules/server/checkIfBlacklisted.js, it will not have its markup rendered on the server. Instead, the page content will only be rendered on the client (browser).

To blacklist a URL from server-side rendering, add its corresponding pattern to the array in /modules/server/checkIfBlacklisted.js.

For example, if you had a URL like /admin/users/:_id that you wanted to prevent from being server-side rendered, you would add '/admin/users(/:_id)' to the array inside of /modules/server/checkIfBlacklisted.js.

import UrlPattern from 'url-pattern';

export default (url) => {
  let isBlacklisted = false;
  ['/documents(/:id)'].forEach((blacklistedPattern) => {
    const pattern = new UrlPattern(blacklistedPattern);
    isBlacklisted = !!pattern.match(url);
  });
  return isBlacklisted;
};