Modes: SPA, MPA, SSG, SSR, CSR

MPA / SSR is the base on which Gracile is built.
You can transform your project for two types of targets: “Server” and “Static”.

Static

Static-site generation (aka SSG) is technically ahead of time server-side rendering (aka SSR).
You’ll get a portable artifact to deploy pretty much anywhere, with HTML indexes, JS/CSS bundles and other static assets.

Server

Server side renders HTML pages on user request, provides API endpoints, serve static client assets and pre-rendered pages.

To fit nicely within your existing setup, Gracile builds an handler that you can consume within your app, with a dedicated or custom adapter.

The Gracile server handler/adapter combo can play nicely with WinterCG or Node HTTP-like runtime and frameworks.

Definitions

Single Page Application (SPA)

The Vite Lit SPA template, with npm create vite -t lit for example, is already a great base for making highly dynamic, client web apps.

Keeping this in mind, you might want to upgrade to something a bit more featureful as soon as the project grows, without breaking your existing Vite setup.

That’s where Gracile comes into play: per-route splitting is a very powerful feature that, in itself, is an incentive to use a full-stack framework.
Combined with CSR, view transitions, or just for serving well-separated parts of your app, you’ll also get the folders to URL mapping convention, which has proven to greatly benefit decent-sized projects/teams.

It’s not an “all or nothing” approach. Some leaves of your apps can be SPA-like, others can be MPAs, with or without client-side routing augmentation.
You might even want to serve different, technically unrelated SPAs from a single build/deployment.

SPAs are typically deployed from a static distributable.
With Gracile you’ll use the static output for that case.

Multiple Pages Application (MPA)

Every page will get pre-route assets splitting.

MPA can be static or server-side rendered, but unlike SPA they will do a full refresh on a route change, meaning you’ll have to maintain the client state in different ways (there are plenty!).

However, it’s possible to augment an MPA with Client-Side routing (see below).

Client-Side Routing (CSR)

Client-Side Routing in the case of an SPA, is entirely done from the bundled app running in the browser.

At first glance, this seems to be irreconcilable with the file-based, old-school web server’s way of handling routing tasks.

However, it’s possible to combine the best of both worlds:

The last one is especially important when your app grows.
While it’s achievable to lazy load code and assets with CSR, the bundler will do a better job with already smaller chunks as a starting point.
That’s what the per-route splitting strategy is providing.

Note

For now, the Gracile CSR add-on is under an experimental phase.
It will work with both “Static” and “Server” modes.

Static mode (SSG)

This is the default output for Gracile the vite build phase.

📄 /vite.config.ts

import { defineConfig } from 'vite';
import { gracile } from '@gracile/gracile/plugin';

export default defineConfig({
  plugins: [
    gracile({
       This is the default, no need to set it up!
      output: 'static',
    }),
  ],
});

With a statically generated output, you’ll get access to those context information: props and params.
Also, you get the chance to populate the props via staticPaths for dynamic route paths.

Server mode

During a server build phase, Gracile will output two distributables: server and client.

The server handler is provided to do upfront work before rendering the page and outputting a Response. Then, you can use it via an adapter with your favorite HTTP server framework.


First setup Vite:

📄 /vite.config.ts

import { defineConfig } from 'vite';
import { gracile } from '@gracile/gracile/plugin';

export default defineConfig({
  plugins: [
    gracile({
      output: 'server',
    }),
  ],
});

Supported environments

The last one is possible by creating your very own adapter for your environment of choice.

Note

Under the hood, the Gracile Handler only require a standard Request input, and returns a Response or a Node’s Readable. The Readable is used because there isn’t widespread support for the standard ReadableStream.from method at the moment. Also, for streaming usage, node:http can only handle Readables.
For now, it would not make sense to do unwanted back and forth convertion, hurting performance for unicity sake.

Express

This is a working base configuration for Express, see also the starter project for it.

📄 /server.js

import express from 'express';

import * as gracile from '@gracile/gracile/node';

import { handler } from './dist/server/entrypoint.js';

const app = express();

app.use(express.static(gracile.getClientBuildPath(import.meta.url)));

app.use((req, res, next) => {
   The Express adapter will pick-up your locals automatically.
  res.locals.requestId = crypto.randomUUID();
  res.locals.userEmail = req.get('x-forwarded-email') || null;
  return next();
});

app.use(gracile.nodeAdapter(handler)); 

const server = app.listen(
  3030,
  () => gracile.printUrls(server.address()),
);

Locals with Express

📄 /src/ambient.d.ts

/// <reference types="@gracile/gracile/ambient" />

