Elder.js is an opinionated static site generator and web framework built with SEO in mind. (Supports SSR and Static Site Generation.)

Features:

  • Build hooks allow you to plug into any part of entire page generation process and customize as needed.
  • A Highly Optimized Build Process: that will span as many CPU cores as you can throw at it to make building your site as fast as possible. For reference Elder.js easily generates a data intensive 18,000 page site in 8 minutes using a budget 4 core VM.
  • Svelte Everywhere: Use Svelte for your SSR templates and with partial hydration on the client for tiny html/bundle sizes.
  • Straightforward Data Flow: By simply associating a data function in your route.js, you have complete control over how you fetch, prepare, and manipulate data before sending it to your Svelte template. Anything you can do in Node.js, you can do to fetch your data. Multiple data sources, no problem.
  • Community Plugins: Easily extend what your Elder.js site can do by adding prebuilt plugins to your site.
  • Shortcodes: Future proof your content, whether it lives in a CMS or in static files using smart placeholders.
  • 0KB JS: Defaults to 0KB of JS if your page doesn’t need JS.
  • Partial Hydration: Unlike most frameworks, Elder.js lets you hydrate just the parts of the client that need to be interactive allowing you to dramatically reduce your payloads while still having full control over component lazy-loading, preloading, and eager-loading.

Context

Elder.js is the result of our team’s work to build this site (ElderGuide.com) and was purpose built to solve the unique challenges of building flagship SEO sites with 10-100k+ pages.

Elder Guide Co-Founder Nick Reese has built or managed 5 major SEO properties over the past 14 years. After leading the transition of several complex sites to static site generators he loved the benefits of the JAM stack, but wished there was a better solution for complex, data intensive, projects. Elder.js is his vision for how static site generators can become viable for sites of all sizes regardless of the number of pages or how complex the data being presented is.

We hope you find this project useful whether you’re building a small personal blog or a flagship SEO site that impacts millions of users.

Project Status: Stable

Elder.js is stable and production ready.

It is being used on on this site and 2 other flagship SEO properties that are managed by the maintainers of this project.

We believe Elder.js has reached a level of maturity where we have achieved the majority of the vision we had for the project when we set out to build a static site generator.

Our goal is to keep the hookInterface, plugin interface, and general structure of the project as static as possible.

This is a lot of words to say we’re not looking to ship a bunch if breaking changes any time soon, but will be shipping bug fixes and incremental changes that are mostly “under the hood.”

As September 2020, the ElderGuide.com team expects to maintain this project at least until 2023-2024. For a clearer vision of what we mean by this and what to expect from the maintainers as far as what is considered “in scope” and what isn’t, please see this comment.

Getting Started:

The quickest way to get started is to get started with the Elder.js template using degit:

Here is a demo of the template: https://elderjs.netlify.app/

Step 1: Clone Template

npx degit Elderjs/template elderjs-app
cd elderjs-app

Step 2: Install Dependencies

npm install ## or just yarn

Step 3: Start the Project

npm start

Navigate to http://localhost:3000. You should see your app running.

Developing using the Template:

For development, we recommend running two separate terminals. One for the server and the other for rollup.

Terminal 1: Server

npm run dev:server ## `npm start` above starts a server, but doesn't rebuild your Svelte components on change.

Terminal 2: Rollup

npm run dev:rollup ## This rebuilds your svelte components on change.

Once you have these two terminals open, edit a component file in src, save it, and reload the page to see your changes.

To Build/Serve HTML:

npm run build

Let the build finish.

npx sirv-cli public

Why We Built Elder.js

When we set out to build elderguide.com we tested 6 different static site generators (Gatsby, Next.js, Nuxt.js, 11ty, Sapper and Hydrogen.js) and ultimately realized there wasn’t a solution that ticked all of our boxes.

On our journey we had 3 major realizations:

  1. Most SSGs are built for either simple sites/blogs or for full scale “app frameworks” that have added an ‘export’ process added as an afterthought.
  2. Fetching data from multiple sources (dbs, apis, config files, markdown files) can lead to major code spaghetti.
  3. Client side routing adds a huge amount of complexity (and bundle size) to initial loads for very little SEO benefit. If you aren’t building an App, why would we want to fully hydrate our JS framework just for faster routing? Browsers are great at routing… we should only be hydrating things that need to be hydrated.

