Monorepo
Your ohne app and your Nuxt or Next website in one repository.
Keep your ohne app and your website in one repository. You install once, start both with one command, and deploy each on its own.
The ohne app is your backend: the API and the dashboard, plus the CMS if you add it. The website is a Nuxt or Next app that reads from that API. The repository holds both, side by side:
my-site/
├── apps/
│ ├── backend/ your ohne app
│ └── web/ your Nuxt or Next website
├── .gitignore
├── package.json
└── pnpm-workspace.yamlCreating it
One command creates the whole repository:
pnpm create ohne@latest my-site --web nuxt--web nextcreates a Next website instead.- It needs pnpm, since the repository is a pnpm workspace.
- In a terminal, you can leave
--webout and pick the website when it asks.
Then start both apps, as in Running. The sections below set up the same layout by hand, one file at a time.
The workspace
Start with the root. It makes the two apps one pnpm workspace:
{
"name": "my-site",
"private": true,
"scripts": {
"build": "pnpm --filter web build",
"dev": "pnpm --recursive --parallel dev",
"typecheck": "pnpm --recursive typecheck"
},
"packageManager": "pnpm@11.5.1"
}pnpm devstarts both apps.pnpm buildbuilds the website. The backend has nothing to build.pnpm typecheckchecks both.
The backend
Scaffold an ohne app into apps/backend:
pnpm create ohne@latest apps/backend --yesIt is a regular ohne app, so the rest of these docs apply to it unchanged. Run an ohne command in it from the root with pnpm --filter backend exec:
pnpm --filter backend exec ohne syncKeep web beside backend, never inside it. ohne dev reloads on every .ts file in its folder, and its tsconfig.json would check the website too.
A Nuxt website
Create apps/web with these files:
{
"name": "web",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"postinstall": "nuxt prepare",
"preview": "nuxt preview",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"nuxt": "4.6.2",
"vue": "3.5.44",
"vue-router": "5.4.0"
},
"devDependencies": {
"typescript": "6.0.3",
"vue-tsc": "3.3.13"
}
}ohneAPIis your API in development. In production,NUXT_PUBLIC_OHNE_APIreplaces it.- The website stays on TypeScript 6, since
vue-tscand Vue's compiler need TypeScript's JavaScript API, which version 7 does not have. The backend stays on 7.
Nuxt installs esbuild, which runs an install script. pnpm refuses it until you allow it in the root pnpm-workspace.yaml:
packages:
- apps/*
allowBuilds:
esbuild: trueA Next website
Create apps/web with these files:
{
"name": "web",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"dev": "next dev",
"start": "next start",
"typecheck": "next typegen && tsc"
},
"dependencies": {
"next": "16.4.2",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@types/node": "26.0.0",
"@types/react": "19.3.0",
"typescript": "7.0.2"
}
}next typegenwrites the typestscreads, likeLayoutProps, sopnpm typecheckworks on a fresh clone.next buildleaves thistsconfig.jsonas it is. Drop an option, and the build writes it back.
Running
Install from the root, then start both apps:
pnpm install
pnpm devThe dashboard opens at http://localhost:9000, the API at http://localhost:9001, and the website at http://localhost:3000.
When a port is busy, ohne dev and Nuxt move to the next free one, and the addresses above stop matching. Free the port and run pnpm dev again.
Calling the API
The website reads from the routes your backend defines. In Nuxt, a page's <script setup> takes the address from the runtime config:
const api = useRuntimeConfig().public.ohneAPI;
const { data: posts } = await useFetch(`${api}/posts`);In Next, a server component reads it from the environment:
const api = process.env.OHNE_API ?? 'http://localhost:9001';
const response = await fetch(`${api}/posts`, { cache: 'no-store' });
const posts = await response.json();- Next renders these calls on the server, where CORS does not apply.
- Nuxt makes the same call in the browser once the visitor moves to another page. Allow the website's origin in the API's CORS policy, like
http://localhost:3000in development. - Origins match exactly. A website opened at
127.0.0.1:3000is a different origin fromlocalhost:3000.
Adding the CMS
When your backend is the CMS for this website, add the layer to it, and your framework's package to the website:
pnpm --filter backend add @ohnejs/cms
pnpm --filter web add @ohnejs/nuxt @ohnejs/clientFor a Next website, add @ohnejs/next instead of @ohnejs/nuxt.
Set it up as in The CMS, with one more key, types:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base', 'ohnejs/uploads', '@ohnejs/cms'],
cms: {
site: 'http://localhost:3000',
routes: { Pages: '/[...slug]' },
types: '../web/ohne.ts',
},
});The CMS now writes your website's types to apps/web/ohne.ts. It rewrites the file while ohne dev runs, whenever you change a collection or a block, and on every pnpm install.
- Commit
ohne.ts. The website then builds without the backend, even on a host that installsapps/webalone. - The website reads the CMS through its own server, so the CMS needs no CORS step.
- Uploaded files land in
apps/backend/.uploads/. Add.uploads/to the root.gitignore. - Frameworks sets up the package, the page and its components.
In CI, check that someone who changed a block also committed the new types:
pnpm --filter backend exec ohne prepare
git diff --exit-code apps/web/ohne.tsDeploying
Each app deploys on its own:
- The backend deploys like any ohne app, from
apps/backend. With the CMS, setSITE_URLto the website's public address. - A Nuxt website builds with
pnpm --filter web buildand runs asnode apps/web/.output/server/index.mjs, withNUXT_PUBLIC_OHNE_APIset to your API. With the CMS, setNUXT_OHNE_APItoo. - A Next website builds with
pnpm --filter web buildand runs aspnpm --filter web start, withOHNE_APIset to your API.
Only the backend needs Node 26. The website runs on any version its framework supports.