declare namespace Gracile {
  interface Locals {
    requestId: import('node:crypto').UUID;
    userEmail: string | null;
  }
}
 This is how you synchronise both Gracile and Express global Locals!
declare namespace Express {
   You can do it in the other way, too; Gracile extending Express.
  interface Locals extends Gracile.Locals {} 
}

Hono

This is a working base configuration for Hono, see also the starter project for it.

📄 /server.js

import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';

import * as gracile from '@gracile/gracile/hono';

import { handler } from './dist/server/entrypoint.js';

 This is how you synchronise both Gracile and Hono global Variables!
/** @type {Hono<{ Variables: Gracile.Locals }>} */
const app = new Hono();

app.get(
  '*',
  serveStatic({ root: gracile.getClientBuildPath(import.meta.url) }),
);

app.use((c, next) => {
   The Hono adapter will pick-up your locals (called "variables" by Hono) automatically.
  c.set('requestId', crypto.randomUUID());
  c.set('userEmail', 'admin@admin.home.arpa');
  return next();
});

app.use(gracile.honoAdapter(handler)); 

serve(
  { fetch: app.fetch, port: 3030, hostname: gracile.LOCALHOST },
  (server) => gracile.printUrls(server),
);

Locals with Hono Variables

📄 /src/ambient.d.ts

/// <reference types="@gracile/gracile/ambient" />

declare namespace Gracile {
  interface Locals {
    requestId: import('node:crypto').UUID;
    userEmail: string | null;
  }
}
📄 /server.ts

const app = new Hono<{ Variables: Gracile.Locals }>();

📄 /server.js

 Use JSDoc
/** @type {Hono<{ Variables: Gracile.Locals }>} */
const app = new Hono();

Creating an adapter

You can use Gracile with an HTTP server that works with either Node’s own http IncomingMessage/ServerResponse or a framework that supports the standard WHATWG Request/Response pair.
Note that Node Readable stream API support is required by your JS runtime.

For the most straightforward reference, check how the Hono adapter is made under the hood. It can be a good inspiration for making an adapter for another WHATWG-friendly runtime.

Alternatively, for Koa, Fastify etc., check out Gracile’s source code for the node:http adapter. It should be remotely similar to an Express setup, with minor modifications.
You can then open a PR if you’re willing to contribute back to the community.

Supported deployment environments

Please kindly note that for now, Lit SSR is officially supported on Node.js APIs compatible runtimes, as well as “serverless” Vercel functions.
There is ongoing work for official support across more environments, like WinterCG-compatible runtimes (Deno, Netlify, Cloudflare, Alibaba…) and possibly more (Bun, Service Workers…).

ReadableStream implementations can vary (e.g. ReadableStream.from is not available everywhere), but as soon as this API is more stable and featureful as Node’s Readable, Gracile internals will adopt it for streamlining its rendering phase.

Static assets (JS, CSS, pre-rendered pages…)

All you need to do is to serve the client/dist from the /* route path of your HTTP framework static assets handler.

Be sure to catch those requests before the Gracile handler kicks in, like for any other public assets.

Ahead-of-time, pre-rendered pages follow the same convention as any static assets.
The client/dist/my-route/index.html with be served to the /my-route URL path name.
See also prerender in defining routes.

If you have any doubts, see how static assets are handled in the starter projects.

Smart caching (advanced)

This is an advanced technique that requires more setup than Gracile owns pre-rendered page, but also offers many more possibilities:

Pre-rendered pages happen once (ahead of time), and it’s unaware of the future context from which the request happens.
With smart caching strategies, you can rely on your infrastructure to handle this in a dynamic, on-demand way.

Since the 2020s, ‘“Stale-While-Revalidate” and “Content Cachepolicies have become more widespread.
It’s now relatively straightforward to serve cached pages, efficiently with Vercel, Netlify, CloudFlare or Nginx. Remix or Fresh are doing this approach successfully.

Also combining prefetch on hover/focus on links can pre-trigger the necessary work and “revive” expired cache before the user gets access to the content; if it’s quick enough. You could probably shave ~300ms with this.

Nginx web server

Use proxy_cache_background_update with Nginx

This is an example snippet. You should customize it further, like targeting mostly static routes, where data freshness is not critical (i.e., not on multi-page forms). And don’t forget that response headers may be used too, for granular and dynamic control.

proxy_cache_key $scheme://$host$uri$is_args$query_string;
proxy_cache_valid 200 10m;
proxy_cache_bypass $arg_should_bypass_cache_xyz1234;
proxy_cache_use_stale updating error timeout http_500 http_502 http_503 http_504 http_429;
proxy_cache_lock on;
proxy_cache_background_update on;

”Serverless” platforms