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/next

v0.1.0

Published

Renders the pages of an ohne CMS in Next.js: redirects, 404s, metadata and the live preview.

Readme

@ohnejs/next

Renders the pages of an ohne CMS in a Next.js site. Each path resolves on the server, redirects and misses answer with the right status, the metadata comes from the CMS, and the dashboard's editor previews every change live.

It needs Next 16 with the App Router and React 19.

Install

pnpm add @ohnejs/next @ohnejs/client

Fetch your CMS's types into ohne.ts, as @ohnejs/client shows. They type every page by its collection.

Connect

Create the connection once:

// lib/ohne.ts
import { createOhneNext } from '@ohnejs/next';

import type { CMS } from '../ohne';

export const ohne = createOhneNext<CMS>({ api: process.env.OHNE_API ?? 'http://localhost:9001' });

OHNE_API is the address of your API. The server reads it when it runs, so one build serves every environment.

Every page request passes through the proxy. It hands the path and query to your pages, keeps a preview out of caches and search engines, and serves the editor's script:

// proxy.ts
import { ohne } from './lib/ohne';

export const proxy = ohne.proxy;

export const config = { matcher: '/((?!_next/).*)' };

Keep the matcher covering /_ohne/: the proxy serves the editor's script there. Next reads the matcher when it builds, so it must stay a literal.

The page

One catch-all page renders every path the CMS answers, / included, so delete app/page.tsx:

// app/[[...slug]]/page.tsx
import { OhneBlocks } from '@ohnejs/next';

import { Hero } from '../../components/hero';
import { Text } from '../../components/text';
import { ohne } from '../../lib/ohne';

export const generateMetadata = ohne.metadata;

export default async function Page() {
  const { page } = await ohne.page();
  return (
    <main lang={page.locale}>
      <h1>{page.record.title}</h1>
      <OhneBlocks blocks={page.record.content} components={{ Hero, Text }} />
    </main>
  );
}

ohne.page() resolves the current address. A permanent redirect answers 308 and a temporary one 307, keeping the query when the CMS does. A miss answers 404, and a failing API answers 500.

ohne.metadata gives Next the title, description, share tags, robots, canonical and hreflang links. The page, its metadata and its not-found page share one request to the API.

With more than one collection, check page.collection to narrow page.record.

Put your icon at app/favicon.ico. Without it, each request for /favicon.ico asks the CMS for a page.

Blocks

OhneBlocks renders each block through the component named after it, and wraps it in a <div> the editor can select. Pass as="section" for another element. Each component takes its block:

// components/hero.tsx
import { OhneLink, OhneRichText } from '@ohnejs/next/client';

import type { Block } from '../ohne';

export function Hero({ fields }: Block<'Hero'>) {
  return (
    <section>
      <h2>{fields.heading}</h2>
      <OhneRichText value={fields.body} />
      <OhneLink link={fields.link} className="button">
        Read more
      </OhneLink>
    </section>
  );
}

A block the CMS adds fails the typecheck until it has a component. A block that arrives before your next build renders nothing. A block's component may be async.

Site data

A layout or a component reads site data with ohne.read and ohne.list:

// app/[[...slug]]/layout.tsx
import { OhneLink } from '@ohnejs/next/client';

import { ohne } from '../../lib/ohne';

export default async function SiteLayout({ children }: LayoutProps<'/[[...slug]]'>) {
  const settings = await ohne.read('Settings');
  return (
    <>
      <nav>
        {settings.menu.map((item) => (
          <OhneLink key={item.label} link={item.link}>
            {item.label}
          </OhneLink>
        ))}
      </nav>
      {children}
      <footer>{settings.copyright}</footer>
    </>
  );
}

Reads are cached like the page's, and the webhook drops them with it. Records read in your default locale unless you pass locale.

Only a page receives searchParams. A block reads the query with ohne.searchParams() instead, like a page number for its list:

const page = Number((await ohne.searchParams()).get('page')) || 1;
const posts = await ohne.list('Posts', { order: ['-publishedAt'], page });

A layout must not read the query: Next keeps a layout as first rendered when only the query changes.

Forms

A form creates a record in a collection your CMS opens to visitors. Send it from a Server Action with ohne.create:

// app/contact/actions.ts
'use server';

import type { Creatable } from '../../ohne';

import { ohne } from '../../lib/ohne';

export async function sendMessage(message: Creatable['Messages'], token: string) {
  return ohne.create('Messages', message, { headers: { 'recaptcha-token': token } });
}

The header carries a spam check's token, like the one a reCAPTCHA widget adds to the form, to the middleware on the API that reads it. A create is never cached.

The action answers { ok: true, UUID }, or { ok: false, status, message, errors } when the API refuses the input. errors holds a message per invalid field, so a Client Component shows each one beside its input:

