Configuration & Content
This guide covers the day-to-day building blocks of an @sigx/ssg site: configuration, file-based routing, layouts, collections, MDX, hydration, and the generated sitemap. Every snippet uses the public @sigx/ssg import path.
Configuration
Author your config with defineSSGConfig (or its alias defineConfig) and export it as the default from ssg.config.ts:
import { defineSSGConfig } from '@sigx/ssg';
export default defineSSGConfig({
pages: 'src/pages',
layouts: 'src/layouts',
outDir: 'dist',
base: '/',
site: {
title: 'My Site',
description: 'Built with @sigx/ssg',
url: 'https://example.com',
lang: 'en',
},
defaultLayout: 'default',
});
defineSSGConfig applies defaults before merging your config:
pagesissrc/pages,layoutsissrc/layouts,contentissrc/content.defaultLayoutisdefault,outDirisdist,baseis/.prefetchandspaNavigationare enabled,site.langisen.markdown.shikiis on;tocusesminLevel: 2,maxLevel: 3.linkCheckis'warn';searchis off.trailingSlashis'always'— derived public URLs (canonical,og:url, sitemap<loc>) end with/for folder routes, matching the<path>/index.htmloutput that static hosts serve with 200. Set'never'only when your host serves the slash-less URL directly.
base works in both directions: when not set, SSG inherits Vite's base; when set in ssg.config.ts, it is passed to the production Vite builds, so the sitemap, canonical URLs, router, and asset URLs all stay in sync for sub-path deploys.
The markdown.* and toc.* options apply whether you set them in ssg.config.ts or pass them as ssgPlugin() arguments — both feed the same MDX pipeline, with plugin arguments taking precedence.
Other notable options:
spaNavigation(defaulttrue) — internal anchor clicks (including plain markdown links in MDX) navigate through the router instead of full page reloads. Opt out per link with adata-no-spaattribute, or globally withspaNavigation: false. See SPA navigation.sitemap—SitemapOptions, orfalseto skip sitemap.xml/robots.txt entirely. See Sitemap and robots.txt.redirects— a{ '/old': '/new/' }map of static redirects. See Redirects.search—true(orSearchOptions) to emit asearch-index.jsonover the rendered pages. See Built-in search.llms—true(orLlmsOptions) to emitllms.txt,llms-full.txt, and per-page markdown renditions for LLM consumption. See LLM-friendly output.linkCheck—'warn'(default),'error', or'off'for internal link & anchor validation. See Link checking.routes— a function that returns programmatic routes merged with the filesystem scan. See Programmatic routes.data— build-time data loaders exposed viavirtual:ssg-data. See Build-time data loaders.hooks— build pipeline hooks. See Build hooks.clientEntry/serverEntry/htmlTemplate— explicit entry/template paths that win over convention detection (htmlTemplate: falseforces the generated template). A configured path that doesn't exist fails the build.navigation.sectionOrder— sidebar category order. See Sidebar order.clientImports— side-effect imports added to the generated client entry; the usual home for'@sigx/ssg/styles.css', the base stylesheet for ssg-emitted markup (code windows, package-manager tabs, heading anchors, dual-theme Shiki). See The base stylesheet.
File-based routing
Files under src/pages map to routes by their path. Supported page extensions are .tsx, .jsx, .mdx, and .md.
| File | Route |
|---|---|
index.tsx | / |
about.tsx | /about |
blog/index.tsx | /blog |
blog/[slug].tsx | /blog/:slug |
docs/[...path].tsx | /docs/*path |
Optional segments are also supported: [[id]] becomes :id? and [[...slug]] becomes *slug.
Routes are sorted by specificity: static beats dynamic (:param) beats catch-all (*).
Excluded from routing
- Root-level
components/,hooks/,utils/, andlib/folders. - Any file or folder starting with
_(at any level). *.test.*and*.spec.*files.
Dynamic routes need getStaticPaths
A dynamic route must export getStaticPaths() returning an array of StaticPath objects, each with params and optional props. The build resolves getStaticPaths from the built SSR bundle, so it can live in .tsx and .mdx pages alike. Routes without it are skipped with a warning (SSG102); a getStaticPaths that throws fails the build.
Route params and per-path props arrive as component props (props.params.slug, plus your props spread at the top level) — in SSR, at hydration (the build embeds them), and after client-side navigation.
A src/pages/404.* page is emitted as a root 404.html — the not-found convention static hosts serve.
import { component } from 'sigx';
export async function getStaticPaths() {
return [
{ params: { slug: 'hello-world' } },
{ params: { slug: 'second-post' }, props: { featured: true } },
];
}
export default component<{ params: { slug: string } }>(({ props }) => {
return () => <article>Post: {props.params.slug}</article>;
});
Layouts
Layouts live in src/layouts. A layout default-exports a component and renders the page content through slots.default?.():
import { component } from 'sigx';
import type { LayoutProps, LayoutSlots } from '@sigx/ssg';
export default component<LayoutProps, unknown, LayoutSlots>(({ slots }) => {
return () => (
<div class="site">
<header>My Site</header>
<main>{slots.default?.()}</main>
</div>
);
});
A page selects its layout, in order of precedence, via:
- Frontmatter
layout:(MDX/Markdown). - An exported
export const layout = '...'. - The collection config.
- The config
defaultLayout(defaults todefault).
Themes bundle layouts, components, and CSS in a package referenced by the config theme field. A theme's exported config can also contribute markdown plugins, head tags, CSS imports, build hooks, and a default layout — your own config always wins. Layouts receive the whole site config as props.site (including the site.nav, site.logo, and site.repo branding fields), so themes render your branding instead of hardcoding their own. See Themes for authoring and consuming a theme, and the prebuilt @sigx/ssg-theme-daisyui.
Layouts also receive the current page's meta as props.meta and the route path as props.path, so they can render edit-this-page links, last-updated stamps, and breadcrumbs (see Per-page metadata).
MDX and Markdown
Frontmatter is parsed with gray-matter and is available in MDX expressions through frontmatter:
---
title: My Post
description: A post written in MDX
category: Blog
order: 10
---
# {frontmatter.title}
Body content with **GFM**, autolinked headings, and Shiki highlighting.
Defaults that apply to content pages:
- Shiki syntax highlighting is on (light:
github-light, dark:github-dark). - GFM,
rehype-slug, and autolinked headings are enabled. - Table of contents headings are extracted between
toc.minLevel(2) andtoc.maxLevel(3).
Navigation-related frontmatter fields come from PageMeta: category (a string, or an array for nesting), order, sidebar, draft, toc, and ssr. SEO and per-page chrome fields are documented under Per-page metadata.
Code highlighting and Shiki
Set markdown.shiki to a ShikiConfig to tune highlighting:
import { defineSSGConfig } from '@sigx/ssg';
import { transformerNotationDiff } from '@shikijs/transformers';
export default defineSSGConfig({
markdown: {
shiki: {
light: 'github-light',
dark: 'github-dark',
langs: ['ts', 'bash'],
transformers: [transformerNotationDiff()],
skipLanguages: ['mermaid', 'math'],
},
},
});
transformers— Shiki transformers applied to every highlighted block (including each package-manager variant). The raw fence meta is exposed to transformers asoptions.meta.__raw, so the@shikijs/transformersmeta-driven features (line highlighting{1,3-5}, diff, focus, word highlight) work without any core changes.skipLanguages— fence languages the highlighter leaves untouched, so the raw<pre><code class="language-…">survives in the output for a later rehype plugin or island to claim — e.g.['mermaid', 'math'].
Package-manager code fences
A shell fence whose lines are npm/pnpm/yarn/bun commands (install/add, run, dlx, create, remove, …) is rendered with an npm/pnpm/yarn/bun tab strip and all four variants pre-rendered server-side. The client switcher (from @sigx/ssg/client) only flips which variant is visible — it never rewrites highlighted text. shiki.defaultPackageManager (default 'pnpm') chooses which variant shows first; a visitor's saved choice overrides it. See Client runtime → Package-manager switcher for the programmatic API.
Draft pages
Pages with draft: true frontmatter are visible in dev but excluded from production builds and the sitemap. To preview drafts in a production build, pass --drafts to sigx ssg build (or drafts: true to the programmatic build()).
Collections and navigation
A collection groups pages under a path prefix and generates its own sidebar from their category / order frontmatter. A page is assigned to a collection on a path-segment boundary — /docs claims /docs and /docs/intro, but never /docs-internal — and to the most specific (longest-matching) collection when several apply, so nested collection paths are fully supported. A page like /modules/updates-ui/x belongs to the /modules/updates-ui collection, not the shorter /modules/updates one.
import { defineSSGConfig } from '@sigx/ssg';
export default defineSSGConfig({
collections: {
docs: {
path: '/docs',
layout: 'docs',
showDrafts: 'dev',
},
},
});
You can also supply explicit navigation, or rely on auto-generation (the default):
import { defineSSGConfig } from '@sigx/ssg';
export default defineSSGConfig({
navigation: {
autoGenerate: true,
showDrafts: 'dev',
sidebar: [
{
title: 'Getting Started',
order: 1,
items: [
{ title: 'Introduction', href: '/docs/intro' },
{ title: 'Setup', href: '/docs/setup' },
],
},
],
},
});
At runtime, navigation is consumed through the virtual:ssg-navigation module, which exposes getSidebar, getCollectionNav, and detectCollection:
import nav, { getSidebar, detectCollection } from 'virtual:ssg-navigation';
const collection = detectCollection('/docs/intro');
const sidebar = collection ? getSidebar(collection) : nav.navigation;
detectCollection applies the same rule the build uses: it matches on a path-segment boundary and returns the longest-matching collection, so a path under a nested collection resolves to that nested collection rather than its parent.
Client and island hydration
The auto-generated client entry imports ssrClientPlugin (re-exported from @sigx/ssg/client) and hydrates #app; the server entry uses the SignalX server renderer's renderToString. The wiring it generates looks like this:
import { defineApp, component } from 'sigx';
import { createRouter, createWebHistory } from '@sigx/router';
import { ssrClientPlugin } from '@sigx/ssg/client';
import routes from 'virtual:ssg-routes';
import { setupLayouts, LayoutRouter } from 'virtual:generated-layouts';
const router = createRouter({
history: createWebHistory({ base: '/' }),
routes: setupLayouts(routes),
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition;
if (to.hash) return { el: to.hash };
return { top: 0 };
},
});
const App = component(() => () => <LayoutRouter />);
defineApp(<App />).use(router).use(ssrClientPlugin).hydrate('#app');
Selective hydration (islands) works with @sigx/ssr-islands via client:* directives, so you can ship static HTML and hydrate only the interactive parts.
Link prefetch
Prefetch-on-hover is enabled by default (config prefetch, with a 100 ms delay). The generated client entry wires it automatically. From @sigx/ssg/client you can also call the helpers directly:
import { prefetch, setupPrefetch } from '@sigx/ssg/client';
setupPrefetch({ delay: 150 });
prefetch('/blog/hello-world');
Other client helpers detect rendering mode and read embedded state:
import { isStaticPage, getInitialState } from '@sigx/ssg/client';
if (!isStaticPage()) {
const state = getInitialState<{ user: string }>();
}
Build hooks
Three hooks in ssg.config.ts extend the production build (they run on builds only, not the dev server) — the slots for feeds, custom indexing, HTML post-processing, and other build-time side effects:
export default defineSSGConfig({
hooks: {
transformHtml(html, page) {
// page: { path, params, props?, meta?, route }
return html.replace('</head>', '<meta name="generator" content="@sigx/ssg"></head>');
},
onPageRendered(page) {
// page is the build-result entry plus the final html: { path, file, time, size, meta?, html }
},
async postBuild(result, ctx) {
// result.pages lists every written page; ctx is { outDir, config }
},
},
});
transformHtml(html, page)runs per page after head tags and app HTML are injected, returning the HTML to write.onPageRendered(page)observes each page after it is written (the build-result entry plus itshtml).postBuild(result, ctx)runs exactly once after every page is written and the sitemap is generated;ctxis{ outDir, config }.
The per-page hooks run concurrently and in no guaranteed order, so avoid unsynchronized shared state beyond append-only collection. A hook that throws fails the build. A theme can contribute its own hooks; the theme's run first, then yours (transformHtml chains the output through both).
Sitemap and robots.txt
build() writes sitemap.xml and robots.txt automatically. You can also generate them programmatically:
import { build, generateSitemap, generateRobotsTxt } from '@sigx/ssg';
const result = await build();
const xml = generateSitemap(
[{ path: '/', priority: 1.0, changefreq: 'weekly' }],
config,
);
const robots = generateRobotsTxt(config);
Entries are prefixed with site.url + base, and folder-route paths are normalized per the trailingSlash policy (default 'always': /about → https://example.com/about/), byte-identical with the canonical and og:url emitted in each page's head. The same normalization is exported as normalizePagePath(path, trailingSlash?). When mapping built pages, priority is derived from depth (/ = 1.0, depth 1 = 0.8, depth 2 = 0.6, otherwise the default 0.5) and the default change frequency is weekly. Pages with robots: noindex and the /404 page are excluded automatically, and a public/robots.txt you ship is never overwritten.
SitemapOptions carries exclude (glob/exact path matches), additionalUrls, defaultChangefreq, and defaultPriority. Freshness has three layers, applied in order — the later wins:
export default defineSSGConfig({
sitemap: {
exclude: ['/draft/**'],
lastmod: 'git', // 'git' | 'mtime' | false (default)
transform(entry, page) {
// adjust an entry, or return null/undefined to drop it
return entry;
},
},
});
sitemap.lastmod— derive<lastmod>per page from the source file.'git'uses each file's last commit date (one repo-widegit logwalk — needs full history, so setfetch-depth: 0in CI);'mtime'uses the filesystem timestamp. Defaultfalse.- Per-page frontmatter
lastmod,changefreq, andpriorityoverride the derived/default values for that page (an explicitlastmodalways wins over thegit/mtimederivation). sitemap.transform(entry, page)runs last as a per-entry escape hatch — adjust the entry, or returnnull/undefinedto drop it.
Redirects
A redirects map points old paths at new ones:
export default defineSSGConfig({
redirects: {
'/old-guide': '/docs/guide/',
'/legacy': 'https://example.org/elsewhere',
},
});
For each entry the build emits, at the old path, a static page with <meta http-equiv="refresh">, a canonical pointing at the target, noindex, and a fallback link — so the redirect works on any static host. It also writes a _redirects file (Netlify / Cloudflare Pages format) so hosts that support it answer with a real 301 before HTML is involved. Relative targets are prefixed with base; absolute URLs pass through untouched. A redirect whose source would overwrite a rendered page fails the build, and a _redirects file you ship via public/ is appended to (your hand-written rules stay first, so they keep precedence).
Built-in search
Set search: true to emit a search-index.json over the rendered pages at build time — one entry per page with its title, description, headings, tags, and visible text (noindex pages and the 404 page are excluded, like the sitemap):
export default defineSSGConfig({
search: true,
// or tune it:
// search: { output: 'search-index.json', maxTextLength: 8000 },
});
Consume the index on the client with loadSearchIndex() and searchPages() from @sigx/ssg/client — see Client runtime → Search. To surface a theme's search UI (such as the daisyUI theme's ⌘K command palette), also set site.search: true; it reaches layouts via LayoutProps.site. Pair it with the top-level search: true so the index actually exists.
LLM-friendly output (llms.txt)
Set llms: true (or an LlmsOptions object) to emit the llms.txt convention over the built pages, so LLMs and agents can ingest the site as markdown instead of scraping HTML:
llms.txt— a markdown index:# title, a> descriptionblockquote, yourintromarkdown, then one## sectionper group of- [Title](/path.md): descriptionlinks pointing at the pages' markdown renditions.llms-full.txt— the renditions concatenated, each prefixed with a---block carrying its canonicalurl.- Per-page
.mdrenditions — a cleaned markdown file next to each markdown-sourced page's HTML: route/docs/guide/→dist/docs/guide.md, served as/docs/guide.md. - Per-area sub-indexes —
areas: { '/docs': {} }emits/docs/llms.txtscoped to pages under that prefix, for consumers that only need one area (a per-areallms-full.txtis opt-in via the area'sfull).
export default defineSSGConfig({
llms: {
intro: 'Read [the cheatsheet](/start.md) first.',
sections: [
{ title: 'Guides', collections: ['docs'] },
{ title: 'More', links: [{ title: 'Playground', href: '/play', note: 'interactive' }] },
],
full: { exclude: ['/changelog/**'] },
areas: { '/docs': {} },
exclude: ['/internal/**'],
},
});
Behavior and knobs:
- Visibility follows the sitemap —
robots: noindexpages and the 404 page are excluded (drafts never reach production builds) — plusexcluderoute globs, per-page frontmatterllms: false, and atransform(md, page)escape hatch that can adjust a rendition or returnnullto drop the page. - Sections default to one
##per collection (declaration order, links in sidebar order, frontmatterdescriptionas the note), with uncollected pages under## Other. Curate withsections— each entry pullscollections, explicitpages, and/or hand-authoredlinks. - Renditions are source-based: the page's
.md/.mdxsource with frontmatter replaced by a minimalurl/title/descriptionheader, MDX imports/exports/JSX stripped by a fence-aware scanner (fenced code is never touched), and fence info strings normalized to the bare language..tsx/.jsxpages have no rendition — they appear in the index only when named insections[].links, linked to their HTML route. public/wins — anllms.txt(or anyllms*file) you ship viapublic/is never overwritten, same courtesy as robots.txt.- Build-time only — like the sitemap, the outputs are written by
sigx ssg build; the dev server doesn't serve them.
The helper family (prepareLlmsPages, buildLlmsIndex, buildLlmsFullText, renderPageMarkdown, getMarkdownPath, writeLlmsOutputs) is exported for programmatic use — see API Reference → LLM output.
Link checking
After rendering, every internal <a href> is validated: the path must resolve to an emitted page (or a redirect source, or an on-disk asset in public/), and any #fragment must match an element id on the target page. linkCheck controls the outcome:
export default defineSSGConfig({
linkCheck: 'warn', // 'warn' (default) | 'error' | 'off'
});
'warn' reports findings, 'error' fails the build (use it in CI), 'off' disables the check. External, protocol-relative, and mailto:/tel: links are skipped. The same logic is exported as checkLinks / formatLinkCheckReport for programmatic use.
Programmatic routes
routes adds pages that don't come from the filesystem scan — CMS-backed pages, tag archives, generated indexes. It returns an array (or a promise) of routes, each pointing at a component file, and is merged with the scanned routes everywhere (dev, build, navigation):
export default defineSSGConfig({
routes: (ctx) => [
{ path: '/tags/sigx', file: './src/templates/tag.tsx', meta: { title: 'sigx' } },
{ path: '/changelog', file: './src/templates/changelog.tsx' },
],
});
Each route has a path (concrete or with :params), a file (absolute or relative to the project root), and optional layout and meta. A path collision with a scanned page, or a missing file, fails the build.
Build-time data loaders
data runs a set of loaders once per build (shared across the client and SSR builds) and once per dev-server start, exposing the results through the virtual:ssg-data module:
export default defineSSGConfig({
data: {
versions: async () => fetch('https://registry.npmjs.org/sigx').then((r) => r.json()),
},
});
import data from 'virtual:ssg-data';
console.log(data.versions);
The module also emits one named export per key (import { versions } from 'virtual:ssg-data') plus the default object. Each key must be a valid identifier (it becomes an export name), and each value must be JSON-serializable — values are baked into the bundle for both the server render and the client, so a loader that returns undefined, a function, or a non-serializable value fails the build. To type named imports, augment virtual:ssg-data in your project.
Sidebar order
navigation.sectionOrder sets the order of categories in an auto-generated sidebar — either a name → weight map (lower first, merged over the built-in defaults) or an explicit list (listed categories come first in list order; unlisted ones follow the defaults):
export default defineSSGConfig({
navigation: {
sectionOrder: ['Getting Started', 'Guides', 'API Reference'],
},
collections: {
// a collection can override the site-wide order with its own
blog: { path: '/blog', sectionOrder: { Featured: 0, Archive: 10 } },
},
});
A collection's own sectionOrder wins over the site-wide one for that collection.
SPA navigation
By default (spaNavigation: true) same-origin internal anchor clicks — including the plain <a> elements that MDX content links compile to — are routed through the client router instead of triggering a full page reload. The browser keeps handling modified clicks, external/mailto: links, target/download anchors, same-document #hash scrolls, and anything that already called preventDefault(). Opt out per element (or any ancestor container) with a data-no-spa attribute, or globally with spaNavigation: false. Navigation honours trailingSlash: under the default 'always' it pushes the canonical trailing-slash URL — the same form the hard load and the static host's 301 redirect both serve — so client and server URLs match without any pushState shim; paths with a file extension (e.g. /sitemap.xml) are pushed verbatim. The generated client entry wires this automatically; in a custom entry call installSpaNavigation(router, { base, trailingSlash }) from @sigx/ssg/client.
Per-page metadata
Beyond navigation, PageMeta (frontmatter or an exported meta) carries SEO and page-chrome fields the layout/theme can render:
robots,canonical,keywords,jsonLd, and extraheadtags feed the page's head.lastmod,changefreq, andpriorityoverride sitemap values for the page (see Sitemap).sourceFile(the page's root-relative source path, embedded automatically) combines withsite.editBaseto produce an edit-this-page link.- Frontmatter
datebecomes the page's<lastmod>fallback.
Site-wide page chrome lives on SiteConfig and reaches layouts via LayoutProps.site:
site.editBase— base URL for edit-this-page links (e.g.'https://github.com/org/repo/edit/main/'); the page'ssourceFileis appended.site.announcement—{ text, href?, id? }rendered by the theme above the header. Changeidto re-show an announcement a visitor dismissed.site.jsonLd,site.head,site.nav,site.logo,site.repo— global structured data, head tags, and branding.
When no /404 page exists, the build still writes a default 404.html.
The base stylesheet
@sigx/ssg emits markup with its own class names — code-window chrome, the package-manager tab strip, heading anchors, copy-code buttons, and dual-theme Shiki output. Import the base stylesheet so that markup is styled, usually via clientImports:
export default defineSSGConfig({
clientImports: ['@sigx/ssg/styles.css'],
});
A theme that styles this markup itself contributes the import through its config.css, so you don't add it twice.
Next steps
- Client runtime — the package-manager switcher, search, copy-code, prefetch, and SPA navigation helpers.
- Themes — authoring and consuming themes, and the prebuilt daisyUI theme.
- Commands — every
sigx ssgcommand with its flags. - API Reference — the full list of public exports.
