| name | vercel-inline-cms |
| description | Install a lightweight inline-editing CMS into any Vercel-deployed project — content store + EditableText component + cookie-auth admin shell + Vercel KV/Upstash/Blob storage. Auto-detects the framework and installs natively for Next.js (App Router & Pages Router) and Vite + React. For SvelteKit, Astro, Nuxt, or plain static, prints a manual-port guide. Use when the user asks to add a CMS, inline editing, editable text, an admin panel, or "make this site editable." |
/vercel-inline-cms — Vercel Inline CMS
Drop a working inline-editing CMS into any project deployed on Vercel.
Origin
Extracted from the Nickson Studios website CMS (May 2026) — a hand-rolled inline-editing system (Context provider + EditableText + cookie auth + swappable storage) that emerged from wanting client-editable copy on a Vite + React site without bolting on a heavyweight headless CMS. After running on Nickson Studios for several weeks, the four-layer architecture (content store, inline editor, admin shell, swappable backend) had proven itself simple enough to generalize. This skill packages that same architecture and adapts it to any project deployed on Vercel: Next.js App Router, Next.js Pages Router, Vite + React (auto-installed); SvelteKit/Astro/Nuxt/static (documented manual port).
The storage layer was rewritten during extraction to auto-select from Vercel-provisioned env vars (Vercel KV, Upstash Redis, Vercel Blob) rather than hardcoding @vercel/kv — so projects on the current Vercel Marketplace stack work without code changes.
This skill is designed for projects deployed on Vercel. It uses Vercel serverless functions for the API, Vercel-provisioned storage (KV, Upstash Redis, or Blob) for persistence, and Vercel environment variables for secrets. It will install successfully on a project that isn't yet deployed to Vercel, but its assumptions (the /api/*.js convention, the storage env var names, vercel.json rewrites) are Vercel-specific. For other hosts (Render, Fly, self-hosted Node), this is the wrong skill.
What this skill installs
A four-part CMS:
- Content store —
ContentContext React provider holding a JSON content object, hydrated from GET /api/content with defaultContent.js as fallback.
- Inline editor —
<EditableText path="..." /> renders plain text for visitors and a contentEditable field for logged-in admins.
- Admin shell —
AdminGate (auth check + login form) → AdminShell (mounts EditModeProvider, Cmd+S, beforeunload guard) → AdminToolbar (sticky Save/Discard/Sign out bar). All three are required — without AdminShell, <EditableText> stays read-only.
- Storage backend — Auto-selects from Vercel-provisioned env vars: Vercel KV (
KV_REST_API_URL/KV_REST_API_TOKEN), Upstash Redis (UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN), or Vercel Blob (BLOB_READ_WRITE_TOKEN). In local dev with none set, falls back to an in-memory map.
The components ship with 'use client' directives so the same code works as React Client Components in Next.js App Router and as ordinary components in Vite + React (which ignores the directive).
Step 0 — Detect the framework
Read package.json and vercel.json (if present) to classify the project:
| Signal | Result | Path |
|---|
next dep + app/ directory exists | Next.js App Router | Tier 1 — install |
next dep + pages/ directory exists (no app/) | Next.js Pages Router | Tier 1 — install |
vite dep + react dep | Vite + React | Tier 1 — install |
@sveltejs/kit dep | SvelteKit | Tier 2 — manual port |
astro dep | Astro | Tier 2 — manual port |
nuxt dep | Nuxt | Tier 2 — manual port |
index.html at project root, no framework dep | Plain static | Tier 2 — manual port |
| Anything else | Unknown — ask the user | |
For Tier 2 frameworks, do not install. Tell the user the architecture transfers but the file layout differs, then offer to read out the relevant section of <skill-dir>/templates/docs/manual-port.md (or write it to docs/cms-port.md in their project).
For Tier 1 frameworks, continue with Step 1.
Step 1 — Verify the stack
After detection, run these checks for the matching Tier 1 path. If any fail, stop and report — do not force the install.
Hard requirements (abort if missing)
- All Tier 1:
package.json exists.
- Next.js App Router:
app/layout.tsx, app/layout.jsx, src/app/layout.tsx, or src/app/layout.jsx exists (entry point we'll patch). Both top-level app/ and src/app/ layouts are supported. The relative-path depths in Steps 3–5 work identically for either — paste snippets unchanged.
- Next.js Pages Router:
pages/_app.tsx, pages/_app.jsx, src/pages/_app.tsx, or src/pages/_app.jsx exists.
- Vite + React:
src/main.jsx or src/main.tsx exists.
- TypeScript projects (.tsx layout): confirm
tsconfig.json has "allowJs": true (default in create-next-app). The CMS components ship as .jsx without type definitions; imports from a .tsx consumer require allowJs. If allowJs is false, either enable it or convert the templates to .tsx before installing.
- All: no existing
src/cms/, src/admin/, app/admin/, src/app/admin/, or pages/admin/ directory (avoid clobbering). No existing api/login.js, api/logout.js, api/session.js, or api/content.js.
If any directory or file conflicts exist, list them and ask whether to overwrite, rename, or abort. Default to abort.
Soft requirements (detect and ask)
- Vite + React only: needs a router. If
react-router-dom is in package.json, use it. If not, ask: "The CMS admin needs nested routes. Install react-router-dom? (yes/no/abort)". Next.js uses its own router — no extra dep needed.
The components are framework-agnostic on styling: they use semantic class names styled by templates/src/cms/cms-admin.css (plain CSS with CSS variables for theming). No Tailwind dependency.
Step 2 — Ask about storage
Before copying any files, ask: "What storage backend will this use on Vercel? (1) Vercel KV / Upstash Redis — recommended for small content. (2) Vercel Blob — fine for any size; one JSON file. (3) Skip — I'll provision later (CMS will run in in-memory dev mode until then)."
Note: the user does not have to pick now — the storage layer auto-detects whichever env vars are set at runtime. The question is just to know what to put in .env.example and what provisioning command to suggest in the handoff.
Vercel CLI status (2026-05+)
The vercel storage create kv subcommand has been removed from recent Vercel CLI versions (54.x). KV/Upstash Redis must now be provisioned via the Marketplace (vercel integration add upstash-redis or the dashboard). Vercel Blob retains a first-class CLI: vercel blob create-store <name> --access public --yes.
If the user picked KV/Upstash but the CLI doesn't have that subcommand on their machine, stop and switch to Blob — don't silently leave the project unprovisioned. When you switch storage mid-install, return to Step 9 and install @vercel/blob (the dep-install step is conditional on storage choice — if you switched, the original branch may have skipped it).
Vercel Blob access mode
The current store.js template hardcodes access: 'public' on put() because the CMS content blob is rendered to all visitors anyway (no sensitive data; the value of the access setting is the URL shape on read). You must create the store with --access public, not --access private. A private store will reject the public put() and the save endpoint will 500 with the access mismatch.
Suggested handoff command, ready to copy-paste:
vercel blob create-store <project>-cms --access public --yes
--yes accepts the "link this store to " prompt non-interactively and propagates BLOB_READ_WRITE_TOKEN to all environments.
Step 3 — Copy shared files
These files are identical across all Tier 1 frameworks. Copy from <skill-dir>/templates/:
templates/src/cms/ContentContext.jsx → src/cms/ContentContext.jsx
templates/src/cms/EditableText.jsx → src/cms/EditableText.jsx
templates/src/cms/cms-admin.css → src/cms/cms-admin.css
templates/src/admin/AdminGate.jsx → src/admin/AdminGate.jsx
templates/src/admin/AdminShell.jsx → src/admin/AdminShell.jsx
templates/src/admin/AdminToolbar.jsx → src/admin/AdminToolbar.jsx
templates/src/admin/ArrayControls.jsx → src/admin/ArrayControls.jsx
templates/src/content/defaultContent.js → src/content/defaultContent.js
templates/api/_lib/auth.js → api/_lib/auth.js
templates/api/_lib/store.js → api/_lib/store.js
templates/api/content.js → api/content.js
templates/api/login.js → api/login.js
templates/api/logout.js → api/logout.js
templates/api/session.js → api/session.js
Notes:
- The
api/*.js files use Vercel's Node serverless function convention. They always live at the project root, not under src/api/ — Vercel routes them as separate serverless functions even inside a Next.js project. No need to convert to app/api/.../route.ts for App Router.
- The React components have
'use client' at the top so they work as Client Components in Next.js App Router. Vite ignores the directive.
Project layout cases:
src/ exists (most modern Next + most Vite): copy as shown above — src/cms/, src/admin/, src/content/.
- No
src/ (legacy Next setup): drop the src/ prefix — put cms/, admin/, content/ next to app/ (or pages/).
api/ is always project-root regardless — never src/api/.
The components reference each other by relative paths only.
Step 4 — Patch the entry point (mount <ContentProvider>)
Do not replace the existing layout file. Most real projects have fonts, metadata, structured data, theme providers, smooth-scroll providers, analytics, etc. already wired into the root layout. Pasting a from-scratch example would destroy that.
What you must do, in this order:
- Add the import for
ContentProvider.
- Add the import for the admin CSS.
- Find the existing
{children} JSX expression inside <body> and wrap it with <ContentProvider>...</ContentProvider>.
Provider ordering
<ContentProvider> should wrap outside of:
- Router providers (if any — Next App Router has none to worry about, Vite uses
<BrowserRouter>).
- Theme / dark-mode providers whose toggles shouldn't trigger a content refetch.
<ContentProvider> should wrap inside of:
<html> and <body> tags (it's a client component, can't sit outside).
- Server components that hydrate above the React tree.
Other client providers (Lenis smooth scroll, framer-motion configs, analytics shells) can sit either inside or outside <ContentProvider> — there's no required ordering. Putting <ContentProvider> outside them means content is hydrated once and survives provider re-renders.
Next.js App Router — diff applied to existing app/layout.tsx (or src/app/layout.tsx)
import { ContentProvider } from '../cms/ContentContext';
import '../cms/cms-admin.css';
Verify the file still parses (bun run build or next build) before moving on.
Next.js Pages Router — pages/_app.tsx
import { ContentProvider } from '../cms/ContentContext';
import '../cms/cms-admin.css';
export default function App({ Component, pageProps }) {
return (
<ContentProvider>
<Component {...pageProps} />
</ContentProvider>
);
}
Vite + React — src/main.jsx
import { ContentProvider } from './cms/ContentContext';
import './cms/cms-admin.css';
<ContentProvider>
<App />
</ContentProvider>
Wrap outside any router; inside any provider whose state should survive a route change.
Step 5 — Add admin routes
The admin lives at /admin with public pages mirrored under /admin/edit/<slug>. The wrapper chain is the same in every framework: AdminGate → AdminShell → your page.
Next.js App Router — add app/admin/layout.tsx + edit page
Paths are identical whether the project uses app/ or src/app/. The relative depths (../../admin/AdminGate, ../../../page) are the same in both. Copy unchanged.
import AdminGate from '../../admin/AdminGate';
import AdminShell from '../../admin/AdminShell';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<AdminGate>
<AdminShell>{children}</AdminShell>
</AdminGate>
);
}
import { redirect } from 'next/navigation';
export default function AdminIndex() {
redirect('/admin/edit');
}
import Home from '../../../page';
export default function AdminEdit() {
return <Home />;
}
The [[...slug]] double-bracket syntax is Next's optional catch-all route — matches /admin/edit as well as /admin/edit/anything/here. Single-bracket [...slug] would make a slug required and would break the bare /admin/edit redirect target. Keep the double brackets.
For each additional public page you want to edit, add a sibling route under app/admin/edit/<slug>/page.tsx that imports and renders the same component.
Next.js Pages Router — pages/admin/[[...slug]].tsx
import AdminGate from '../../admin/AdminGate';
import AdminShell from '../../admin/AdminShell';
import Home from '../index';
export default function AdminPage() {
return (
<AdminGate>
<AdminShell>
<Home />
</AdminShell>
</AdminGate>
);
}
Vite + React — codemod src/App.jsx
Read src/App.jsx. Identify the <Routes> block. Inject an admin subtree as a sibling of the public routes:
import AdminGate from './admin/AdminGate';
import AdminShell from './admin/AdminShell';
import { Outlet, Navigate } from 'react-router-dom';
function AdminGateRoute() { return <AdminGate><Outlet /></AdminGate>; }
function AdminShellRoute() { return <AdminShell><Outlet /></AdminShell>; }
<Route path="/admin" element={<AdminGateRoute />}>
<Route element={<AdminShellRoute />}>
<Route index element={<Navigate to="/admin/edit" replace />} />
<Route path="edit" element={<Home />} />
{/* one <Route path="edit/<slug>" element={<Page />}/> per public page */}
</Route>
</Route>
After editing, re-read the file and verify the JSX parses. If it doesn't, revert and print the snippet for the user to paste in.
If you add more edit/<slug> routes, also extend the PAGES array at the top of src/admin/AdminToolbar.jsx with one entry per route so the toolbar nav shows them.
Fall back to paste-in instructions if: App.jsx doesn't exist, uses createBrowserRouter with a deeply customized object config, or imports routes from another file you can't trace.
Step 6 — Write the SPA rewrite (Vite + React only)
For Vite SPAs, copy templates/vercel-spa.json to the project root as vercel.json (only if no vercel.json exists). It contains the catch-all rewrite needed so refreshing /admin/edit doesn't 404:
{ "rewrites": [{ "source": "/((?!api/).*)", "destination": "/index.html" }] }
Skip this step for Next.js — Next handles routing on its own and this rewrite would break it.
Step 7 — Update .env.example
Append (create the file if missing):
ADMIN_PASSWORD=change-me-before-deploying
SESSION_SECRET=generate-with-openssl-rand-hex-32
KV_REST_API_URL=
KV_REST_API_TOKEN=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
BLOB_READ_WRITE_TOKEN=
If .env or .env.local exists, append the same block there with placeholder values and warn the user to fill them in.
Step 8 — Update .gitignore
Ensure these lines exist:
.env
.env.local
.env*.local
.vercel
.vercel/ holds Vercel CLI link metadata + downloaded env vars from vercel env pull — never commit it.
Step 9 — Install missing deps
Detect the package manager from lockfiles (pnpm-lock.yaml → pnpm, yarn.lock → yarn, bun.lock or bun.lockb → bun, else npm) and install:
- Vite + React:
react-router-dom if the user said yes in Step 1.
- Vercel Blob (if user picked it in Step 2 OR switched to Blob mid-install):
@vercel/blob.
- Vercel KV / Upstash Redis: nothing — the store uses
fetch directly against the REST API, no SDK needed.
Important — mid-install storage switch: if the storage choice changed between Step 2 and the actual provisioning (e.g., user picked KV but you discovered the KV CLI subcommand is gone and switched to Blob), return to Step 9 and run the dep install for the new choice. Missing @vercel/blob is the single most common cause of post-install /api/content 500 errors.
Skip if nothing to add.
Step 10 — Wire one editable field as a demo
Pick one obvious piece of text and convert it to <EditableText>. Search in this order — pick the first that exists:
- Next.js App Router:
app/page.tsx (or src/app/page.tsx)
- Next.js Pages Router:
pages/index.tsx
- Vite + React:
src/pages/Home.jsx → src/App.jsx
The canonical conversion pattern
Find the first <h1> or hero heading. Do not replace its text content — that loses the parent element's tag, classes, and any wrappers (<Reveal>, motion components, etc). Instead, replace the entire element with <EditableText> configured to render the same tag with the same classes:
<h1 className="luxe-display mt-10 max-w-5xl">
Sixty households a year. Every detail is in place before the household lands.
</h1>
<EditableText
as="h1"
path="hero.headline"
className="luxe-display mt-10 max-w-5xl"
fallback="Sixty households a year. Every detail is in place before the household lands."
/>
as="h1" preserves the semantic tag. className keeps the styling. fallback is belt-and-suspenders insurance if defaultContent.js is missing the path — typically dead code after the next step, but cheap to leave in.
Add the import:
import { EditableText } from '../cms/EditableText';
Update src/content/defaultContent.js so hero.headline matches the page's original text — visitors see the same string they saw before; admins can now click it to edit.
If the H1 is inside wrappers (e.g. <Reveal><h1>...</h1></Reveal>), keep the wrapper and swap only the <h1> for <EditableText as="h1" ... />. The wrapper continues to handle its concerns (reveal-on-scroll, motion); EditableText handles the text + edit affordance.
If no obvious heading is found, skip the demo and tell the user how to wire one manually.
Step 11 — Print the handoff checklist
Output exactly this (substitute the framework + storage lines):
✓ Vercel Inline CMS installed.
Framework detected: <Next.js App Router | Next.js Pages Router | Vite + React>
Storage choice: <Vercel KV/Upstash | Vercel Blob | Skipped (in-memory dev only)>
Next steps (you must do these — I can't):
1. Set ADMIN_PASSWORD in .env.local to a real password.
2. Generate SESSION_SECRET: openssl rand -hex 32
3. Provision storage on Vercel:
KV/Upstash: vercel storage create kv
Blob: vercel storage create blob
This auto-adds the env vars to your Vercel project.
4. Pull the new env vars locally: vercel env pull .env.local
5. Run your dev server, visit /admin, log in with ADMIN_PASSWORD.
6. Click any <EditableText> field on the site to edit it inline.
Cmd+S saves; the toolbar shows dirty state.
I wired up one editable field as a demo: hero.headline.
Add more by wrapping text in <EditableText path="some.dot.path" /> and adding
the default value to src/content/defaultContent.js.
Deploy with: vercel --prod
Things to refuse or warn about
- Hardcoding the password into the code: refuse. Explain it must be an env var.
- Skipping auth ("just let anyone edit"): warn strongly, then refuse. Public inline editing = vandalism risk.
- Installing on a Tier 2 framework anyway: don't. Print the relevant section of
docs/manual-port.md and stop.
- Existing
src/cms or src/admin directory: ask before overwriting.
- Project that isn't going to Vercel: this skill's storage layer expects Vercel-provisioned env vars. If the user explicitly says they're hosting elsewhere (Render, Fly, self-hosted Node), tell them this isn't the right skill — the architecture transfers but the storage layer would need replacing with a database client.
Mental model for debugging
If something breaks after install, the four layers fail in characteristic ways. State which layer you think is failing before changing code.
- Content not loading →
/api/content GET returns {} or errors. Check the network tab; check ContentProvider wraps the app; check the storage backend (env vars set in Vercel, vercel env pull run locally).
- Edits don't save → POST to
/api/content returns 401 or 500. 401 = session cookie missing/expired (re-login). 500 = store write failed; check the function logs in Vercel for the storage error.
- Can't log in → POST
/api/login returns 401 even with the right password. Check ADMIN_PASSWORD is set in the running process's env (vercel env ls for prod; .env.local for dev). Check SESSION_SECRET is set.
<EditableText> shows nothing → the path doesn't exist in defaultContent.js. Add it. The component returns empty string for missing paths so a typo silently shows blank.
<EditableText> shows text but won't go editable on /admin/edit/... → AdminShell isn't wrapping the page tree. Re-check Step 5: the page must be a descendant of both AdminGate and AdminShell.
- "No CMS storage backend configured" error in prod → user deployed but didn't run the storage provisioning command. Point them at Step 11 instruction 3.
- POST
/api/content returns 500 with "BLOB_READ_WRITE_TOKEN is set but @vercel/blob is not installed" → run the package install for the chosen storage backend (npm install @vercel/blob / bun add @vercel/blob). Most common after a mid-install storage switch — see Step 9.
- POST
/api/content returns 500 and Vercel function logs show a Blob access error → the Blob store was created with --access private but store.js uses access: 'public' on put(). Recreate the store with --access public (vercel blob delete-store <id> --yes && vercel blob create-store <name> --access public --yes). The content blob is not sensitive — it's rendered to all visitors regardless, so public is the correct setting.
CSS / theming
All admin styling lives in templates/src/cms/cms-admin.css and uses CSS variables (--cms-accent, --cms-bg, etc.). To rebrand, override any of those variables in a stylesheet imported after cms-admin.css. Plain CSS, no preprocessor, no runtime CSS-in-JS — works in any project regardless of styling stack.