No.JS works seamlessly with static site generators and server-side pre-rendering. Because the framework initializes on top of existing HTML — rather than replacing it — you can deliver fully pre-rendered pages that No.JS then enriches with reactivity, without any build step or special server support.
No.JS reads its initial state from the state= attribute on any element. If you pre-populate that attribute with your data before the browser renders the page, the content is immediately available — no fetch required, no flicker, 100% indexable by Googlebot.
<!-- HTML generated by your SSG / server with inline data -->
<div id="app" state='{"product": {"name": "Sneaker X", "price": 299, "slug": "sneaker-x"}}'>
<h1 bind="product.name"></h1> <!-- "Sneaker X" — in initial HTML -->
<span bind="'$' + product.price"></span> <!-- "$299" — in initial HTML -->
</div>When No.JS initializes, it reads the state= value, creates a reactive context, and binds normally — but since the content is already rendered, the user sees it instantly and search engines index it without needing to execute JavaScript.
For pages where data changes infrequently (blog posts, product listings, landing pages), generate the HTML with data inline at build time using any SSG or templating tool.
Eleventy example (product.njk):
---
layout: base.html
---
<div id="app" state='{{ product | dump }}'>
<h1 bind="product.name"></h1>
<p bind="product.description"></p>
<button post="/api/cart/add" body='{"id":"{{ product.id }}"}'>
Add to cart
</button>
</div>
{{ product | dump }}is a Nunjucks filter that serializes theproductobject to a JSON string. Equivalent filters exist in other template engines:{{ product | to_json }}in Jinja2,{{ product | json }}in Liquid.
The state= value is set at build time from the template data. No.JS boots, reads it, and makes the "Add to cart" button interactive — but <h1> and <p> were already in the HTML.
For dynamic data (user-specific pages, real-time content), your server generates the state= attribute at request time.
Node.js / Express example:
app.get('/products/:id', async (req, res) => {
const product = await db.products.findById(req.params.id);
res.render('product', {
state: JSON.stringify({ product })
});
});<!-- product.html template -->
<div id="app" state="<%= state %>">
<h1 bind="product.name"></h1>
</div>You can combine static content with dynamic updates. Render the initial state server-side for SEO, and let No.JS refresh it client-side on user interaction:
<!-- Pre-rendered initial state for Googlebot -->
<div id="app" state='{"product": {"name": "Sneaker X", "stock": 12}}'>
<h1 bind="product.name"></h1>
<span bind="product.stock + ' in stock'"></span>
<!-- Dynamic: only runs after user interaction, not needed for SEO -->
<button post="/api/cart/add" body='{"id":"sneaker-x"}' then="cart.count++">
Add to cart
</button>
</div>| Content type | Approach | Why |
|---|---|---|
| SEO-critical (title, description, main content) | state= pre-rendered |
Indexed immediately, no fetch required |
| User-specific (cart, preferences, session) | get= with JS |
Changes per user — can't be pre-rendered |
| Frequently updated (stock levels, prices) | get= with refresh= |
Needs to be live — pre-render for fallback only |
| Static references (nav menu, categories) | state= pre-rendered |
Rarely changes, should be in initial HTML |
Content a search engine should index → put it in state= (pre-rendered)
Content that changes per user/session → use get= (fetched client-side)
SEO-critical content placed inside a get= directive will only appear after the fetch completes. While Googlebot does execute JavaScript, there is typically a delay of several days before dynamically loaded content is indexed. For product names, page titles, descriptions, and canonical URLs, pre-rendering is strongly recommended.
npm install @11ty/eleventy_site/ ← built HTML output (deploy this)
_includes/
base.html ← base layout
pages/
product.njk ← product page template
_data/
products.json ← your data
.eleventy.js
.eleventy.js:
module.exports = function(eleventyConfig) {
eleventyConfig.addPassthroughCopy("assets");
return { dir: { output: "_site" } };
};pages/product.njk:
---
layout: base.html
pagination:
data: products
size: 1
alias: product
permalink: /products/{{ product.slug }}/
---
<div id="app" state='{{ product | dump }}'>
<h1 bind="product.name"></h1>
<p bind="product.description"></p>
</div>_includes/base.html:
<!DOCTYPE html>
<html>
<head>
<title>{{ product.name }} | My Store</title>
<meta name="description" content="{{ product.description }}">
<script src="/assets/no.js" defer></script>
</head>
<body>{{ content | safe }}</body>
</html>Run npx eleventy and your _site/ directory contains fully pre-rendered HTML pages that No.JS enriches with interactivity.
When embedding data in state=, always HTML-escape the JSON value to prevent XSS:
// Node.js — safe embedding
const stateJson = JSON.stringify(product).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
// → use stateJson as the state= attribute valueMost template engines (Jinja2, Nunjucks, Twig, Liquid) escape attribute values by default. Verify that your SSG does not disable escaping for state= values.
When your SSG renders each page with its own <title> and
<meta name="description"> server-side, those tags are already in the
initial HTML — no client-side directive is needed for static content.
For SPA routing (single index.html with a No.JS router), the server
renders only one HTML file and the router handles navigation. Use the
page-title, page-description, page-canonical, and page-jsonld
attributes directly on <template route> elements to update <head> on
each navigation — see Route Head Attributes →.
For non-routing pages (product pages, landing pages without a router),
the page-title and page-description directives from
Head Management → provide the same capability as
a body directive with full reactive support.
Next: Data Fetching →