The DatoCMS Blog

Comparing JS frameworks for content-heavy sites

Posted on November 12th, 2024 by Ronak Ganatra

Ok, let’s get right into it - no long life story or filler. 

Which SSG/frontend framework do you recommend when working with DatoCMS” is a supremely common conversation we have with our users, and while the classic answer is always “it depends”, I’m entitled to form overly subjective opinions based on several criteria. Especially given that most of these conversations revolve around content heavy websites that have 1000s and 1000s of articles.

So. I spun up 10,001 Lorem Ipsum blog posts in a DatoCMS project, used nothing but the basics (no fancy CSS frameworks, no animations, no overkill) and threw together a simple frontend to stress test Next.js, Nuxt, Svelte, and Astro head to head.

This is what we built with each framework.

Why bother comparing these in the first place? Well they’re currently the most commonly discussed for us so we wanted to lay out what we think works best, but if you’re using or prefer something else (ember.js or some bleeding edge tanstack start anyone?), you do you!

It's also worth noting, that while this definitely isn't meant to be "best practice", we opted to take the default Astro approach of prefetching and prerendering EVERY path, so build times bloated up while we built 10,001+ slugs on build time. While this may not be the best approach for different use-cases, we took it this time around to try and be a bit more cache efficient since we're not adding any cache invalidation or headers in addition.

TLDR? I (very personally and subjectively) ended up claiming that Astro was the best framework for content-focused websites.

Anyways, here’s everything we covered 👇

The CMS stuff

In DatoCMS we set up an oversimplified model for blog posts. The homepage and blog index page is handled directly in the repo, so all we needed to do was provide an API with the “content” itself.

In the project we have 2 models, one for a blog post, and one for an author related to the blog post.

The author model is a simple string field for the name, and an asset field for the avatar.

The post model is slightly more complicated to try and use multiple field types:

  • An asset field for a featured image

  • String fields for the title and description

  • A Structured Text field for the content

  • A relation field to connect a post to an author

  • A slug field for the slug, and

  • A date field for the publication date

Finally, in the media area we added 4 avatars and 4 stock images to use in rotation for all the posts, before Lorem Ipsumming 10K posts using the Content Management API.

To confirm that everything was working as expected, we played around with the CDA playground to make sure the only 2 queries we needed were returning all the expected content.

Querying for all the posts to list out on the /blog page

{
allPosts(first: 100) {
title
slug
date
author {
name
}
}
}

Which returned something like this

