SSR & SSG

EmberKit ships four rendering modes controlled by emberkit.config.ts. The Vite plugin handles SSR during development; the CLI builds client and server bundles and can pre-render static routes at build time.

Rendering Modes

ModeBuild outputRuntimeBest for
hybrid defaultClient + server bundle + ssr-manifest.json; static routes pre-rendered to HTML filesPre-rendered HTML for static paths; SSR for dynamic routes ([param])Most apps (docs, marketing + dynamic pages)
staticSame as hybrid, but all non-dynamic routes pre-rendered at build timeStatic files only (no server required)Blogs, marketing sites, documentation
ssrClient + server bundle + manifest; no pre-render stepEvery HTML request rendered on the serverPersonalized or frequently changing content
spaClient bundle onlyClient-side routing; dev server skips SSR middlewareDashboards, admin panels, fully client-driven apps

How routes are classified

During emberkit build, each file under src/routes/ becomes a manifest entry:

  • Static route β€” path has no dynamic segments (e.g. /, /about, /docs/installation)
  • Dynamic route β€” path contains a segment parameter (e.g. /blog/:slug from [slug].tsx)

In hybrid mode, static routes are written to dist/.../index.html at build time. Dynamic routes are rendered at request time via dist/server/entry-server.js.

Configuration

Create emberkit.config.ts in your project root (or run emberkit generate config):

import { defineConfig } from '@emberkit/core';

export default defineConfig({
  mode: 'hybrid', // 'static' | 'ssr' | 'spa' | 'hybrid'
  server: {
    port: 3000,
    host: 'localhost',
  },
  build: {
    outDir: 'dist',
    target: 'esnext',
  },
  // Optional: merge extra Vite options
  vite: {
    plugins: [/* emberkitVitePlugin(), tailwindcss(), … */],
  },
});

Register the Vite plugin in vite.config.ts (included in CLI templates):

import { defineConfig } from 'vite';
import { emberkitVitePlugin } from '@emberkit/core/vite-plugin';

export default defineConfig({
  plugins: [emberkitVitePlugin()],
  esbuild: {
    jsxImportSource: '@emberkit/core',
  },
});

The plugin reads emberkit.config.ts for mode, routeDir, markdown options, and compression. If no config file exists, hybrid is the default.

Development

pnpm dev   # emberkit dev β€” Vite + HMR

For every mode except spa, the dev server runs SSR middleware on HTML navigations:

  1. Skips assets, HMR, and non-HTML requests
  2. Loads the virtual SSR entry and calls render(url, server)
  3. Injects rendered markup into index.html at #app (or <body id="app">)

View the page source in the browser to confirm server-rendered HTML before hydration.

Production Build

pnpm build   # emberkit build
ModeSteps
spaClient bundle β†’ dist/
ssrClient bundle β†’ SSR bundle β†’ ssr-manifest.json
hybridClient β†’ SSR β†’ manifest β†’ pre-render static routes
staticClient β†’ SSR β†’ manifest β†’ pre-render all non-dynamic routes

Build artifacts:

dist/
β”œβ”€β”€ index.html              # Client shell (and / for pre-rendered home)
β”œβ”€β”€ assets/                 # JS, CSS, hashed chunks
β”œβ”€β”€ ssr-manifest.json       # mode, routes[], isStatic flags
β”œβ”€β”€ server/
β”‚   └── entry-server.js     # SSR renderer (unless you provide src/entry-server.tsx)
└── about/
    └── index.html          # Pre-rendered static route (hybrid/static)

Preview and production servers

pnpm preview   # emberkit preview β€” quick local check (default port 4173)
pnpm serve     # emberkit serve β€” production server using ssr-manifest.json

Preview serves static files and calls entry-server.js for SSR modes.

Serve resolves requests in order: static asset β†’ pre-rendered path/index.html β†’ SSR (for ssr mode, or dynamic routes in hybrid) β†’ SPA fallback to root index.html.

Route metadata (built-in SSR head)

The default server entry injects optional route exports into <head>:

// src/routes/about.tsx
export const metadata = {
  title: 'About β€” My App',
  description: 'Learn more about us',
};

export default function AboutPage() {
  return <main><h1>About</h1></main>;
}

For declarative head tags from components, use the <Head> component β€” it updates the DOM on the client and registers tags during render. See Head for details.

Custom error pages

Place these in src/routes/ (not nested under a segment):

FileWhen used
404.tsxNo route matches
500.tsxUncaught error while rendering a route

If neither file exists, EmberKit renders built-in default pages (same components used for client navigation). Custom pages receive pathname on 404 and error on 500.

// src/routes/404.tsx
export default function NotFound() {
  return (
    <main>
      <h1>404</h1>
      <p>Page not found</p>
    </main>
  );
}

Dynamic route props

SSR passes params to the matched route component:

// src/routes/blog/[slug].tsx
import type { RouteParams } from '@emberkit/core';

export default function Post({ params }: RouteParams<{ slug: string }>) {
  return <article><h1>{params.slug}</h1></article>;
}

On the client, the runtime also provides params, query, and request via RouteParams. See Routing.

Low-level HTML APIs

These utilities live in @emberkit/core for custom servers and tests:

renderToHTMLString

import { renderToHTMLString } from '@emberkit/core';

const fragment = renderToHTMLString(<Page />);
// HTML fragment (body content only)

renderToHTMLString escapes text and string attributes for safe SSR output.

Head registry

import { drainHeadContent } from '@emberkit/core';

// After rendering components that use <Head>:
const headTags = drainHeadContent();

Use this in a custom entry-server when you need full <Head> support in SSR.

Hydration after SSR

Server HTML includes data-ek-bind indices and data-ekclick handler ids for interactive regions. The client entry reconnects signals and events without re-rendering the full tree. Details: Hydration.

Performance (why speed is the goal)

EmberKit’s rendering modes exist to minimize time-to-content and client work:

TechniqueSpeed benefit
SSR / pre-renderUsers see HTML immediately; no empty shell waiting for JS
static / hybrid pre-renderStatic paths served from disk or CDN β€” very low TTFB
Selective hydrationSmaller JS parse/execute; faster Time to Interactive on content-heavy pages
Signal DOM bindingUpdates skip virtual DOM diffing β€” only bound nodes change

Speed is the framework’s first principle; weight and zero-JS defaults support it. See Introduction.

Edge deployment

Deploy pre-rendered dist/ to any static host, or run entry-server.js on Node, Bun, or edge adapters. See Edge Deployment.

Next Steps

  • Hydration β€” Selective client interactivity
  • Routing β€” File-based routes, layouts, loaders
  • Head β€” Declarative <head> management
  • Edge Deployment β€” Cloudflare Workers and Deno