Initially we decided to go with Sapper but hit major data roadblocks and issues unusable build times and development reload times.

In an afternoon of frustration, we whipped up a very rudimentary SSG with a complex and error prone process of adding Svelte components… but it worked. #productionready

After shipping ElderGuide.com to production we were working on a refactor when a moment of genius from Kevin over at Svelte School prompted a major breakthrough that allowed us to use Svelte 100% for templating and still get partial hydration even thought Svelte doesn’t support it.

After much consideration we decided to open source the project so others could use it.

We can’t wait to see what you build with it.

Routes

At the core of any site are it’s “routes” or templates.

In Elder.js a route is made up of 2 files that live in your route folder: ./src/routes/${routeName}/ folder.

They are:

  1. A route.js file. This is where you define route details such as the route’s permalink function, all function and data function.
  2. A Svelte component to be used as a template matches the ${routeName}. eg: ./src/routes/blog/Blog.svelte (from here on out we refer to these specific Svelte components as “Svelte Templates”)

Route.js

route.js files consist of a permalink function, an all function, and a data function.

Elder.js uses “explicit routing” instead of the more common “parameter based” routing found in most frameworks like express.

At first, Elder.js’ non-conventional routing can be intimidating, but it offers some major benefits discussed below while streamlining data flow in complex sites.

Let’s look at an example of how you’d setup a route like /blog/:slug/ where there are only 2 blogposts.