// components/contact-form.tsx
'use client';

import { useState } from 'react';

import { sendMessage } from '../app/contact/actions';

export function ContactForm() {
  const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
  const [sent, setSent] = useState(false);

  async function send(form: FormData) {
    const field = (name: string) => String(form.get(name));
    const message = { name: field('name'), email: field('email'), message: field('message') };
    const result = await sendMessage(message, field('g-recaptcha-response'));
    if (result.ok) setSent(true);
    else setErrors(result.errors);
  }

  if (sent) return <p>Thanks!</p>;
  return (
    <form action={send}>
      <input name="name" />
      <input name="email" />
      {errors.email && <p>{errors.email}</p>}
      <textarea name="message" />
      <button>Send</button>
    </form>
  );
}

Any other failure throws inside the action, like a collection visitors may not create in.

Each create passes the visitor's address and language on, so the API answers in their language. To give each visitor their own rate limit, list your site's server in api.trustProxy. Do that only behind a proxy that sets X-Forwarded-For, like your host's: a Next server facing the internet directly passes on whatever address a visitor claims.

Rich text and links

OhneRichText renders a richText field in a <div>, escaping every text and address. Clicks on links to your pages go through Next's router. Pass inline for an inline field, which renders in a <span>, so it fits inside a heading.

OhneLink renders a link field: Next's Link for a path on your site, a plain <a> for any other address. A link that opens in a new tab gets rel="noopener noreferrer". A link with no address a browser may follow renders its children in a <span>, keeping its className.

Not found

A miss renders the not-found page beside the catch-all. It reads the 404 record your CMS keeps:

// app/[[...slug]]/not-found.tsx
import { OhneBlocks } from '@ohnejs/next';

import { Hero } from '../../components/hero';
import { Text } from '../../components/text';
import { ohne } from '../../lib/ohne';

export default async function NotFound() {
  const { resolved } = await ohne.current();
  const page = resolved.kind === 'notFound' ? resolved.page : undefined;
  return (
    <main>
      <h1>{page?.title ?? 'Page not found'}</h1>
      <OhneBlocks blocks={page?.content} components={{ Hero, Text }} />
    </main>
  );
}

Keep it inside [[...slug]]. A root not-found.tsx that reads the request makes every page of the app dynamic. Its <title> is the layout's.

Remove cacheComponents and partialPrefetching from next.config.ts: with them, redirects and misses answer 200.

Next renders this page in the browser: the server answers 404 with an almost empty body, so a crawler that runs no JavaScript sees no content (vercel/next.js#99287).

Live preview

Render OhnePreview once, in the root layout:

// app/layout.tsx
import { OhnePreview } from '@ohnejs/next/client';

export default function Layout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="en">
      <body>
        {children}
        <OhnePreview />
      </body>
    </html>
  );
}

Inside the editor's frame it joins the editor, and each change renders the page again on the server with your drafts. Anywhere else it does nothing.

The editor keeps the frame on the page you edit, so a click on a link does not leave it. If your own code navigates inside the frame, the next page renders without the preview token, so it shows saved content.

Sitemap and robots

Two route handlers serve the sitemap and robots.txt the CMS writes:

// app/sitemap.xml/route.ts
import { ohne } from '../../lib/ohne';

export const GET = ohne.sitemap;
// app/robots.txt/route.ts
import { ohne } from '../../lib/ohne';

export const GET = ohne.robots;

They read the API when they run. A rewrite in next.config.ts would fix the API address at build time instead.

Caching

By default every page view reads the API. Set revalidate to keep each read in Next's data cache for that many seconds:

// lib/ohne.ts
import { createOhneNext } from '@ohnejs/next';

import type { CMS } from '../ohne';

export const ohne = createOhneNext<CMS>({
  api: process.env.OHNE_API ?? 'http://localhost:9001',
  revalidate: 300,
});

A preview is never cached. Let the CMS drop the cache the moment content changes, with a route for its webhook:

// app/api/ohne/route.ts
import { ohne } from '../../../lib/ohne';

export const POST = ohne.webhook(process.env.WEBHOOK_SECRET);

Point a webhooks entry of your CMS at /api/ohne with the same secret. A forged or stale request answers 401. Some changes send no webhook, like a scheduled page going live, so revalidate is the longest a page stays stale.

Every OhneLink in view prefetches its page, which reads the API. With revalidate set, those reads come from the cache; without it, pass prefetch={false} where that matters.

A private API

OHNE_API may be an address only your server reaches, since the browser loads everything through your site. Set API_URL on the CMS to its public address, so the files a page links to carry it.

Docs

Frameworks walks through a whole site. Your website explains pages, blocks and the live preview.