A package for Next.js to use Neos CMS as a headless CMS with full visual editing.
This package is used inside a Next.js project that fetches content from Neos CMS for rendering and offers editing with full preview capabilities. It provides components and helpers to handle the loading and rendering of nodes. It adds the necessary editing metadata for the Neos UI to work as in a traditional Neos setup.
Inside Neos CMS a few supporting packages are used to provide the content via an API for Next.js and adjust the behavior of the Neos UI:
Note: this readme focuses on using Zebra with the Next.js app router, as it is a more flexible approach for data-loading and supports React server components.
@networkteam/zebra
to your projectApply withZebra
to your next.config.js
:
/** @type {import('next').NextConfig} */
const { withZebra } = require('@networkteam/zebra');
const nextConfig = {
reactStrictMode: true,
// ...
};
module.exports = withZebra(nextConfig);
NEOS_BASE_URL
to your Neos installation and PUBLIC_BASE_URL
to the public URL of your Next.js site.See the demo project for a working example:
And here's a list of articles with more background:
NEOS_BASE_URL
: The base URL of your Neos installation. This could be an internal URL that is not reachable from outside.PUBLIC_BASE_URL
: The base URL of your Next.js site. This is the URL where your website will be reachable from outside.REVALIDATE_TOKEN
: A secret token that will be used to validate calls to the revalidate API route.Zebra provides a NodeRenderer
component to render a node from Neos.
You provide a mapping from node types to React components via initNodeTypes
which should be imported in your root layout.
It is good practice to split components in presentational (no knowledge about Neos) and content components (adds Zebra components and helpers for editing capabilities on top of presentational components).
Note: as React server components do not support
useContext
, we explicitly pass thectx
object to all components that need to access content.
Define node type mappings (lib/config/nodeTypes.ts
):
import { initNodeTypes } from '@networkteam/zebra/server';
import DocumentPage from '../components/document/Page';
import ContentHeadline from '../components/content/Headline';
initNodeTypes({
// Documents
'MyProject.Site:Document.Page': DocumentPage,
// Content
'Neos.NodeTypes:Headline': ContentHeadline,
});
Create a dynamic route with optional catch-all (app/[[...slug]]/page.tsx
):
import { loadDocumentPropsCached, NodeRenderer } from '@networkteam/zebra/server';
import { DataLoaderOptions } from '@networkteam/zebra/types';
import { Metadata } from 'next';
import { notFound, redirect, permanentRedirect } from 'next/navigation';
const dataLoaderOptionsFor = (routePath: string): DataLoaderOptions => ({
cache: 'force-cache',
next: {
tags: ['document'],
},
});
// This is for generating metadata
export async function generateMetadata({
params,
}: {
params: {
slug: string[];
};
}): Promise<Metadata> {
const routePath = params.slug && Array.isArray(params.slug) ? params.slug.join('/') : '/';
const neosData = await loadDocumentPropsCached(routePath, dataLoaderOptionsFor(routePath));
if (!neosData) {
return {};
}
const { node, site, meta } = neosData;
const title = meta?.isRootPage ? site.properties.title : `${node.properties.title} ā ${site.properties.title}`;
return {
title,
};
}
// And this will render the page output
const Page = async ({ params: { slug } }: { params: { slug: string[] } }) => {
const routePath = slug && Array.isArray(slug) ? slug.join('/') : '/';
const dataLoaderOptions = dataLoaderOptionsFor(routePath);
const neosData = await loadDocumentPropsCached(routePath, dataLoaderOptions);
if (!neosData) {
return notFound();
}
// Check for possible redirects
if ('redirect' in neosData) {
if (neosData.redirect.statusCode === 308 || neosData.redirect.statusCode === 301) {
permanentRedirect(neosData.redirect.targetPath);
}
redirect(neosData.redirect.targetPath);
}
if (neosData?.node.nodeType === 'Neos.Neos:Shortcut') {
return redirect(neosData.node.properties.targetUri || '/');
}
return (
// Render the node data with NodeRenderer which uses the node type mappings
<NodeRenderer
ctx={{
routePath,
currentNodeIdentifier: neosData.node.identifier,
documentNodeIdentifier: neosData.node.identifier,
dataLoaderOptions,
}}
node={neosData.node}
/>
);
};
export default Page;
Add a component for a basic document page (lib/components/document/Page.tsx
):
import { ContextProps } from '@networkteam/zebra';
import { ContentCollection } from '@networkteam/zebra/server';
import Header from './partials/Header';
const DocumentPage = ({ ctx }: { ctx: ContextProps }) => {
const { mainNavigation } = await withMeta(ctx);
const inBackend = ctx.inBackend;
return (
<div className="flex min-h-screen flex-col">
<Header
ctx={ctx}
mainNavigation={mainNavigation}
inBackend={inBackend}
/>
<main className="flex grow flex-col justify-between">
<ContentCollection className="grow" nodeName="main" ctx={ctx} />
</main>
</div>
);
};
export default DocumentPage;
Add a presentational component for a headline:
import classNames from 'classnames';
type HeadlineProps = {
children: React.ReactNode;
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
size?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
className?: string;
};
const Headline = ({ children, as: Component = 'h1', size, className }: HeadlineProps) => {
return (
<Component
className={classNames(
{
'text-6xl': size === 'h1',
'text-5xl': size === 'h2',
'text-4xl': size === 'h3',
'text-3xl': size === 'h4',
'text-2xl': size === 'h5',
'text-xl': size === 'h6',
},
className
)}
>
{children}
</Component>
);
};
export default Headline;
Add a content component for a headline:
import { ContextProps } from '@networkteam/zebra';
import { ContentComponent, Editable, withNode } from '@networkteam/zebra/server';
import { baseClasses } from '@/lib/utils/baseClasses';
import Headline from '../ui/Headline';
const ContentHeadline = async ({ ctx }: { ctx: ContextProps }) => {
const node = await withNode(ctx);
return (
<ContentComponent ctx={ctx} className={baseClasses(node)}>
<Headline as={node.properties.hierarchy} size={node.properties.size}>
<Editable ctx={ctx} property="title" />
</Headline>
</ContentComponent>
);
};
export default ContentHeadline;
Have a look at the Zebra Demo for a full setup of developing a Next.js project alongside Neos CMS in a monorepo.
Basically it boils down to:
NEOS_BASE_URL
to the URL of your local Neos/neos
Deploying a site where content comes from Neos CMS and the actual frontend is generated in Next.js is a little bit more involved, since both systems work together when generating content or using the backend.
There are multiple things to consider:
CI
to opt-out of caching (by calling a function like headers
). This can be used e.g. in not-found.tsx
which would cause build errors otherwise, see not-found.tsx
in Zebra demo.PUBLIC_BASE_URL
is provided to the Next.js frontend, which will set X-Forwarded-*
proxy headers for Neos.trustedProxies
configuration in Neos allows this./neos
, /_Resources
) and others to Next.js (/neos/preview
, /
). In Kubernetes this can be solved at the Ingress level.PUBLIC_BASE_URL
env var to make sure URIs are generated correctly for revalidate calls to the content API in Neos.Neos.Flow.http.trustedProxies
in Settings.yaml
.Please have a look at https://github.com/networkteam/zebra/blob/v0.9.0/README.md to see previous instructions for using the pages router.
We are happy to accept contributions. Just open an issue or pull request.
Special thanks to Philip Schmidt (@esdete2) for initiating and pushing the full-editing approach and implementing the first Zebra project in his spare-time.