npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ohnejs/nuxt

v0.1.1

Published

Renders the pages of an ohne CMS in Nuxt: redirects, 404s, the head and the live preview.

Readme

@ohnejs/nuxt

Renders the pages of an ohne CMS in Nuxt. Each path resolves on the server, redirects and 404s answer with the right status, the <head> fills itself, and the editor's live preview just works.

Install

pnpm add @ohnejs/nuxt @ohnejs/client

Configure

List the module and name your API:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@ohnejs/nuxt'],
  ohne: { api: 'http://localhost:9001' },
  app: { head: { title: 'My site' } },
});

Only the server reads ohne.api. The browser reads through the site's own /_ohne routes, so the API needs no CORS and may sit on a private network.

The module types every page from the ohne.ts file the cms writes next to nuxt.config.ts. Point the cms at it with cms.types in its config. Name another file with ohne.types.

The page

One catch-all page renders every path the cms answers. An app/app.vue hides it unless it renders <NuxtPage />, so delete the one Nuxt's starter ships:

<!-- app/pages/[...slug].vue -->
<script setup lang="ts">
definePageMeta({ middleware: 'ohne' });

const page = useOhnePage();
</script>

<template>
  <main v-if="page?.kind === 'page' && page.collection === 'Pages'">
    <h1>{{ page.record.title }}</h1>
    <OhneBlocks :blocks="page.record.content" />
  </main>
  <main v-else-if="page?.kind === 'notFound' && page.page">
    <h1>{{ page.page.title }}</h1>
    <OhneBlocks :blocks="page.page.content" />
  </main>
</template>

The ohne middleware resolves the path and query once, on the server, before the page renders:

  • A page fills useOhnePage(). Checking collection narrows record to that collection's fields.
  • A redirect answers with its own code, 301, 302, 307 or 308, and never renders anything.
  • A path no page answers renders your cms page with the slug 404, with a 404 status. Without one, Nuxt's error page shows a 404.
  • An API that fails shows Nuxt's error page with a 502.

After the first load, each navigation resolves in the browser through /_ohne.

Blocks

OhneBlocks renders each block with the component Nuxt names Blocks plus the block's name. So app/components/blocks/Hero.vue renders every Hero block. A component whose name starts with Blocks is a block component.

Each block component takes the block's fields as one fields prop:

<!-- app/components/blocks/Hero.vue -->
<script setup lang="ts">
import type { Blocks } from '~~/ohne';

defineProps<{ fields: Blocks['Hero'] }>();
</script>

<template>
  <section class="hero">
    <h2>{{ fields.heading }}</h2>
    <OhneRichText :value="fields.body" />
  </section>
</template>

Its root element gets data-ohne-block, which the editor uses to outline the block in the preview. Keep one root element, so the attribute has a place to land.

A block without a component renders nothing. nuxt typecheck names it, like Block `Gallery` has no component.

Rich text and links

OhneRichText renders a rich text value in a <div>. Pass inline for an inline field, and it renders a <span> with a <br> between blocks instead:

<OhneRichText :value="fields.body" />
<OhneRichText :value="fields.caption" inline />

A click on a link to one of your pages navigates on the client, like a NuxtLink.

OhneLink renders a link field as a NuxtLink, opening a new tab when the link says so:

<OhneLink :link="fields.button" class="button">{{ fields.label }}</OhneLink>

A link without an address a browser may follow, like one to an unpublished page, renders its content in a <span>. Your classes still land on it.

Layouts

useOhnePage() works anywhere, not only in the page. A layout reads the same page, with no second request:

<!-- app/layouts/default.vue -->
<script setup lang="ts">
const page = useOhnePage();
</script>

<template>
  <div>
    <nav v-if="page?.kind === 'page'">
      <a v-for="{ hreflang, href } in page.alternates" :key="hreflang" :href="href">{{ hreflang }}</a>
    </nav>
    <slot />
  </div>
</template>

To read another path, like a footer page, use the client directly. Pass the preview token, so the editor's drafts show there too:

<!-- app/components/SiteFooter.vue -->
<script setup lang="ts">
const ohne = useOhne();
const token = useOhneToken();
const { data: footer } = await useAsyncData('footer', () => ohne.resolve('/footer', { token: token.value }));
</script>

