The Story of NuxtPress

TL;DR: NuxtPress v0.0.1-beta is out. This blog is built with it. Its own documentation is built with it. Check out some examples. It is still beta and needs more testing. Please file bugs and feature requests if you try it.

Read on for a lot more details.

About a year ago, I released the first version of this blog's source code. It was as a rather elaborate Nuxt boilerplate: loading Markdown files from the file system and using middleware to very crudely inject rendered content into pages.

My blog ran fine with it, but it had a couple of caveats: no client-side VueRouter-based navigation and duplicated markup content delivered inside Nuxt's hydration payload. Also, reusing it on other projects meant copying a lot of code around.

Is Nuxt a static site generator?

Yes, if you want it to be.

Nuxt is primarily a Vue.js framework that allows you to build just about any kind of web application.

For reasons I've explored in the past, I think it hits a sweet spot of abstraction: work with a regular Node app server, with a Vue.js app on top and all the build code to glue it all together. Add to that a thin runtime layer providing a middleware engine and asyncData() and you've got yourself a Nuxt.

Having said that, the ability to generate a static bundle is a secondary feature of Nuxt. When compared to frameworks like Gridsome and Gatsby, that place their primary focus on static generation, Nuxt was bound to leave some users disappointed.

How can we make things better? As Evan You pointed out:

I think as developers we need to battle this "don't adopt anything from your competitor" mindset. A good idea doesn't become a bad idea just because it came from a project that you didn't like (before the idea was created).

I spent some time reading about Gridsome and Gatsby in an attempt to understand what things they got right and how hard would it be to bring them to the Nuxt stack. I was inspired by both. I liked Gridsome's concept of managed pages and programmable data sources, which seem to have equivalences in Gatsby.

Both frameworks seem pretty solid to me and it makes no sense to advocate Nuxt over them if they already work well for you.

I didn't like the API so much though. If you are already using Nuxt for web development, chances are you want some of these features. But you still want them the Nuxt way: with dead simple APIs and convention over configuration as much as possible.

Very few people are familiar with the power of Nuxt's Module Container API. It offers so much granular control over Nuxt build process aspects that you can pretty much make your very own framework with it. Armed with the experience I had building a few Nuxt modules in the past, I began rewriting nuxpress as one.

Solving the data source problem

I wanted to address Sasha's main concern, that is, the static content hydration problem. But I also wanted to bring some of that Nuxt magic to Markdown publishing, and by Nuxt magic I mean: things should just work. In fact, Markdown files should automatically register routes if placed under pages/, just like Nuxt pages.

There's an active Nuxt RFC suggesting the implementation of full static generate. Alexander Lichter writes:

Instead of relying on the API calls, we should inline the response of the asyncData method(s) inside a .json file (per page) when the generate.fullStatic flag is enabled in the nuxt.config.js (the flag name is debatable). Then we can use the .json file as data source without issues.

That seems to be what Gridsome did for their v0.6 release:

From now on, page data will be stored as raw JSON files without interference from webpack. And each file is prefetched and loaded on demand for each page. The overall JavaScript size is reduced by about 30% in most cases.

This is available in Nuxt today in two flavors:

nuxt-payload-extractor's automated approach can work remarkably well for most static Nuxt apps.

Introducing NuxtPress

I set out to implement a Nuxt module that employs these techniques while automating as much of the configuration as possible. I also wanted a flexible data loading API, one that could seamlessly work with the file system as well as with a remote API.

In the spirit of favoring boring technology, I chose a simple REST API: what if we simply made it so that every request to Nuxt also yields a silent API request for the URL requested? Say, a request to /about will silently try to load data from /api/source/about. A request to / silently makes a request to /api/source/index.

What if we could enable this generic /api/source/:path API and make it automatically translate requests to statically available, prefetchable JSON files if your app is built with nuxt generate?

That is how this blog is rendered now, with NuxtPress v0.0.1. Loading an entry from the index page is just a JSON file away, and if you access the URL directly you get the static, prerendered version.

Static JSON Fetch

At its core, NuxtPress will search for .md files in the pages/ directory and build Nuxt routes based on them in a fashion very similar to Nuxt's own handling of .vue files. To get that working in any Nuxt app:

$ npm install @nuxt/press --save

Edit your nuxt.config.js:

export default {
  modules: ['@nuxt/press']
}

With that, .md files will be recognized under pages/. You can even set the Nuxt layout and use YAML metadata for rendering.

pages/foo/bar.md/foo/bar

Links are automatically converted to NuxtLink (so that VueRouter-based navigation works) and you can also use Vue template markup, with some caveats:

---
layout: custom
someText: hey, this works!
---

# Hello world

Go to [/subpage](/subpage)

Here's some text: {​{ $press.source.someText }}

Blueprints

Problem is, placing files under pages/ doesn't work so well for some apps. In a blog, for example, you wouldn't want to manually maintain a directory structure that reflects the chronological nature of entries. You just want to drop files into an entries/ directory and let each entry's metadata determine its final URL.

Internally, NuxtPress uses the concept of app blueprints to run.

The basic ability to build page routes from Markdown files comes from the common bundled app, which is always enabled by default and supports other NuxtPress bundled apps. In NuxtPress' module entry point you'll see the following:

const blueprints = ['docs', 'blog', 'slides', 'common']
await registerBlueprints.call(this, 'press', options, blueprints)

Each blueprint definition has an enabled() method that determines whether or not the blueprint's files should be added to your app's bundle. The docs app for example will only be enabled in your Nuxt app if your source directory contains a docs folder or if you've specifically configured NuxtPress to run in docs standalone mode. In the docs blueprint soure, you'll find:

enabled (options) {
  if (options.$standalone === 'docs') {
    options.docs.dir = ''
    options.docs.prefix = '/'
    return true
  }
  return exists(this.options.srcDir, options.docs.dir)
}

Each bundled app has its own Markdown loader.

The docs app is essentially a minimal VuePress-like Nuxt app, loading files from the source directory and building a book view with table of contents. See NuxtPress' own docs for an example.

The blog app includes a blog view and will build URLs based on the publication date of entries, like you see right here on this blog.

There's also an experimental slides app that will build a slideshow based on Markdown source files, similar to mdx-deck.

See more about NuxtPress' bundled apps in the documentation.

Automated Full Static Generate

Sébastien Chopin has worked on wiring the full static RFC into Nuxt's core, but right now we can take advantage of the HTTP data source API I described earlier, which also helps us solve the problem of being able to provide custom data sources.

Below is a snippet (edited for brevity) from the middleware responsible for retrieving data sources in NuxtPress:

if (!source) {
  source = await $press.get(`api/source/${sourceParam}`)
}

if (!source) {
  source = await $press.get(`api/source/${sourceParam}/index`)
}

In a pre-rendered application (built with nuxt generate), you get static markup ready for display, but you still want to keep the app working for subsequent client-side navigation (VueRouter).

So one way to turn a live Nuxt app into a statically rendered one is to ensure these API requests keep working on the client-side:

function getSourcePath(path) {
  return `/_press/sources/${path}.json`
}

function $json (url) {
  return fetch(url).then(r => r.json())
}

const apiPath = '/api/source'

if (process.static && process.client) {
  press.get = (url) => {
    if (url.startsWith(apiPath)) {
      return $json(getSourcePath(url.slice(apiPath.length + 1)))
    } else {
      return $json(url)
    }
  }
} else {
  press.get = url => ctx.$http.$get(url)
}

In Nuxt's universal mode, NuxtPress will load all data from the base /api/ endpoint, which is handled via Nuxt serverMiddleware. It includes endpoints which are specific to the docs, blog and slides apps, and the /api/source endpoint which is used by all of them.

All API handlers can be overriden which means you can hook any CMS API into NuxtPress.

But if you just want to deploy static files to Netlify, users will get prerendered pages on first render, and automatically prefetched JSON-hydrated pages for client-side navigation.

Avoiding client-side hydration

The basic Vue template for NuxtPress pages looks like this:

<template>
  <nuxt-static
    tag="main"
    :source="$press.source.body" />
</template>

<script>
export default {
  middleware: 'press',
  layout: ({ $press }) => $press.layout
}
</script>

