@consilioweb/payload-maintenance
v0.9.0
Published
Payload CMS plugin — customizable maintenance mode page with i18n support
Maintainers
Readme
@consilioweb/payload-maintenance
Maintenance mode for Payload CMS 3 + Next.js: a configurable public page, an admin toggle, scheduling, audit history and webhooks.
About
Taking a Payload site offline usually means hand-rolling a Next.js middleware, a static page and a way to turn it back on. This plugin ships all three: a maintenance global in the admin panel, a standalone HTML page served by the plugin itself (no route to create), and a middleware helper that returns HTTP 503 with Retry-After so crawlers do not de-index the site.
Around that core it adds what an incident actually needs: an audit trail of who took the site down and for how long, Slack/Discord webhooks with retry, a GDPR-compliant newsletter form with unsubscribe, page-view analytics, and scheduled windows.
Since 0.6.0 the plugin is admin-only and fails closed: the configuration global is no longer world-readable, the admin endpoints reject users from non-admin auth collections, and a database outage keeps the site in maintenance instead of silently reopening it.
Table of Contents
- Features
- Installation
- Quick Start
- Plugin Options
- Middleware Options
- Templates
- Presets
- API Endpoints
- Collections
- Bypass Maintenance
- Package Exports
- Upgrading
- Personal data and retention
- Database and updates
- Uninstall
- Requirements
- Support
- License
Features
- Self-contained maintenance page — served as standalone HTML by
GET /api/maintenance/page. No/maintenanceroute to create in your app. - 12 page templates — from
minimalto canvas-basedparticles, plus acustomHTML template with{{variables}}. - 12 one-click presets — template + colors + fonts + messages, applied from the admin dashboard.
- Multi-language page — per-language title, description and CTA configured in the admin, with browser-language auto-detection and a switcher. The page chrome (countdown labels, contact and newsletter strings) ships in 10 languages:
fr,en,de,es,it,pt,nl,ja,ar,zh. - Scheduled maintenance — start/end dates with auto-enable and auto-disable, driven by
POST /api/maintenance/schedule-check(wire it to a cron) and re-checked by the middleware. - Audit history — every activation and deactivation is logged with who triggered it and how long the site was down, plus every webhook that exhausted its retries.
- Webhooks — Slack, Discord or custom JSON, fired on every toggle, with exponential backoff (3 attempts: 1s, 2s, 4s) and a log collection.
- Newsletter with GDPR consent — signup form on the page,
consent: truerequired,consentAt/consentSourcestored, unique unsubscribe token, duplicate prevention, admin CSV export. - Page-view analytics, anonymised by default — path, referer, user agent and a truncated IP (/24 or /48) recorded while maintenance is on, with top-paths and top-referers aggregation.
analyticsIpModeopts into the full address or into no address at all. - Retention purge — analytics dropped after 13 months by default, on demand through
DELETE /api/maintenance/analytics/purgeand daily through a Payload Jobs task when your app runs the job system. NosetInterval, so nothing duplicates behind several instances. - Uninstaller —
npx maintenance-uninstalldeletes the plugin's documents through the Payload Local API (all three adapters, custom slugs honoured) instead of leaving visitor IPs behind with no UI to erase them. - SEO — HTTP 503 (configurable),
Retry-Aftercomputed from the estimated end date,X-Robots-Tag: noindexand<meta name="robots" content="noindex,nofollow">. - Admin dashboard at
/admin/maintenance— toggle, live preview, subscriber count with CSV export, history timeline, preset gallery. Plus a toggle widget on the main dashboard and a sidebar nav link. - Design customization — Google Fonts by name, Lottie animation by URL, dark/light/auto mode, custom CSS and HTML, logo, favicon, background image, background video, split image, social links (8 platforms), contact email.
Security
- Admin-only authorization — the maintenance global and the seven admin endpoints require a user of the Payload admin collection (
config.admin.user), not merelyreq.user. Override withadminCollectionSlug, or plug your own RBAC withadminAccess. - The
/admin/maintenanceview is gated too — Payload deliberately skips its owncanAccessAdminredirect for custom admin views and delegates the decision to the view, so/admin/maintenanceenforces it itself: it requires both Payload'scanAccessAdminand the sameisMaintenanceAdmingate as the endpoints, and redirects to/admin/unauthorizedotherwise. Without it, a member of any other auth collection of your app (acustomersaccount created by public sign-up) reached the admin panel through this route. - Field-level guards —
webhooks[].url,bypassSecret,allowedIPsandnotifyEmailcarry their ownaccess.read, so they stay hidden even if a host re-opens the global. - Rate limiting per IP on the public endpoints: status 60/min, newsletter 5/min, track 30/min, unsubscribe 10/min.
- Fail-closed status —
GET /statusanswers503instead of a fabricated{ enabled: false }when the global cannot be read, and the middleware keeps the last known state when/statusfails. - Auth cookie validation — the middleware validates the Payload token against
/api/<usersCollectionSlug>/me(positive and negative answers cached 15s; transient failures are not cached). - Input validation — email regex on signup, IANA timezone, schedule cross-field (end after start), custom CSS rejecting
script/iframe, preset ID whitelist, UUID check on unsubscribe. - CSV export hardening — every cell quoted, and formula-injection prefixes (
=,+,-,@, tab, CR) neutralised. trustProxy/trustedProxyHops— control whetherx-forwarded-for/x-real-ipis trusted, and which entry of the chain is the real client. The plugin option now reaches every rate-limited endpoint (/status,/newsletter,/track,/unsubscribe); the middleware has its own pair, which governs theallowedIPscheck.- SSRF guard on webhooks — a webhook URL that resolves to a loopback, RFC1918, CGNAT or link-local address (including its IPv4-mapped IPv6 form,
::ffff:10.0.0.1) is refused before the request leaves, redirects are never followed, andallowedWebhookHostsnarrows the destinations further. The target is re-resolved before every retry, and webhook URLs must behttps://: the guard resolves the name itself andfetchthen resolves it again, so a record with a 0s TTL can swap in an internal address between the two. Over TLS the certificate is the pin — an internal service cannot present one valid for the configured hostname — which plaintexthttp://does not offer.allowedWebhookHostsremains the only control that does not depend on that reasoning: set it (['hooks.slack.com', 'discord.com']) whenever you can. - Signed bypass cookie — the cookie carries
<expiry>.<HMAC-SHA256>instead of the stringtrue, is only accepted when abypassSecretis configured, and both it and?bypass=are compared in constant time. - Throttles keyed by caller, never globally — wrong
?bypass=values and unknown auth cookies are counted per resolved client IP (10/min and 30/min). There is deliberately no global bucket and no sharedunknownbucket: one would let any anonymous visitor exhaust it and lock the operator out of their own bypass. A caller whose IP cannot be resolved is therefore not throttled on these two paths — configuretrustProxy/trustedProxyHops, and use a high-entropybypassSecret. - Auth cache split in two zones — validated sessions and rejected tokens are capped separately (500 each), so a flood of unknown cookies cannot evict the entries of signed-in admins.
- Sandboxed custom HTML —
customHTML/customCSSare rendered inside aniframe sandbox=""(opaque origin, scripting disabled), so a stored payload cannot execute on the site's origin. - Admin-only collections — the four plugin collections (
subscribers,history,analytics,webhook-logs) answerread/create/update/deleteonly for the admin collection (or youradminAccess), never for any authenticated user of another auth collection. - Segment-boundary path matching —
excludedPaths: ['/admin']no longer leaves/administration-des-ventesonline. - Isolated admin components — the sidebar nav link (rendered on every admin page) and the dashboard toggle each sit behind their own error boundary with a silent fallback, so an exception in a maintenance widget cannot take down the whole Payload panel. The
/admin/maintenanceview has one too, with a visible fallback and a retry that actually remounts the subtree.
Installation
pnpm add @consilioweb/payload-maintenanceOr with npm / yarn:
npm install @consilioweb/payload-maintenance
yarn add @consilioweb/payload-maintenancePeer Dependencies
| Package | Version | Required |
|---------|---------|----------|
| payload | ^3.79.1 | Yes |
| @payloadcms/next | ^3.79.1 | Optional (admin view) |
| @payloadcms/ui | ^3.79.1 | Optional (admin UI) |
| @payloadcms/translations | ^3.79.1 | Optional (i18n) |
| next | ^15.0.0 \|\| ^16.0.0 | Optional (middleware, admin view) |
| react | ^19.0.0 | Optional (client components) |
| react-dom | ^19.0.0 | Optional (client components) |
[!IMPORTANT] The Payload floor is
3.79.1, not3.0.0. Payload below3.79.1is vulnerable to a pre-authentication account takeover (GHSA-hp5w-3hxx-vmwf) and to an SQL injection. Nothing in this plugin needs an API newer than Payload 3.0 — the floor is a security floor, and the@payloadcms/*packages ship in lockstep withpayload, so they carry the same one. Payload>= 3.79.1in turn requires Next.js 15+ and React 19, which the ranges above now state instead of pretending to support Next 14 / React 18.
[!IMPORTANT] Next.js 16 + Turbopack — known issue. With Next.js 16 and Turbopack (the default bundler) you may hit a
createContext is not a functionerror duringnext build. This is a Payload CMS issue (#15429, #14330), not specific to this plugin.Workaround — add this to your admin page (
src/app/(payload)/admin/[[...segments]]/page.tsx):export const dynamic = 'force-dynamic'And list the plugin in
transpilePackagesinnext.config.ts:transpilePackages: ['@consilioweb/payload-maintenance'],Next.js 15 works without any workaround.
Quick Start
1. Add the plugin
// payload.config.ts
import { buildConfig } from 'payload'
import { maintenancePlugin } from '@consilioweb/payload-maintenance'
export default buildConfig({
plugins: [
maintenancePlugin({
languages: [
{ label: 'Francais', value: 'fr' },
{ label: 'English', value: 'en' },
],
}),
],
// ...rest of your config
})2. Add the middleware
// src/middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { createMaintenanceMiddleware } from '@consilioweb/payload-maintenance/middleware'
const maintenanceMiddleware = createMaintenanceMiddleware()
export async function middleware(request: NextRequest) {
const maintenanceResponse = await maintenanceMiddleware(request)
if (maintenanceResponse) return maintenanceResponse
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)'],
}3. Regenerate the importmap
pnpm generate:importmapThe plugin then adds:
- a Maintenance global (
maintenance) - up to 4 collections — subscribers, history and analytics follow their
enable*flags, webhook logs is always added - 13 API endpoints, including the standalone HTML page
- an admin dashboard view at
/admin/maintenance - a toggle widget on the main admin dashboard and a sidebar nav link
The maintenance page is self-contained: the plugin serves it via
GET /api/maintenance/page. You do not need to create a/maintenanceroute in your Next.js app.
Plugin Options
Everything passed to maintenancePlugin(). The deprecated options below still compile and log a warning at boot: they are middleware concerns and the plugin never reads them. They are not removed in this release — dropping them would break the build of every consumer that passes one. Planned removal: v1.0.0, not before 2027-03-08.
Each was re-examined rather than assumed dead. Four cannot be wired at all without either publishing a secret (bypassSecret) or adding an endpoint the middleware would have to call before it decides anything (allowedIPs, bypassCookieName, usersCollectionSlug); one was never implemented and honouring it now would be a new feature, not a fix (maintenancePageComponent). authBypass was the exception, and it is wired — see its row below.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| globalSlug | string | 'maintenance' | Slug of the configuration global |
| endpointBasePath | string | '/maintenance' | Prefix for every API endpoint |
| languages | { label: string; value: string }[] | [{ Francais, fr }, { English, en }] | Languages offered for the maintenance page messages |
| excludedPaths | string[] | ['/admin', '/api'] | Paths reported as always accessible by GET /status. The Next.js middleware has its own excludedPaths and does not read this one — keep both in sync |
| addDashboardView | boolean | true | Register the admin view at /admin/maintenance |
| showDashboardToggle | boolean | true | Show the toggle widget on the main admin dashboard |
| mediaCollectionSlug | string | 'media' | Collection used for logo, favicon, background and split image uploads |
| enableSubscribers | boolean | true | Add the newsletter subscribers collection and its endpoints |
| subscribersSlug | string | 'maintenance-subscribers' | Slug of the subscribers collection |
| enableHistory | boolean | true | Add the history / audit collection |
| historySlug | string | 'maintenance-history' | Slug of the history collection |
| enableAnalytics | boolean | true | Add the analytics collection and its endpoints |
| analyticsSlug | string | 'maintenance-analytics' | Slug of the analytics collection |
| analyticsIpMode | 'anonymized' \| 'full' \| 'none' | 'anonymized' | How the visitor's IP is stored. anonymized truncates IPv4 to /24 and IPv6 to /48; full keeps the whole address (you then need your own lawful basis); none writes no ip field at all. Rate limiting always uses the full address, which never leaves the process |
| analyticsRetentionDays | number | 395 | Days of analytics kept by the purge — 13 months, the CNIL ceiling for audience measurement. A value below 1 disables the purge instead of deleting everything |
| subscribersRetentionDays | number | undefined | Days of subscribers kept by the purge. Off by default: a subscriber row is also the proof of their consent |
| webhookLogsSlug | string | 'maintenance-webhook-logs' | Slug of the webhook logs collection (always added) |
| enableScheduling | boolean | true | Add scheduled maintenance and the schedule-check endpoint |
| adminCollectionSlug | string | Payload's admin collection (config.admin.user) | Collection whose users may administer maintenance mode: toggle, stats, export, analytics, presets, and read/update the global |
| adminAccess | ({ req }) => boolean \| Promise<boolean> | undefined | Custom authorization check for the admin endpoints, the global and the /admin/maintenance view. Overrides adminCollectionSlug — plug your own RBAC here. Note: the admin view additionally requires Payload's own canAccessAdmin, so granting maintenance to a collection outside config.admin.user opens the endpoints, not that page (Payload refuses that account everywhere else in /admin anyway) |
| trustProxy | boolean | true | Trust x-forwarded-for / x-real-ip when resolving the client IP. Now forwarded to every rate-limited endpoint (/status, /newsletter, /track, /unsubscribe). Set false when not behind a trusted reverse proxy |
| trustedProxyHops | number | 1 | How many reverse proxies append to x-forwarded-for. The client IP is read as parts[length - trustedProxyHops], because a conforming proxy appends the peer address — the first element of the header is whatever the caller sent. Use 2 for CDN + load balancer |
| allowedWebhookHosts | string[] | undefined | Allow-list of hostnames the webhook sender may contact (sub-domains match). Recommended — it is the only SSRF control that does not depend on DNS timing. Private, loopback, link-local and plaintext http:// targets are refused regardless of this option |
| ~~allowedIPs~~ | string[] | [] | Deprecated — no effect. The IP check runs in the Next.js middleware: pass it to createMaintenanceMiddleware({ allowedIPs }) |
| ~~bypassSecret~~ | string | undefined | Deprecated — no effect. Pass it to createMaintenanceMiddleware({ bypassSecret }) |
| ~~bypassCookieName~~ | string | 'maintenance-bypass' | Deprecated — no effect. The cookie is set and read by the middleware: createMaintenanceMiddleware({ bypassCookieName }) |
| authBypass | boolean | true | Seeds the default value of the authBypass checkbox on the global, which /status publishes and the middleware honours. Was a no-op before; it is now the only one of these middleware-shaped options that could be wired without exposing a secret. defaultValue applies to a global that has never been saved, so an existing install keeps its stored value — change it in the admin, or override it for everyone with createMaintenanceMiddleware({ authBypass: false }) |
| ~~usersCollectionSlug~~ | string | undefined | Deprecated — no effect. It only ever configured the middleware auth bypass: createMaintenanceMiddleware({ usersCollectionSlug }). It does not drive the admin authorization gate — adminCollectionSlug does |
| ~~maintenancePageComponent~~ | string | undefined | Deprecated — never implemented. The page is served by GET /api/maintenance/page; override it with the middleware option maintenancePagePath |
Middleware Options
Everything passed to createMaintenanceMiddleware().
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| apiUrl | string | Request origin | Base URL of the Payload API |
| excludedPaths | string[] | ['/admin', '/api'] | Never-blocked paths, matched on segment boundaries |
| cacheDuration | number | 10 | Status cache, in seconds |
| statusEndpoint | string | '/api/maintenance/status' | Status endpoint to poll |
| maintenancePagePath | string | '/api/maintenance/page' | Standalone HTML page endpoint to serve |
| return503 | boolean | true | Answer HTTP 503 instead of 200 |
| authBypass | boolean | true | Let logged-in admin users through |
| authCookieName | string | 'payload-token' | Payload auth cookie name |
| usersCollectionSlug | string | 'users' | Collection queried to validate the auth cookie — must be the Payload admin collection |
| bypassCookieName | string | 'maintenance-bypass' | Bypass cookie name |
| bypassSecret | string | undefined | Enables ?bypass=SECRET, which sets a 24h signed cookie (HMAC-SHA256 over the expiry). Without it, no bypass cookie is ever honoured. Compared in constant time; failed attempts are throttled. Server-side only, never exposed by the API |
| allowedIPs | string[] | [] | IPs that bypass maintenance. Only meaningful behind a proxy that rewrites x-forwarded-for — see trustedProxyHops. Server-side only, never exposed by the API |
| trustProxy | boolean | true | Trust x-forwarded-for / x-real-ip when resolving the client IP for the allowedIPs check. Distinct from the plugin option of the same name |
| trustedProxyHops | number | 1 | Number of reverse proxies appending to x-forwarded-for. The trusted value is the entry the closest proxy added, not the first element of the header |
| bypassCookieMaxAge | number | 86400 | Lifetime of the bypass cookie, in seconds. The cookie carries a signed, expiring proof — never a boolean |
Templates
Chosen from the template select on the maintenance global. All 12 are rendered by the standalone page endpoint, which is what visitors see.
| Template | Description |
|----------|-------------|
| minimal | Clean icon + message layout (default) |
| countdown | SVG circular ring countdown |
| coming-soon | Flip card countdown with separators |
| glassmorphism | Frosted glass card with floating orbs |
| gradient | Multi-color animated gradient background |
| split-screen | Content left, image right (responsive) |
| video-background | MP4 video with overlay |
| aurora | Northern lights: animated gradient layers, floating particles, SVG waves |
| neon | Cyberpunk: pulsing neon glow, grid background, scanline, corner brackets |
| mesh | Animated blobs with mix-blend-mode and noise texture |
| particles | Canvas particle system with mouse interaction |
| custom | Full custom HTML with {{variables}} |
The exported React
MaintenancePagecomponent implements the first seven pluscustom;aurora,neon,meshandparticlesfall back to its default layout there. Use the served page (/api/maintenance/page, which the middleware fetches) for the full set.
Presets
Template + colors + font + messages, applied in one click from the admin dashboard. Listed by GET /api/maintenance/presets, applied by POST /api/maintenance/presets/apply.
| Preset | Template | Style |
|--------|----------|-------|
| corporate-blue | countdown | Professional dark blue (Inter) |
| startup-launch | gradient | Purple/pink dynamic (Space Grotesk) |
| minimal-elegant | minimal | Clean dark (DM Sans) |
| glass-premium | glassmorphism | Luxurious purple (Outfit) |
| coming-soon-creative | coming-soon | Teal creative (Sora) |
| light-clean | minimal | Light mode (Plus Jakarta Sans) |
| warm-gradient | gradient | Warm orange tones (Poppins) |
| tech-dark | countdown | Cyan tech (JetBrains Mono) |
| aurora-borealis | aurora | Teal/violet northern lights (Space Grotesk) |
| cyberpunk-neon | neon | Magenta/cyan cyberpunk (Orbitron) |
| mesh-modern | mesh | Indigo/rose modern blobs (Geist) |
| particles-cosmic | particles | Deep space constellation (Inter) |
API Endpoints
Paths are relative to endpointBasePath (default /maintenance) under Payload's /api. Admin means a user of the admin collection — see adminCollectionSlug and adminAccess; anything else answers 401.
| Method | Path | Access | Description |
|--------|------|--------|-------------|
| GET | /api/maintenance/status | Public | Public status subset. Answers 503 with Retry-After: 10 if the global cannot be read |
| POST | /api/maintenance/toggle | Admin | Turn maintenance on or off |
| POST | /api/maintenance/newsletter | Public | Newsletter signup — requires { email, consent: true } |
| GET | /api/maintenance/stats | Admin | Subscriber count and recent history |
| GET | /api/maintenance/subscribers/export | Admin | CSV export (only when enableSubscribers) |
| GET | /api/maintenance/unsubscribe | Public | Unsubscribe by token (only when enableSubscribers) |
| POST | /api/maintenance/track | Public | Record a page view (only when enableAnalytics) |
| GET | /api/maintenance/analytics | Admin | Analytics data (only when enableAnalytics) |
| DELETE | /api/maintenance/analytics/purge | Admin | Run the retention purge now. Always registered, including when analytics are off, because subscribersRetentionDays may still be set |
| POST | /api/maintenance/schedule-check | Admin | Apply the scheduled window (only when enableScheduling) |
| GET | /api/maintenance/config | Public | Slugs and base path, used by the admin nav link and dashboard |
| GET | /api/maintenance/page | Public | Standalone HTML maintenance page |
| GET | /api/maintenance/presets | Public | List the available presets |
| POST | /api/maintenance/presets/apply | Admin | Apply a preset to the global |
Collections
Admin below means the same gate as the endpoints: a user of the admin collection (adminCollectionSlug, defaulting to config.admin.user), or your adminAccess. It is not "any authenticated user" — a member of another auth collection of your app (a customers account) is refused, which is what closed the hole where a customer could read the subscriber emails and IPs over /api/<slug>.
| Slug | Role | Read | Create | Update | Delete |
|------|------|------|--------|--------|--------|
| maintenance-subscribers | Newsletter signups | Admin | Admin | Admin | Admin |
| maintenance-history | Audit trail of every toggle | Admin | Admin | Never | Admin |
| maintenance-analytics | Page views during maintenance | Admin | Admin | Never | Admin |
| maintenance-webhook-logs | Webhook delivery attempts | Admin | Admin | Never | Admin |
Subscribers, history and analytics are only added when their enable* option is left on; webhook logs is always added. The plugin's own writes go through the Local API and are not subject to these rules — a public signup must call POST /api/maintenance/newsletter, which enforces the rate limit, the consent check and email validation.
maintenance-subscribers
| Field | Type | Description |
|-------|------|-------------|
| email | email (unique) | Subscriber email |
| language | text | Browser language, normalised to xx / xx-XX or unknown |
| subscribedAt | date | Registration date |
| ip | text | IP address |
| userAgent | text | Browser user agent |
| consentAt | date | GDPR consent timestamp |
| consentSource | text | Consent origin (maintenance-page) |
| unsubscribeToken | text (unique) | Token for the unsubscribe link |
maintenance-history
| Field | Type | Description |
|-------|------|-------------|
| action | select | activated / deactivated / webhook-failed. The schema also accepts scheduled-start, scheduled-end and config-updated, which nothing writes today — a scheduled window flips enabled, so it is logged as activated / deactivated |
| triggeredBy | text | User email, or system |
| timestamp | date | When it happened |
| duration | text | How long maintenance lasted (on deactivation) |
| details | json | Template and message count |
Bypass Maintenance
| Method | How |
|--------|-----|
| Bypass cookie | Visit ?bypass=YOUR_SECRET, which sets a 24h signed cookie. Requires createMaintenanceMiddleware({ bypassSecret }) — the admin field alone is not read by the middleware. Without a configured secret, no bypass cookie is accepted |
| IP whitelist | Requires createMaintenanceMiddleware({ allowedIPs }) — the admin field alone is not read by the middleware |
| Auth bypass | Logged-in users of the collection given by the middleware's usersCollectionSlug see the real site. Enabled by the authBypass checkbox on the global and the middleware's authBypass option |
| Route exclusion | List routes (/pricing, /legal/*) in the global's "Excluded routes" field |
| Path exclusion | The middleware's excludedPaths (default /admin, /api), matched on segment boundaries |
Package Exports
| Sub-path | Exposes | Environment |
|----------|---------|-------------|
| . | Plugin, global, collection factories, endpoint handlers, presets, access helper, types | Server |
| ./client | MaintenancePage, MaintenanceToggle, MaintenanceViewClient, MaintenanceNavLink | Client ('use client') |
| ./views | MaintenanceView — the admin view server component | Server (RSC) |
| ./middleware | createMaintenanceMiddleware, MaintenanceMiddlewareConfig | Next.js middleware (edge) |
// Server
import {
maintenancePlugin,
createMaintenanceGlobal,
createSubscribersCollection,
createHistoryCollection,
createAnalyticsCollection,
createWebhookLogsCollection,
createStatusHandler,
createToggleHandler,
createNewsletterHandler,
createSubscribersExportHandler,
createStatsHandler,
createScheduleCheckHandler,
createTrackViewHandler,
createAnalyticsHandler,
createUnsubscribeHandler,
createPresetsListHandler,
createApplyPresetHandler,
createMaintenancePageHandler,
isMaintenanceAdmin,
rateLimit,
rateLimitResponse,
getNowInTimezone, // deprecated — display formatting only
presets,
getPreset,
presetToPayloadData,
} from '@consilioweb/payload-maintenance'
import type {
MaintenancePluginConfig,
MaintenanceMessage,
MaintenanceStatus,
MaintenanceTemplate,
MaintenanceType,
ScheduleConfig,
SocialLink,
WebhookConfig,
MaintenancePreset,
AdminAccessCheck,
AdminAccessOptions,
} from '@consilioweb/payload-maintenance'
// Client — React components
import {
MaintenancePage,
MaintenanceToggle,
MaintenanceViewClient,
MaintenanceNavLink,
} from '@consilioweb/payload-maintenance/client'
// Views — admin server component
import { MaintenanceView } from '@consilioweb/payload-maintenance/views'
// Middleware — Next.js helper
import { createMaintenanceMiddleware } from '@consilioweb/payload-maintenance/middleware'
import type { MaintenanceMiddlewareConfig } from '@consilioweb/payload-maintenance/middleware'isMaintenanceAdmin(req, { adminCollectionSlug, adminAccess }) is exported so you can gate endpoints of your own with the same rule the plugin uses.
getNowInTimezone is deprecated since 0.6.0 and kept only because it is part of the published API surface. It reparses a wall-clock string as server-local time, so never compare its result against a Payload date field — schedule comparisons use plain UTC instants. Safe for display formatting only.
Upgrading
0.8.x -> 0.9.0
No schema change. No migration to generate. The diff against 0.8.0 touches access, hooks, validate, admin.description and defaultValue — none of which is a column.
What changes for you:
- Visitor IPs in
maintenance-analyticsare now truncated before they are written (IPv4 to /24, IPv6 to /48). Rows already stored keep their full address: the change applies to new writes only. SetanalyticsIpMode: 'full'to restore the previous behaviour, and read the "Personal data and retention" section below before you do. Rate limiting is unaffected — it always used, and still uses, the full address in memory. - Analytics are purged after 395 days by
DELETE /api/maintenance/analytics/purge, and automatically by a Payload Jobs task only if your config already has ajobskey. Nothing runs on its own otherwise. On a long-lived install this first run may delete a lot: check the count with the endpoint before wiring a schedule, or raiseanalyticsRetentionDays. authBypassstopped being a no-op. It now seeds the default value of the checkbox on the global. An install whose global has already been saved is unaffected; a fresh install withauthBypass: falsenow starts with the checkbox off.- A
maintenance-uninstallbinary ships with the package. See "Uninstall". - The newsletter email field gained an
aria-labeland got its focus indicator back on both renderers.
0.6.x -> 0.7.0 -> 0.8.0
No schema change. No migration to generate. Verified on the diff of src/collections and src/globals between v0.6.0 and 0.8.0: only access, hooks and admin.description moved.
Two changes need an action from you.
1. Webhooks must be https://. An existing http:// webhook stops firing. It is not rejected in the admin panel — the field validation still accepts http:// on purpose, so that saving the global does not become impossible for an install that already has one — but fireWebhook refuses it at send time and records the refusal in maintenance-webhook-logs with status: failed, statusCode: 0 and Refused before sending: ....
The consequence is worth stating plainly: you will see nothing in the admin, only deliveries that stop. Check it now:
# Any refused delivery, most recent first
GET /api/maintenance-webhook-logs?where[status][equals]=failed&sort=-timestampThen edit each http:// URL on the maintenance global to https://. While you are there, set allowedWebhookHosts: ['hooks.slack.com', 'discord.com'] — it is the only SSRF control here that does not depend on DNS timing.
2. Admin-only authorization. The global and the six admin endpoints now require a user of the Payload admin collection, not merely req.user. If your app has other auth-enabled collections and someone relied on that, point adminCollectionSlug at the right collection or plug adminAccess.
Personal data and retention
The plugin stores personal data in two of its four collections. This section is written to be copied into your record of processing activities; you are the controller, the plugin is the tool.
| Collection | Personal data | Written by | Possible lawful basis | Default retention |
|------------|---------------|-----------|----------------------|-------------------|
| maintenance-analytics | IP (truncated by default), user agent, referer, path | Every visitor of the maintenance page, automatically | Legitimate interest, only while the IP is truncated and the retention is bounded (CNIL audience-measurement exemption). With analyticsIpMode: 'full', consent is the realistic basis | 395 days (13 months) |
| maintenance-subscribers | Email, IP, user agent, consentAt, consentSource | The visitor, by submitting the newsletter form with consent: true | Consent. The IP and user agent are the proof of that consent, which is why they are kept | None by default — set subscribersRetentionDays |
| maintenance-history | The email of the admin who toggled maintenance | The plugin, on each toggle | Legitimate interest (audit trail) | None |
| maintenance-webhook-logs | Delivery URL, status, up to 2000 bytes of the response | The plugin, on each webhook | Legitimate interest (operations) | None |
Three things to know before you ship:
enableAnalytics is ON by default. Installing the plugin is enough to start collecting. Turn it off with enableAnalytics: false if you do not want it; there is no consent banner on the maintenance page, and adding one would not help — a visitor with no alternative screen cannot give a free consent anyway. Truncation is what makes the collection defensible, not a banner.
The IP is anonymised by default. 203.0.113.42 is stored as 203.0.113.0, 2001:db8:85a3:8d3::1 as 2001:db8:85a3::. analyticsIpMode: 'full' turns that off, and it is a real decision, not a tuning knob: a full IP is personal data that identifies a household, the visitor cannot opt out, and you take on the lawful basis, the information duty and the access/erasure requests that come with it. Choose it for abuse investigation, and shorten analyticsRetentionDays when you do.
Nothing purges itself unless you wire it. DELETE /api/maintenance/analytics/purge runs the sweep on demand. If your Payload config has a jobs key, the plugin also registers a maintenance-retention-purge task scheduled daily at 03:00 — but Payload only runs scheduled tasks when the host has autorun or a cron calling /api/payload-jobs/run configured. Check that yours does; otherwise the endpoint is your only mechanism.
// Retention tuned for a short maintenance window
maintenancePlugin({
analyticsIpMode: 'anonymized', // the default
analyticsRetentionDays: 90,
subscribersRetentionDays: 365, // once the site is back, the list is spent
})Scope. One Payload instance = one installation. The settings live in a single global; these plugins are not compatible with @payloadcms/plugin-multi-tenant, and on a multi-tenant install the maintenance toggle takes every tenant offline.
Database and updates
- This plugin adds collections and a global to your Payload config; it does not own your schema. Your app does.
- Payload does not let a plugin ship migrations:
payload migratereads exactly one directory, the host app's (payload.db.migrationDir, resolved against yourcwd). A migration file inside an npm package is never discovered. - In development,
pushsyncs the schema for you — nothing to do after installing. - In production, run
payload migrate:createthenpayload migrate. Neverpush: it is skipped as soon asNODE_ENV=production, and mixing it with migrations triggers Payload's data-loss warning. - Every release of this plugin states in its Upgrading section whether it changes the schema. To date, none has since 0.6.0.
Uninstall
npx maintenance-uninstall # delete the plugin's documents, remove the package
npx maintenance-uninstall --dry-run # count what would be deleted, change nothing
npx maintenance-uninstall --keep-data
npx maintenance-uninstall --slugs my-subscribers,my-analyticsRead this before removing the plugin by hand. Deleting the dependency and the maintenancePlugin() call leaves maintenance-analytics in your database — IP addresses, user agents and referers, with no retention — and at the same time removes the only interface able to read or erase them. That is the one thing this script exists to prevent.
What it does:
- Reports every source file referencing the package. It does not rewrite them:
maintenancePlugin()sits inside yourpluginsarray with your own options around it, and a regex edit there is how apayload.config.tsgets silently corrupted. Remove the call yourself. - Deletes the documents of the four collections through the Payload Local API (
payload run), so it works on SQLite, PostgreSQL and MongoDB and honours your custom slugs. A destructive step only runs when the plugin is actually detected here — a source reference or the dependency inpackage.json— or with--force-data. - Removes the package and regenerates the import map.
What it deliberately does not do: drop tables. Payload owns the schema, and a plugin dropping tables from under it is how a database drifts from the migration ledger. The script prints the statements for your adapter; run one, then payload migrate:create so your migrations match the new config.
-- SQLite / PostgreSQL, default slugs
DROP TABLE IF EXISTS maintenance_subscribers;
DROP TABLE IF EXISTS maintenance_history;
DROP TABLE IF EXISTS maintenance_analytics;
DROP TABLE IF EXISTS maintenance_webhook_logs;
DROP TABLE IF EXISTS maintenance; -- the global// MongoDB
db.maintenance_subscribers.drop(); db.maintenance_history.drop();
db.maintenance_analytics.drop(); db.maintenance_webhook_logs.drop(); db.maintenance.drop();The <slug>_id columns Payload adds to payload_locked_documents_rels for each collection stay behind. Payload ignores them, and SQLite cannot drop a column anyway.
Requirements
| Requirement | Version |
|-------------|---------|
| Node.js | >=18 |
| Payload CMS | ^3.79.1 (security floor — see Peer Dependencies) |
| Next.js | ^15.0.0 \|\| ^16.0.0 |
| React / React DOM | ^19.0.0 |
| Database | any Payload-supported adapter (SQLite, PostgreSQL, MongoDB) |
Support
- Issues and feature requests: github.com/pOwn3d/payload-maintenance/issues
- Changelog: CHANGELOG.md
- If this plugin saves you time: buy me a coffee
Made by ConsilioWEB.
