Skip to content

jshimkoski/vite-plugin-cer-app

Repository files navigation

@jasonshimmy/vite-plugin-cer-app

A Nuxt/Next.js-style meta-framework built on top of @jasonshimmy/custom-elements-runtime. Turns any Vite project into a full-stack application with file-based routing, server-side rendering, static site generation, server API routes, and more — all through native Web Components.


Features

  • File-based routingapp/pages/ directory maps directly to routes
  • Layoutsapp/layouts/ with <slot> composition
  • Three rendering modes — SPA, SSR (streaming), and SSG
  • Server API routesserver/api/ with per-method handlers (GET, POST, …)
  • Auto-imports — runtime APIs (component, html, ref, …) injected automatically in page files
  • Data loadingloader export per page; serialized server→client via window.__CER_DATA__
  • useHead() — document head management (title, meta, OG tags) with SSR injection
  • App plugins — ordered plugin loading with DI via provide/inject
  • Route middleware — global and per-page guards
  • Server middleware — CORS, auth, and other HTTP-level middleware
  • JIT CSS — Tailwind-compatible, build-time via the runtime's cerPlugin
  • HMR — virtual module invalidation when pages/components are added or removed

Installation

npm install -D @jasonshimmy/vite-plugin-cer-app
npm install @jasonshimmy/custom-elements-runtime

Add the plugin to vite.config.ts:

// vite.config.ts
import { defineConfig } from 'vite'
import { cerApp } from '@jasonshimmy/vite-plugin-cer-app'

export default defineConfig({
  plugins: [cerApp()],
})

Or use a cer.config.ts alongside vite.config.ts (the CLI reads this automatically):

// cer.config.ts
import { defineConfig } from '@jasonshimmy/vite-plugin-cer-app'

export default defineConfig({
  mode: 'spa', // 'spa' | 'ssr' | 'ssg'
})

Quickstart with the CLI

The fastest path is scaffolding a new project:

npx --package @jasonshimmy/vite-plugin-cer-app create-cer-app my-app
# → choose spa / ssr / ssg
cd my-app
npm install
npm run dev

Note: The --package flag is required because create-cer-app is bundled inside @jasonshimmy/vite-plugin-cer-app rather than published as a standalone package.

Or install the CLI globally to skip the flag entirely:

npm install -g @jasonshimmy/vite-plugin-cer-app
create-cer-app my-app
cer-app dev

Project Structure

my-app/
├── app/                        # All client-side app code
│   ├── pages/
│   │   ├── index.ts            # → route /
│   │   ├── about.ts            # → route /about
│   │   ├── blog/
│   │   │   ├── index.ts        # → route /blog
│   │   │   └── [slug].ts       # → route /blog/:slug
│   │   └── [...all].ts         # → catch-all /*
│   ├── layouts/
│   │   └── default.ts          # Default layout wrapper
│   ├── components/             # Auto-registered custom elements
│   ├── composables/            # Auto-imported composables
│   ├── plugins/                # App plugins (01.store.ts → loaded first)
│   └── middleware/             # Global route middleware
├── server/
│   ├── api/
│   │   ├── users/
│   │   │   ├── index.ts        # GET/POST /api/users
│   │   │   └── [id].ts         # GET/PUT/DELETE /api/users/:id
│   │   └── health.ts           # GET /api/health
│   └── middleware/             # Server-only HTTP middleware
├── public/                     # Copied as-is to dist/
├── index.html                  # HTML entry
└── cer.config.ts               # Framework config

.cer/ is auto-generated on every dev/build and gitignored. The framework bootstrap (app.ts) lives there and is never user-owned — plugin updates propagate automatically.


Pages

Every file in app/pages/ defines a custom element and optionally exports page metadata and a data loader:

// app/pages/blog/[slug].ts

// component, html, useProps are auto-imported — no import statement needed
component('page-blog-slug', () => {
  const props = useProps({ slug: '' })

  return html`
    <div class="prose">
      <h1>${props.slug}</h1>
    </div>
  `
})

// Optional: page metadata
export const meta = {
  layout: 'default',
  middleware: ['auth'],
  hydrate: 'load',
}

// Optional: server-side data loader
export const loader = async ({ params }) => {
  const post = await fetch(`/api/posts/${params.slug}`).then(r => r.json())
  return { post }
}

File → Route mapping

File Route
app/pages/index.ts /
app/pages/about.ts /about
app/pages/blog/index.ts /blog
app/pages/blog/[slug].ts /blog/:slug
app/pages/[...all].ts /*
app/pages/(auth)/login.ts /login (group prefix stripped)

Layouts

// app/layouts/default.ts
component('layout-default', () => {
  return html`
    <header><nav>...</nav></header>
    <main><slot></slot></main>
    <footer>...</footer>
  `
})

The framework wraps each route's content inside the layout declared by meta.layout. Defaults to 'default' if the file exists.


Server API Routes

// server/api/users/[id].ts
import type { ApiHandler } from '@jasonshimmy/vite-plugin-cer-app/types'

export const GET: ApiHandler = async (req, res) => {
  res.json({ id: req.params.id })
}

export const DELETE: ApiHandler = async (req, res) => {
  res.status(204).end()
}

useHead()

import { useHead } from '@jasonshimmy/vite-plugin-cer-app/composables'

component('page-about', () => {
  useHead({
    title: 'About Us',
    meta: [
      { name: 'description', content: 'Learn more about us.' },
      { property: 'og:title', content: 'About Us' },
    ],
  })

  return html`<h1>About</h1>`
})

Content Layer

Drop Markdown and JSON files into content/ and query them with queryContent().

Numeric ordering prefixes are supported on both directories and files. A leading NN. is stripped from the public content path, which lets you keep source-tree ordering without leaking the prefix into URLs:

content/
  01.docs/
    01.getting-started.md   -> /docs/getting-started
    02.routing.md           -> /docs/routing
  02.blog/
    01.index.md             -> /blog
    02.2026-04-01-hello.md  -> /blog/hello

Date-prefixed filenames still work the same way after the numeric prefix is removed.

For content-driven routing, use app/pages/[...all].ts to resolve valid nested URLs at runtime. Unlike app/pages/404.ts, a catch-all page is not treated as a 404 automatically. Existing content routes stay HTTP 200, and in SSG with ssg.routes: 'auto' a catch-all page that uses queryContent() can auto-generate concrete output paths from the content store.

See docs/content.md for the full content-layer API and examples.


Documentation

Guide Description
Getting Started Installation, scaffolding, first app
Configuration All cer.config.ts options
Routing File-based routing, dynamic segments, route groups
Layouts Layout system and <slot> composition
Components Auto-registered custom elements
Composables Auto-imported composables
Content Layer File-based Markdown/JSON content with queryContent() and useContentSearch()
Plugins App plugin system and DI
Middleware Route guards and server middleware
Server API Routes HTTP handlers in server/api/
Data Loading Page loaders and SSR data hydration
Head Management useHead() reference
Rendering Modes SPA, SSR, and SSG in detail
CLI Reference cer-app and create-cer-app commands
Manual Testing Guide How to test every feature end-to-end

License

MIT

About

A Nuxt/Next.js-style meta-framework built for native Custom Elements.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages