Elder.js is an opinionated static site generator and web framework built with SEO in mind. (Supports SSR and Static Site Generation.)
Features:
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.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.
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.
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.
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.
npm run build
Let the build finish.
npx sirv-cli public
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:
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.
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:
route.js
file. This is where you define route details such as the route’s permalink
function, all
function and data
function.${routeName}
. eg: ./src/routes/blog/Blog.svelte
(from here on out we refer to these specific Svelte components as “Svelte Templates”)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());
Elder.js’ approach to routing is unconventional but it offers several distinct advantages, the two biggest are:
/senior-living/:facilityId/
and /senior-living/:articleId/
and /senior-living/:parentCompanyId/
. This also makes i18n and l10n much more approachable.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. Fatdata
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:
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 justdata.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.
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>;
}
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;
};
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;
};
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).
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.
mutable
and props
ArraysEach 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 themutable
array are passed as a Proxy.
#svelte #programming #web-development #developer