"data": {
    "allPosts": [
      {
        "title": "Quisque. Fringilla pharetra metus ante natoque mattis lacus faucibus nisl.",
        "slug": "quisque-fringilla-pharetra-metus-ante-natoque-mattis-lacus-faucibus-nisl",
        "date": "2024-10-31",
        "author": {
          "name": "Tim"
        }
      },
}

Querying for each post by slug to generate the /blog/[slug] pages

query getPost($slug: String) {
post(filter: {slug: {eq: $slug}}) {
title
slug
image {
url
id
}
date
description
content {
value
}
author {
name
avatar {
url
id
}
}
}
}

Which returned something like this

{
"data": {
"post": {
"title": "Quisque. Fringilla pharetra metus ante natoque mattis lacus faucibus nisl.",
"slug": "quisque-fringilla-pharetra-metus-ante-natoque-mattis-lacus-faucibus-nisl",
"image": {
"url": "https://www.datocms-assets.com/144276/1729501777-bike.avif",
"id": "UcjRUwtiS5ujFm3PN6vqqg"
},
"date": "2024-10-31",
"description": "Scelerisque molestie posuere varius. Senectus Massa. Eros. Taciti auctor sagittis risus nostra pellentesque morbi lacus vivamus magna rutrum nisl tempor.",
"content": {
"value": {
"schema": "dast",
"document": {
"type": "root",
"children": [
{
"type": "heading",
"level": 2,
"children": [
{
"type": "span",
"value": "Netus"
}
]
},
//...
]
}
}
},
"author": {
"name": "Makenna",
"avatar": {
"url": "https://www.datocms-assets.com/144276/1729500256-raul.avif",
"id": "fNh4cWO6TreUpLtRTesr8w"
}
}
}
}
}

Once we were sure the CMS stuff works, off we went into the barebones frontends using each framework.

The “Design”

To keep things light, we ended up not choosing any fancy CSS framework or complex styling. Between Tailwind, Chakra, Blaze, and so many others, it's a highly subjective matter anyways, so we opted to have a consistent globals.css shared between all projects, and snuck in a few cheeky inline CSS params for some sections (mostly out of laziness than anything else).

The end result was a very Halloween-ey looking black and orange blog that won't win any design awards.

The posts themselves were just rendered using DatoCMS's structured text packages for React, Vue, and Astro (with the exception of Svelte which uses structured-text-to-html-strings). These too had no added styling, just rendering the content with a bit of padding here and there, and not applying any additional asset optimizations.

Working with each Framework

I've previously played around with Next and Astro before, so they were quicker to set up, but given the great documentation and community, and I had no complaints diving into Vue and Svelte as well. From the DX side of things, I guess, this is where a "work with what you like" is more valid than anything else, there wasn't one framework that did anything dramatically different or out of convention.

With Next.js

We took a very simple approach getting started with a npx create-next-app@latest next-demo, and opted to use the app router, without TypeScript or Tailwind, since we didn't want to bring in any heavy CSS into the project.

This led to a rather minimal list of core dependencies, with as little "bloat" as Next would allow in a setup like this.

├── @babel/core@7.25.8
├── @datocms/cda-client@0.2.2
├── babel-eslint@10.1.0
├── dotenv@16.4.5
├── eslint-config-next@15.0.0
├── eslint@8.57.1
├── next@15.0.0
├── react-datocms@7.0.3
├── react-dom@18.3.1
└── react@18.3.1

Next offers Geist as a local font out of the box, so we retained it - with the other frameworks defaulting to system fonts.

Our structure was obscenely minimal, with a component created for a Nav, a homepage, and a blog page and post page nested under a blog directory, all using a barebones layout styled with a globals.css

Since we wanted to mimic Astro's approach to prefetching, Next.js’s getStaticProps and getStaticPaths were core here. Given that we had 10K blog posts, we opted for prerendering every page and post at build time.

export async function getStaticPaths() {
const posts = await getAllPosts();
const paths = posts.map((post) => ({ params: { slug: post.slug } }));

Prerendering 10K+ pages was a breeze. While the build time increased, the result was a blazing-fast static site. The fully prerendered pages made page loads almost instantaneous.

I found Next the "easiest" to work with given that it's incredibly well documented, and being the fave of the React community, the available resources to getting started were the easiest. I found it to be the most "common sense" approach to building a website, but that's already biased since I've mainly worked with React in the past.

With Nuxt.js

We kicked off the Nuxt project using the npx nuxi init setup. We opted out of TypeScript and Tailwind for the sake of simplicity and to match our “no CSS framework” rule across all projects.

Nuxt’s core dependencies were minimal and straightforward, but it did include a few Vue-specific packages like vue-datocms for rendering structured text. Here’s the lean list we ended up with:

├── @nuxtjs/eslint-config@9.0.0
├── dotenv@16.4.5
├── vue-datocms@1.0.0
├── nuxt@3.14.0
└── graphql-request@5.2.0

The folder structure was minimal, similar to our Next.js project. We used a standard /pages directory for routing, with /pages/blog/index.vue listing out all the posts and /pages/blog/[slug].vue for individual posts. The routing was straightforward with Vue’s NuxtLink, and everything played nicely with minimal boilerplate.

What I liked here with Nuxt was the automatic data fetching capabilities with useAsyncData. Given it was the first time I'd used it, we didn’t need to set up custom server-side data fetching hooks or deal with any complexity. Instead, we just fetched data directly in the page components using useAsyncData, keeping the code clean.

Nuxt’s handling of useRuntimeConfig also made it really easy to manage the DatoCMS API.

Since we wanted to prerender everything at build time though, there were some quirks Nitro, especially when attempting to pre-generate all 10K+ post pages. However this was a super simple configuration using generateRoutes to get all posts from the DatoCMS API by creating a script:

import { getAllPosts } from '../lib/datocms.js';
export async function generateRoutes() {
const posts = await getAllPosts();
return posts.map(post => `/blog/${post.slug}`);
}

And having hooks run this async in the nuxt.config.ts

hooks: {
async 'nitro:config'(nitroConfig) {
const routes = await generateRoutes();
nitroConfig.prerender = nitroConfig.prerender || {};
nitroConfig.prerender.routes = ['/', '/blog', ...routes];
},
},

In the end, Nuxt’s DX was also really nice. It took me some getting used to with the whole template-before-script flip around script, but the framework was still fun to work with.

With Astro

Astro might have been my fave to work with in this even though I was more familiar with Next. Known for its “content-focused” approach and "0 JS" approach, I tweaked the comparison to put all other frameworks head to head with its' pre-render everything direction. I felt it was the perfect candidate for a site like this with 10K blog posts. We started the project with npm create astro@latest and went with the default choices, skipping any CSS framework.

Astro’s dependency list was slim and to the point, relying heavily on Astro’s built-in features:

├── @astrojs/node@1.0.0
├── dotenv@16.4.5
├── @datocms/astro@1.1.0
├── graphql-request@5.2.0
└── astro@3.5.0

We used Astro components for the core structure and pulled in React for structured text rendering with @datocms/astro.

Astro’s routing was the simplest of all for me. We just placed our pages in /src/pages with /blog.astro for the index and /blog/[slug].astro for individual posts. Astro’s DX was exceptional, with clear error messages and fantastic documentation.

In many ways it felt like going back to Next's src structure before app-router, back when Next felt simpler and more lightweight with a focus on websites.

Astro’s default mode is to prerender everything. While this made the setup easy, it also resulted in very long build times by default when generating all posts. Astro’s strength is supposed to lie in its focus on performance and content rendering. For content-heavy websites, Astro’s approach of fully pre-rendering everything makes it the perfect choice if you want a fast, SEO-friendly site without any client-side JS bloat.

With Svelte

SvelteKit was the most unfamiliar territory for me, but I'd been looking for an excuse to try Svelte for a while. Following a npm create svelte@latest, I opted for Svelte 5, which at the time was 2-3 days freshly released.

Similar to all, the core deps were minimal. Svelte’s compiler does most of the heavy lifting, so we didn't need a lot to get started:

├── @sveltejs/adapter-node@5.2.8
├── dotenv@16.4.5
├── graphql-request@5.2.0
├── svelte@5.0.0
├── svelte-preprocess@5.1.0
└── svelte-kit@1.0.0

Svelte’s file-based routing was reminded me of Next in many ways, but getting used to typing + all the time took some getting used to. We placed our pages under src/routes, with /blog/+page.svelte for the blog index and /blog/[slug]/+page.svelte for individual posts.

Data fetching in SvelteKit is usually done with load functions in +page.server.js files. This made server-side data fetching using graphql-request intuitive.

Prerendering with SvelteKit was straightforward but required some more configuration. We used adapter-node to deploy our app and enabled full prerendering via sveltePreprocess in svelte.config.mjs.

import { sveltePreprocess } from 'svelte-preprocess';
import adapter from '@sveltejs/adapter-auto';
export default {
preprocess: sveltePreprocess(),
kit: {
adapter: adapter(),
alias: {
$lib: './src/lib',
},
},
};

This allowed us to generate all blog post pages at build time. SvelteKit’s build process was incredibly fast, even with this massive amount of content.

SvelteKit’s DX was the most unique. It felt more “bare metal” compared to the other frameworks, and once I got a bit more familiarised with the syntax, it felt more "natural language-y" to type.

The deploying and compiling

I opted to deploy all of them on Vercel since it was what I was the most familiar with - and as far as I could see, CF pages needed some extra config to work with some frameworks that I wasn't too keen on setting up at the moment.

With consistent deployment experience across frameworks, I just went with it.

While I played around with several deployments here and there, here's an "average" I found for each project when not pre-generating all paths at build time .

FrameworkFirst BuildBuild with cache
Astro31s~12s
Next.js47s~18s
Nuxt43s~15s
Svelte39s~14s

Vs. a comparison when pre-generating all paths at build time for just 1,250 posts.

FrameworkAv. Fully Prerendered Build Time
Astro54s
Next.js72s
Nuxt67s
Svelte58s

At 1K posts, each framework barely had anything above one another, so we thought, why not stress test it a bit more, and redo this experiment with 10K posts.

That's where some interesting insights came out. Here's the comparison on the initial build times for 10K posts,

FrameworkInitial Build Time
Astro5m42s
Next.js4m37s
Nuxt4m59s
Svelte4m23s

vs. the build times averaged out once we had caches in place.

FrameworkAv. build time with cache
Astro4m44s
Next.js3m28s
Nuxt4m3s
Svelte3m51s

This is where I learned about rendering engines 😅 Astro uses Vite’s SSR capabilities, which can be slower for really large-scale static generation because it re-parses and compiles every .astro file. Next.js is built on top webpack which is more efficient in handling large numbers of pre-generated pages.

Behind the scenes, the dependencies and overall bundle size impacted the build output too of course, and here too, with it's minimal approach, Astro really shined though.

FrameworkCore DependenciesBuild Output
Astro44.2MB
Next.js96.8MB
Nuxt55.5MB
Svelte94.8MB

And finally, on the client side, the same trend can be seen with the overall load on the browser. Astro's zero-JS approach makes it the lightest on the frontend, with Next remaining as the largest.

FrameworkJS BundleInitial JS
Astro0KB0KB
Next.js120KB78KB
Nuxt90KB65KB
Svelte25KB15KB

The performance roundup

And finally, everyone's fave flex metric - the Lighthouse.

We ran the Lighthouse on each page type of each project to compare them head to head - keeping in mind that we haven't done any fancy custom OG customizations or anything beyond a sitewide default.

Another small note - they'd all most likely be "100" if I spent more time optimizing assets and minimizing some JS, but without any of those best practices added in, this is what I ended up with:

FrameworkPerformanceAccessibilityBest PracticesSEO
Astro99100100100
Next.js95100100100
Nuxt96100100100
Svelte97100100100

So, in the end, Astro won it for me for pure content sites given the following:

  • Zero JavaScript by default meant fastest loading times and best Lighthouse scores giving me better performance.

  • Minimal configuration and templating gave me the "simplest" DX.

  • I had consistent build times.

  • Considering it needed minimal dependencies and smallest client-side resource usage, it also had the smallest footprint.

All things considered, I consider Astro as my winner. Blowing up the scope of the project did start skewing the performance metrics back in favour of Next, but Astro felt really fun to work with, and if a site isn't tremendously massive and/or not content focused, I think Astro can be a really great choice.

Though does any of this matter? Don't listen to me, keep using whatever you're most comfortable with 😅

PS: Shoutout to Silvano, Marco, and Marcelo from the team for babysitting me through Vue, Svelte, and the CMA 🫶🏽