// ./src/routes/blog/route.js
module.exports = {
  template: 'Blog.svelte',
  permalink: ({ request }) => `/blog/${request.slug}/`, // this is the same as /blog/:slug/ in 'parameter based' routing.
  all: async () => {
    // The all function returns an array of all possible "request" objects for a route.
    // Here we are explicitly defining every possible variation of this route.
    return [{ slug: 'blogpost-1' }, {slug: 'blogpost-2'}],
  },
  data: async ({ request }) => {
  // The object returned here will be available in the Blog.svelte as the 'data' prop.
  return {
    blogpost: `This is the blogpost for the slug: ${request.slug}`.
  }
};

Here is what is happening in plain English:

  • permalink(): The permalink function is similar to your standard route definition you’d see with placeholders. This means /blog/:slug/ would be defined as /blog/${request.slug}/. The permalink function’s job is to take the request objects returned from all and transform them into relative urls.
  • all(): This async function returns an array of all of the request objects for a given route. Often this array may come from a data store but in this example we’re explicitly saying we only have 2 blog posts, so only two pages will be generated.
  • data(): The data function prepares the data required in the Blog.svelte file. Whatever object is returned will be available as the data prop. In the example we are just returning a static string, but you could also hit an external CMS, query a database, or read from the file system. Anything you can do in node, you can do here.

In this example, we’re just returning a simple object in our data() function, but we could have easily used node-fetch and gotten our blogpost from a CMS or used fs to read from the filesystem:

const blogpost = await fetch(
  `https://api.mycms.com/getBySlug/${request.slug}/`
).then((res) => res.json());

Why Routing Differs from Express-like Frameworks

Elder.js’ approach to routing is unconventional but it offers several distinct advantages, the two biggest are:

  1. Unlike traditional ‘parameter based’ routing, Elder.js’ does not have to crawl all of the links of a site to know what pages need to be generated. This allows for fully parallelized build times that scale with CPU resources. (As of October 2020, ElderGuide.com has ~20k pages and builds in 1 minute 22 seconds.)
  2. Users have full control over their URL structure. No complex regex is needed to have /senior-living/:facilityId/ and /senior-living/:articleId/ and /senior-living/:parentCompanyId/. This also makes i18n and l10n much more approachable.

Route.js Best Practices:

With the simple route.js example out of the way, let’s talk about best practices and let’s look at a more complex example of a route.js file.

Best Practice: A route’s all function should return the minimum viable data points needed generate a page.

Skinny request objects. Fat data functions.

When people first encounter Elder.js there is a strong temptation to load the request objects returned by a route’s all function with tons of data.

While this approach works, it doesn’t scale very well. Fetching, preparing, and processing data should be done in your data function.

That said, it is recommend that you only include the bare minimum required to query your database, api, file system, or data store on the request object. From there do all of the data fetching, preparing, and organization in the route’s data function.

Real World Example

To drive this point home and to show a more complex example of routing, imagine you’re building a travel site that lists tourist attractions for major cities throughout the world.

You have a city route and for each page on that route you need 3 data points to query your API, database, or datastore in order to pull in all of the rest of the page’s data.

These data points are:

  1. The language of the page being generated
  2. The City slug
  3. The Country slug

Here is what a minimal route.js would look like to support /en/spain/barcelona/ and /es/espana/barcelona/.

// ./src/routes/city/route.js
module.exports = {
  permalink: ({ request, settings }) =>
    `/${request.lang}/${request.country.slug}/${request.slug}/`,
  all: async () => {
    return [
      { slug: "barcelona", country: { slug: "spain" }, lang: "en" },
      { slug: "barcelona", country: { slug: "espana" }, lang: "es" },
    ];
  },
  data: async ({ request }) => {
    // discussed below.
  },
};

Problems with Fat Request Objects

Imagine for a moment that we attempted to include all of the additional details needed to generate the page for this route in our request objects like so:

module.exports = {
  // permalink function
  all: async () => {
    return [
      { slug: 'barcelona', country: { slug: 'spain' }, lang: 'en', data: { hotels: 12, attractions: 14, promotions: ['English promotion'], ...lotsOfData } },
      { slug: 'barcelona', country: { slug: 'espana' }, lang: 'es' data: { hotels: 12, attractions: 14, promotions: ['Spanish promotion'], ...lotsOfData } }
    ]
  }
  // data function
}

Now imagine in your data function looks like so and you’re getting more data.

module.exports = {
  // permalink function
  all: async () => {
    return [
      { slug: 'barcelona', country: { slug: 'spain' }, lang: 'en', data: { hotels: 12, attractions: 14, promotions: ['English promotion'], ...lotsOfData } },
      { slug: 'barcelona', country: { slug: 'espana' }, lang: 'es' data: { hotels: 12, attractions: 14, promotions: ['Spanish promotion'], ...lotsOfData } }
    ]
  },
  data:  async ({ request }) => {
    const hotels = [
        { ...hotel }, // imagine this has a lot of details
        { ...hotel },
        { ...hotel },
        { ...hotel },
        { ...hotel },
      ];

    // this will now be available in your svelte template as your 'data' param.
    // you could access all of the hotel details at `data.hotels`
    return {
      hotels,
    };
  },
}

With this implementation you’ve now got both request and data objects in Svelte templates and you’re asking yourself:

Should I be accessing request.data.hotels or just data.hotels.length to get the number of hotels?

Save yourself this headache by remembering: skinny **request** objects, fat **data** functions.

Only store the minimum data needed on your request objects. Instead return all of the data required by the page from the data function.

Note: If you’re interested in i18n please look at this issue as robust support could be offered by a community plugin.

Database Connections, APIs, and External Data Sources

The data function of each route is designed to be the central place to fetch data for a route but the implementation details are very open ended and up to you.

Just about anything you can do in Node.js you can do in a data function.

That said, if you are hitting a DB and want to manage your connection in a reusable fashion, the recommended way of doing so is to populate the query object on the [bootstrap](https://elderguide.com/tech/elderjs/#hook-example-1-bootstrap) hook.

Using this pattern allows you to share a database connection across the entire lifecycle of your Elder.js site.

Cache Data Where Possible Within Route.js Files

If you have a data heavy calculation required to generate a page, look into calculating that data and caching it before your module.exports definition like so:

// ./src/routes/city/route.js

// do heavy calculation here
// this prevents the data from being calculated each request
const cityLookupObject = {
   barcelona: {
   // lots of data.
  }
}

module.exports = {
  permalink: ({ request, settings }) =>
    `/${request.lang}/${request.country.slug}/${request.slug}/`,
  all: async () => {
    return [
      { slug: "barcelona", country: { slug: "spain" }, lang: "en" },
      { slug: "barcelona", country: { slug: "espana" }, lang: "es" },
    ];
  },
  data: async ({ request }) => {

    return {
      city: cityLookupObject[request.slug];
    }

  },
};

Data Used in Multiple Routes

If you have data that is used in multiple routes, you can share that data between routes by populating the data object on the boostrap hook documented later in this guide.

Assuming you have populated the data.cities with an array of cities on the boostrap hook you could access it like so:

// ./src/routes/city/route.js
module.exports = {
  permalink: ({ request }) => `/${request.slug}/`,
  all: async ({ data }) => data.cities,
  data: async ({ request, data }) => {
    return {
      city: data.cities.find(city=> city.slug === request.slug);
    }

  },
};

Data defined in boostrap is available on all routes.

all() Function Spec

Here is the function signature for a route.js all function:

all: async ({ settings, query, data, helpers }): Array<Object> => {
  // settings: this describes the Elder.js settings at initialization.
  // query: an empty object that is usually populated on the 'bootstrap' hook with a database connection or api connection. This is sharable throughout all hooks, functions, and shortcodes.
  // data: any data set on the 'bootstrap' hook.
  return Array<Object>;
}

permalink() Function Spec

Here is the function signature for a route.js permalink function:

permalink: ({ request, settings }): String => {
  // NOTE: permalink must be sync. Async is not supported.

  // request: this is the object received from the all() function. Generally we recommend passing a 'slug' parameter but you can use any naming you want.
  // settings: this describes the Elder.js bootstrap settings.
  return String;
};

data() Function Spec

Whether you’re building a personal blog or complex data driven SEO site, a route’s data function is the recommend place to fetch (from a db, api, or other source) and prepare data to be consumed by your Svelte templates.

Here is the function signature for a route.js data function:

data: async ({
  data, // any data set by plugins or hooks on the 'bootstrap' hook
  helpers, // Elder.js helpers and user helpers from the ./src/helpers/index.js` file.
  allRequests, // all of the `request` objects returned by a route's all() function.
  settings, // settings of Elder.js
  request, // the requested page's `request` object.
  errors, // any errors
  perf, // the performance helper.
  query, // search for 'query' in these docs for more details on it's use.
}): Object => {
  // data is any data set from plugins or hooks.
  return Object;
};

Hooks: How to Customize Elder.js

Elder.js hooks are designed to be modular, sharable, and easily bundled in to Elder.js plugins for common use cases… while still giving developers of all skill levels an easy way to customize core page generation logic to their own needs.

For a full overview of the hooks available, you can reference the hookInterface.ts or the hooks list below.

In short there is a hook at every major step of the page generation process from system bootstrap (the bootstrap hook) all the way to writing html to your computer (on the requestComplete hook).

The Goal of Elder.js Hooks

No project becomes a ‘tangled mess’ on day one. It happens over time.

You or someone on your team makes a small “hacky” fix.

This change was intended to be temporary but it falls off your team’s radar.

Over time these “hacky” fixes build up and slowly make a project hard to reason about and hard to work on.

The goal of Elder.js’ hook implementation is that any changes that don’t fit in a route.js file are instead aggregated in a single hooks.js file where anyone on a team will know to expect to find any hidden complexity.

The result of this approach is that of a project’s hacky fixes are no longer scattered across a project, but instead live in a single self documenting location where users have complete but predictable control over the Elder.js page generation process.

The added benefit is that plugins can also tap into these hooks offering sharable functionality.

Hook Interface: the mutable and props Arrays

Each Elder.js hook explicitly defines which props are available to a function registered on a hook along with which of those props are mutable by that function.

This defines the “contract” that Elder.js’ hook interface implements.

  • props represents the parameters that are available to a function registered on a hook.
  • mutable represents which of the props can be changed on a specific hook.

This structure was implemented to keep mutation and side effects predictable.

Under the hood, all items in the props array that aren’t in the mutable array are passed as a Proxy.

Hook Lifecycle

Elder.js hook Lifecycle

#svelte #programming #web-development #developer

Elder.js: An Opinionated, SEO focused, Svelte Framework
13.30 GEEK