<template>
  <footer v-if="footer?.kind === 'page'">{{ footer.record.title }}</footer>
</template>

Site data

useOhneRead and useOhneList read records anywhere, a layout among them, on the server and again in the browser as the visitor moves on:

<!-- app/pages/blog.vue -->
<script setup lang="ts">
const route = useRoute();
const settings = await useOhneRead('Settings');
const posts = await useOhneList('Posts', () => ({ page: Number(route.query.page) || 1 }));
</script>

<template>
  <main>
    <p v-for="post in posts.records" :key="post.UUID">{{ post.title }}</p>
    <NuxtLink v-if="posts.page < posts.lastPage" :to="{ query: { page: posts.page + 1 } }">Older</NuxtLink>
    <footer>{{ settings.copyright }}</footer>
  </main>
</template>
  • A collection that holds one record, like Settings, reads without a UUID. Any other reads one record by its UUID, as useOhneRead('Posts', uuid), and the ref holds undefined when a visitor may not see it.
  • Records read in the current page's locale unless you pass locale.
  • A failed read shows the error page with a 502, as a failed page does. So the ref always holds the record.
  • Options passed as a getter read again when they change, like the page number above.

The guide shows a layout that reads the site's menu.

Forms

A contact form stores a record through useOhne().create, for a collection whose create your API opens to visitors. The browser posts to the site, and the site passes the create on to your API:

<!-- app/components/ContactForm.vue -->
<script setup lang="ts">
const ohne = useOhne();
const sent = ref(false);
const errors = ref<Partial<Record<string, string>>>({});

async function send(event: Event) {
  const form = new FormData(event.target as HTMLFormElement);
  const result = await ohne.create('Messages', {
    email: String(form.get('email')),
    message: String(form.get('message')),
  });
  if (result.ok) sent.value = true;
  else errors.value = result.errors;
}
</script>

<template>
  <p v-if="sent">Thanks!</p>
  <form v-else @submit.prevent="send">
    <input name="email" type="email" />
    <p v-if="errors.email">{{ errors.email }}</p>
    <textarea name="message" />
    <button>Send</button>
  </form>
</template>

A refusal the visitor can fix, like an invalid email, resolves with the API's message and a message per field in errors. Any other failure throws.

The site passes on only the create's body, its language and the visitor's address. A spam check that reads a header, like a recaptcha-token, needs that header named in ohne.headers:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@ohnejs/nuxt'],
  ohne: { api: 'http://localhost:9001', headers: ['recaptcha-token'] },
});

Then pass it with the create: ohne.create('Messages', input, { headers: { 'recaptcha-token': token } }). For each visitor to get their own rate limit, list your site's server in api.trustProxy on the API. The guide shows the collection and the spam check.

The head

The module fills the <head> from the page: the title, description, share tags, robots, the canonical link, a link per translation, and <html lang>. These are the tags the cms's own renderHead writes.

A page without SEO data adds no title, so app.head.title from your config shows. That includes the 404 page.

Preview

The editor frames your site with a preview token in the address. The module does the rest:

  • Pages read with the token, so drafts show.
  • The response is private, no-store and noindex, so no cache or search engine keeps it.
  • The site connects to the editor, and each unsaved change shows at once.

The token stays for the whole visit, even after the editor's script drops it from the address bar.

Sitemap and robots

The site serves /sitemap.xml and /robots.txt from the cms. A robots.txt in public/ wins, so delete the one Nuxt's starter ships, or keep your own there.

Production

Set NUXT_OHNE_API on the server, and it replaces ohne.api when the server starts. One build then serves every environment:

NUXT_OHNE_API=http://cms.internal:9001 node .output/server/index.mjs

When that address is private, set API_URL on the cms to the API's public URL. The cms builds upload URLs from it, and the browser cannot load an image from a private address.

TypeScript

nuxt typecheck runs vue-tsc, which needs TypeScript 6. Install both with pnpm add -D typescript@6 vue-tsc, and keep typescript at 6 in your Nuxt app, even if the rest of your workspace uses 7.

A types file from another version of @ohnejs/client leaves your pages untyped. nuxt typecheck names it, like `ohne.ts` was written for another version of `@ohnejs/client`. Have the cms that matches your client write it again.

The full guide lives at ohne.dev/docs/cms/frameworks.