Migration guide: Mapo v1 → v2
This guide covers everything that changed between Mapo v1 (Nuxt 2 / Vue 2 / Vuex / Axios) and Mapo v2 (Nuxt 4 / Vue 3 / Pinia / $fetch). It documents both the breaking changes to the framework itself and the tooling changes made to the monorepo.
Installing v2 in an existing app
On npm, the latest tag of mapomodule still points to v1, so pnpm update mapomodule keeps a v1 app on v1. Install v2 from the next dist-tag, together with Nuxt UI, which v2 is built on:
pnpm add mapomodule@next @nuxt/ui
pnpm add @mapomodule/mapo-integrations-camomilla@next # only if you use Camomilla CMSDo the same for any other @mapomodule/* package your app declares, then port the code with the Breaking changes below. The next channel explains how to upgrade later.
Monorepo tooling
Package manager: Yarn → pnpm
v1 used Yarn Workspaces + Lerna. v2 uses pnpm Workspaces.
# v1
yarn install
yarn add some-package
# v2
pnpm install
pnpm add some-packageThe workspace declaration moved from package.json (workspaces) to pnpm-workspace.yaml:
# pnpm-workspace.yaml
packages:
- "packages/@mapomodule/*"
- "packages/mapomodule"
- "apps/*"
- "docs"lerna.json has been removed. All Lerna scripts (lerna publish, lerna bootstrap) are no longer used.
Task orchestration: Lerna → Turborepo
Build, test, and lint tasks are now orchestrated by Turborepo (turbo.json). It handles dependency ordering and incremental caching automatically.
# Run build for all packages (respects dependency order)
pnpm turbo build
# Run typecheck across the monorepo
pnpm turbo typecheckRelease: yarn pub → multi-semantic-release
Releases are driven by Conventional Commits + multi-semantic-release. Each fix:, feat:, or BREAKING CHANGE: commit automatically:
- Bumps the version of only the affected packages
- Generates a per-package
CHANGELOG.md - Publishes to npm
- Creates a GitHub release
No more manual yarn pub or lerna publish invocations.
TypeScript: JavaScript → TypeScript strict
v1 was plain JavaScript. v2 is TypeScript strict end-to-end. All packages extend a shared tsconfig.base.json at the root:
{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler"
}
}Package changes
Removed packages
| Package | Reason |
|---|---|
@mapomodule/integrations | The adapter loader pattern is unnecessary in Nuxt 4 — each integration is a standalone Nuxt module added directly to modules[] |
@mapomodule/http-client | Created and removed in the same session (DP-6 → DP-7). $fetch.create() in a plugin replaces it with zero abstraction overhead |
@mapomodule/routemeta | Nuxt 4's definePageMeta() supports arbitrary typed properties via module augmentation — the AST parser is no longer needed |
New packages
| Package | Purpose |
|---|---|
@mapomodule/mapo-integrations-camomilla | Nitro proxy middleware for Camomilla CMS — rewrites /api/* paths and handles Django session/cookie sync |
Renamed / restructured
| v1 | v2 |
|---|---|
packages/@mapomodule/* | packages/@mapomodule/* |
store/modules/app.js | @mapomodule/store → useSidebarStore, useSnackStore |
store/modules/user.js | @mapomodule/store → useAuthStore |
store/modules/media.js | @mapomodule/store → (media state moved into @mapomodule/form) |
mapomodule — from re-export to real Nuxt module (DP-9)
In v1, mapomodule was a JavaScript file that re-exported everything from @mapomodule/core. In v2 it started as a TypeScript re-export (export * from '@mapomodule/core') but has been refactored into a proper defineNuxtModule:
// mapomodule/src/module.ts
export default defineNuxtModule({
meta: { name: "mapomodule", configKey: "mapo" },
async setup() {
if (!hasNuxtModule("@mapomodule/core")) {
await installModule("@mapomodule/core");
}
},
}) satisfies NuxtModule;Nuxt 4.1 introduced moduleDependencies, and Mapo uses it in compatible modules (for example @mapomodule/store for Pinia). The mapomodule meta-module currently keeps resolver-based installModule for pnpm strict transitive resolution compatibility.
This means mapomodule in modules[] is now sufficient to install the entire Mapo stack — no need to list @mapomodule/core or @mapomodule/store separately.
Breaking changes
Vuex → Pinia
All Vuex module access is gone. Replace with Pinia composables:
// v1 — Vuex
this.$store.commit("mapo/user/SET_TOKEN", token);
this.$store.dispatch("mapo/app/showSnackMessage", {
message: "Saved!",
color: "success",
});
this.$store.getters["mapo/app/drawer"];
// v2 — Pinia
const auth = useAuthStore();
// No setToken — authentication is derived from user info, not a token value
// After login, use: auth.setUser(user) — called automatically by useMapoAuth().login()
const snack = useSnackStore();
snack.show("Saved!", "success");
const sidebar = useSidebarStore();
sidebar.drawer;Important:
setToken()does not exist in v2. The__mapo_sessioncookie is HttpOnly and cannot be stored in JavaScript state.isAuthenticatedis derived from whether user info has been loaded (!!auth.info). You should not need to callsetUserorresetdirectly —useMapoAuth()handles both.
Axios → $fetch
this.$axios is gone. All HTTP calls now go through $mapoFetch (configured by @mapomodule/core) or via useCrud.
// v1
const { data } = await this.$axios.get("/api/articles/");
// v2
const data = await useCrud<Article>("/api/articles/").list();
// or direct:
const { $mapoFetch } = useNuxtApp();
const data = await $mapoFetch("/api/articles/");$mapoFetch does not inject an Authorization header — auth is cookie-based. The 401/403 interceptors are handled automatically (session reset + redirect on 401, snack on 403).
nuxtServerInit → init server plugin
The Vuex nuxtServerInit action is replaced by a Nuxt server plugin in @mapomodule/core:
// v1 — store/index.js
actions: {
nuxtServerInit({ commit, dispatch }, { req }) {
const { __mapo_session } = cookieparser.parse(req.headers.cookie)
if (__mapo_session) {
commit('user/SET_TOKEN', __mapo_session)
await dispatch('user/getInfo')
}
}
}
// v2 — handled automatically by @mapomodule/core plugin (01.init.server.ts)
// No action required — authStore and sidebarStore are hydrated on every SSR request.
// The plugin forwards the full Cookie header to the backend and calls authStore.setUser().Route middleware
// v1 — middleware/auth.js
export default function ({ store, redirect }) {
if (!store.getters["mapo/user/isLoggedIn"]) redirect("/login");
}
// v2 — applied via definePageMeta, implemented in @mapomodule/core
definePageMeta({
middleware: ["auth"],
});Integration configuration
// v1 — nuxt.config.js
mapo: {
integrations: {
camomilla: {
location: '@mapomodule/mapo-integrations-camomilla',
configuration: {
api: { target: 'http://localhost:8000' }
}
}
}
}
// v2 — nuxt.config.ts
export default defineNuxtConfig({
modules: ['mapomodule', '@mapomodule/mapo-integrations-camomilla'],
mapo: {
authLoginUrl: '/api/auth/login',
userInfoApi: '/api/profiles/me/',
logoutUrl: '/api/auth/logout',
},
camomilla: {
server: 'http://localhost:8000',
}
})List view: MapoList config object → <MapoList> declarative props
In v1 a list view was a Vue component reading a mapoList config object. In v2 it is a typed Vue component driven by props/slots that share FieldDescriptor[] with <MapoDetail>.
<!-- v1 -->
<MapoList :crud="crud" :config="{ headers, filters, actions }" />
<!-- v2 -->
<MapoList
:crud="useCrud < Article > '/api/articles/'"
:columns="columns"
:filters="filters"
:quick-edit-fields="quickEditFields"
:tabs="[
{ key: 'draft', label: 'Drafts' },
{ key: 'published', label: 'Published' },
]"
@row-click="(row) => navigateTo(`/articles/${row.id}`)"
>
<template #cell.status="{ row }">
<UBadge :color="row.published ? 'success' : 'neutral'">
{{ row.published ? "Published" : "Draft" }}
</UBadge>
</template>
</MapoList>Quick-edit reuses the same field descriptors as <MapoDetail> — no separate dialog code. Drag reorder calls crud.updateOrder once, not one PATCH per row.
Detail view: MapoDetail config → <MapoDetail> + FieldDescriptor[]
<!-- v1 -->
<MapoDetail :crud="crud" :config="{ fields, tabs, sidebar }" />
<!-- v2 -->
<script setup lang="ts">
import type { FieldDescriptor } from "mapomodule/types";
const fields: FieldDescriptor<Article>[] = [
{ key: "title", type: "text", required: true, tab: "content" },
{ key: "body", type: "editor", tab: "content", translatable: true },
{ key: "is_draft", type: "switch", group: "sidebar" },
];
</script>
<template>
<MapoDetail
:crud="useCrud<Article>('/api/articles/')"
:fields="fields"
:languages="['en', 'it']"
/>
</template><MapoDetail> automatically wires useMapoForm(), useCrud, the unsaved-changes guard, the snack/confirm bridges, and the language switcher. It sends a differential PATCH (only the changed keys) and surfaces field-level 400 errors on the right field.
Form fields: declarative fields config (compatible)
The fields: FieldDescriptor<T>[] shape stays similar in spirit to v1 but is now a TypeScript discriminated union: typos in key are caught by the IDE, type: 'select' requires attrs.options, and unknown types render as a yellow placeholder instead of crashing.
Per-field i18n:
// v1 — `i18n: true` on the descriptor
// v2
{ key: 'title', type: 'text', translatable: true } // reads/writes model.translations[lang].title
{ key: 'body', type: 'editor', translatable: true, synci18n: true } // also propagates to all langsCustom field registration moved from "stick a Vue component into a folder" to a typed plugin call:
// v2 — app/plugins/my-fields.ts
export default defineNuxtPlugin(() => {
defineFormField("video-cut", () => import("~/components/VideoCutField.vue"), {
attrs: { aspectRatio: "16:9" },
});
});See Custom fields → for the per-page registry override and descriptor.is escape hatches.
Legacy is: components → v2 field equivalents
In v1 a field descriptor could use is: 'ComponentName' to render a fully custom field component (e.g. is: 'AnimatedTitleFields'). In v2 the same data is expressed as multiple standard FieldDescriptor objects — no custom component needed.
Group / tab structural change:
| v1 concept | v2 equivalent |
|---|---|
Top-level group (renders a tab) | tab: 'name' on each field |
| Nested group inside a group | group: 'name' on each field |
| Sidebar group | sidebarFields prop on <MapoDetail> |
Component mapping:
Legacy is: | v2 fields |
|---|---|
AnimatedTitleFields | 3 × type: 'text': pre_title, underline_title, post_title |
CTAField | 2 × type: 'text': title, link |
IconSelect with coloredIconChoices | type: 'select' + attrs.items array |
Prefooter | type: 'media' + type: 'media' + type: 'switch' + type: 'text' |
Before (v1):
// Descriptor with is: pointing to a monolithic component
{ label: 'Hero', is: 'AnimatedTitleFields', field: 'hero' }
// → renders one widget that internally manages pre_title / underline_title / post_title
{ label: 'CTA', is: 'CTAField', field: 'cta' }
// → one widget managing title + link
{ label: 'Icona', is: 'IconSelect', field: 'icon', coloredIconChoices: true }
// Top-level groups were tabs in the UI:
groups: [
{ name: 'Contenuto', fields: [ ... ] }, // → top-level tab
{
name: 'SEO',
groups: [ // → nested group inside tab
{ name: 'Meta', fields: [ ... ] },
],
},
]After (v2):
import type { FieldDescriptor } from "mapomodule/types";
import { KnownFieldType } from "mapomodule/types";
interface PageModel {
hero_pre_title: string;
hero_underline_title: string;
hero_post_title: string;
cta_title: string;
cta_link: string;
icon: string;
prefooter_bg: string | null;
prefooter_logo: string | null;
prefooter_dark_mode: boolean;
prefooter_tagline: string;
}
const fields: FieldDescriptor<PageModel>[] = [
// ── AnimatedTitleFields → 3 plain text fields (tab: 'contenuto') ─────────
{
key: "hero_pre_title",
type: "text",
label: "Pre-titolo",
tab: "contenuto",
cols: 12,
},
{
key: "hero_underline_title",
type: "text",
label: "Titolo",
tab: "contenuto",
cols: 12,
},
{
key: "hero_post_title",
type: "text",
label: "Post-titolo",
tab: "contenuto",
cols: 12,
},
// ── CTAField → 2 plain text fields ───────────────────────────────────────
{
key: "cta_title",
type: "text",
label: "Testo CTA",
tab: "contenuto",
cols: 8,
},
{
key: "cta_link",
type: "text",
label: "URL CTA",
tab: "contenuto",
cols: 4,
},
// ── IconSelect with coloredIconChoices → select + items ──────────────────
{
key: "icon",
type: KnownFieldType.Select,
label: "Icona",
tab: "contenuto",
attrs: {
items: [
{ label: "🔵 Primario", value: "primary" },
{ label: "🟡 Accent", value: "accent" },
{ label: "⚫ Neutro", value: "neutral" },
],
},
},
// ── Prefooter → media + media + switch + text (group card) ───────────────
{
key: "prefooter_bg",
type: "media",
label: "Sfondo",
tab: "contenuto",
group: "prefooter",
},
{
key: "prefooter_logo",
type: "media",
label: "Logo",
tab: "contenuto",
group: "prefooter",
},
{
key: "prefooter_dark_mode",
type: "switch",
label: "Dark mode",
tab: "contenuto",
group: "prefooter",
},
{
key: "prefooter_tagline",
type: "text",
label: "Tagline",
tab: "contenuto",
group: "prefooter",
},
];Tip: use
flattenFieldGroupswhen you have many fields sharing the sametab/group— it lets you author them as a nested tree and avoid repeating the same keys on every descriptor.
Component override: MapoOverride* folder → app/mapooverride/
v1: components/MapoOverrideTopbar.vue (prefix-based)
v2: app/mapooverride/MapoTopbar.vue (exact name match)The MapoOverride prefix is gone in v2 — the file must match the exact component name. See MapoOverride System →.
Page meta
// v1 — export default {} in page script
export default {
meta: { title: "Articles", icon: "mdi-file" },
middleware: "auth",
};
// v2 — definePageMeta (Nuxt 4 native, no AST parser needed)
definePageMeta({
middleware: ["auth", "permissions"],
label: "Articles",
icon: "mdi-file",
permissions: { model: "article", action: "view" },
});