The actual source code is slightly more complex as it involves conditionally registering components based on the enabled apps.

But notice how there's no data() or asyncData()?

That's because data is stored directly injected in Vue's context. So it doesn't get serialized into NUXT and delivered in a <script> tag in addition to the prerendered markup.

This is made possible with the <NuxtStatic> component that NuxtPress introduces, which ensures the component stays static on the client-side. This of course might not always be desirable.

In Nuxt 2.9+ there will be absolutely no need for <nuxt-static> as asyncData payloads will be automatically inlined as JSON.

If you're embedding Vue components inside your Markdown source files, you'll want to eject and replace the original templates with asyncData() and <NuxtTemplate> instead:

<template>
  <nuxt-template
    tag="main"
    :source="source.body" />
</template>

<script>
export default {
  middleware: 'press',
  layout: ({ $press }) => $press.layout,
  asyncData: ({ $press }) => ({ source: $press.source })
}
</script>

<NuxtTemplate> is another component NuxtPress introduces that allows for dynamic rendering of Vue template markup. That means your Markdown-generated HTML markup can also be powered by Vue templating features. This requires NuxtPress to build apps using Vue's full build. You can disable Vue in Markdown altogether and use Vue's smaller runtime build if you want. If you do, <NuxtTemplate> switches to using v-html for rendering dynamically loaded HTML.

The downside of asyncData() is that for lengthy HTML content, that means also delivering a big __NUXT__ payload. This is why NuxtPress introduced <NuxtStatic>. For most apps though, the performance impact on first render might not be noticeable at all, given all the caching and compression strategies available in modern HTTP servers.

Ejectable source code

Another cool feature we're testing in NuxtPress is template ejection and shadowing.

$ npx nuxt-press eject blog

The above command, for instance, will eject all the source code for blog app, allowing you to completely modify it to your needs, should you need to. Under press/, you'll find every single file composing the blog app. Just a regular Vue app:

blog/
├─ components/ 
│  ├─ entry.vue
│  └─ sidebar.vue
├─ layouts/ 
│  ├─ blog.vue
├─ pages/ 
│  ├─ archive.vue
│  ├─ index.vue
├─ head.js
└─ rss.xml

NuxtPress v0.0.1-beta is out for early adopters willing to help us deliver a great Markdown experience for Nuxt developers. Make sure to file issues and don't forget to join our Discord channel for support.

VuePress Feature Parity

Although NuxtPress bundled docs app is fairly robust, I can't say there's feature parity with VuePress at this stage. It is however a rather hackable Nuxt app underneath, as with all apps bundled with NuxtPress: they're all vanilla Nuxt apps under the hood, leveraging NuxtPress Markdown filesystem loader and sources API. You can eject the entire docs app source code into your codebase and add any missing features that you'd like. You can also customize the Markdown processor used for loading documentation files.

Contributing

This early beta release welcomes anyone who wants to join the effort. Here are a few areas that could use attention:

  • Themes: all bundled apps come with default themes that are currently very basic and could receive several aesthetic and potential UX enhancements. If you improve anything in the stylesheets, please submit a PR with a screenshot.
  • Documentation: needs revisions, tutorials and examples covering advanced usage of all bundled apps. If you make a new example and think it should be in examples/, please submit it.

Contributing is extremely easy, just pull the nuxt/press repository, install NPM dependencies and use the dev script to test your changes directly in the bundled examples. If you change something in src/blueprints/blog, you'll want to test with your changes with:

npm run dev examples/blog

Just knowing Vue and Nuxt is enough to contribute to NuxtPress bundled apps, thanks to its blueprints architecture.

Acknowledgements

This endeavour would have not been possible without my employer STORED e-commerce, which fully supports my open source work contributing to the Nuxt project.

Thanks to Nuxt cocreator Sébastien Chopin, who provided much needed insights along the way and fellow Nuxt Team member Pim, who delivered an amazingly minimal yet functional docs app and also bootstrapped the entire project's test suite.

Also thanks to Darek Wędrychowski, Eduardo San Martin Morote and fellow Nuxt Team members Dmitry Molotkov and Pooya Parsa for reading drafts of this article.

Other resources