@onderwijsin/nuxt-newsletter-signup
| Install | |
|---|---|
npm install @onderwijsin/nuxt-newsletter-signup |
|
| Latest Version: | 1.2.2 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Oct 18, 2026 |
| Links: | GitHub · npm |
@onderwijsin/nuxt-newsletter-signup
Provider-independent newsletter signup for Nuxt 4. The module exposes one server endpoint and normalizes Loops and Mailchimp into the same request and error contract. Provider credentials and provider requests stay on the server; the browser only calls the generated Nuxt endpoint.
Installation
pnpm add @onderwijsin/nuxt-newsletter-signup @nuxt/ui
The application supplies @nuxt/ui as a peer dependency so module runtime code shares the
application's instances. Nuxt module registration remains automatic.
Register the module in nuxt.config.ts:
export default defineNuxtConfig({
modules: ["@onderwijsin/nuxt-newsletter-signup"],
newsletterSignup: {
provider: "loops",
apiKey: "your-loops-api-key",
lists: {
default: "your-loops-mailing-list-id"
},
fields: {
firstName: { required: true },
lastName: { required: false },
organization: { required: false }
}
}
});
The consumer must provide apiKey. It is used by the server runtime and is never exposed to the
client. The module does not choose, load, or manage credentials for the consumer.
When using Mailchimp, provide the server value associated with each configured audience. This must
be the Mailchimp server prefix in the format us followed by one or more digits, such as us4 or
us21; hostnames, URLs, paths, whitespace, and other formats are rejected during module setup. A
top-level server is available as a fallback for a single audience or configurations where all
audiences share one server. For example:
export default defineNuxtConfig({
modules: ["@onderwijsin/nuxt-newsletter-signup"],
newsletterSignup: {
provider: "mailchimp",
apiKey: "your-mailchimp-api-key",
lists: {
default: "audience-a",
options: [
{ label: "Nieuwsbrief A", id: "audience-a", server: "us4" },
{ label: "Nieuwsbrief B", id: "audience-b", server: "us5" }
]
},
fields: {
firstName: { required: true, target: "FNAME" },
lastName: { target: "LNAME" },
organization: { target: "ORG" }
}
}
});
For Mailchimp, server belongs to the audience configuration. The selected list option’s server is
used for that request; us4 and us5 are only examples of valid server prefixes.
Cloudflare Workers
The Mailchimp adapter uses Node’s node:crypto API to calculate the subscriber hash. Cloudflare
Workers deployments must enable the nodejs_compat compatibility flag in the
Wrangler configuration. Nuxt
applications running on Workers already require this flag. Cloudflare also enables
nodejs_compat_v2 when the compatibility date is 2024-09-23 or later; see the
Cloudflare Node.js compatibility documentation.
Local or remote endpoint
By default, the module registers its local server handler at POST /api/newsletter/signup and the
composable calls that path. This is the normal setup:
newsletterSignup: {
provider: "loops",
apiKey: "your-loops-api-key",
endpoint: {
enabled: true
},
lists: { default: "your-list-id" }
}
The local route is always /api/newsletter/signup; endpoint.url is ignored while local
registration is enabled. Consumers that already have the handler in another Nuxt application can
disable local handler registration and point the composable at that application instead:
newsletterSignup: {
endpoint: {
enabled: false,
url: "https://newsletter-api.example.com/api/newsletter/signup"
}
}
With enabled: false, this module still registers useNewsletterSignup() but does not register
POST /api/newsletter/signup. The url may be a relative path or an HTTP(S) URL. A remote endpoint
must implement the same request and error contract. Provider options are not needed in the consuming
application when it only uses the remote endpoint.
Lists
For a form that always subscribes to one list, configure lists.default and omit listId from the
request:
lists: {
default: "newsletter-main"
}
When only lists.default is configured, the endpoint always uses that server-owned list and rejects
any client-supplied listId.
For a form that lets the visitor choose a list, configure lists.options. Each submitted listId
must match one of the configured option IDs:
lists: {
options: [
{ label: "Nieuwsbrief A", id: "audience-a" },
{ label: "Nieuwsbrief B", id: "audience-b" }
];
}
For Mailchimp, add the server for each audience option:
lists: {
options: [
{ label: "Nieuwsbrief A", id: "audience-a", server: "us4" },
{ label: "Nieuwsbrief B", id: "audience-b", server: "us5" }
];
}
You may configure both default and options. In that case, omitting listId uses default,
while a supplied listId must still be present in options.
Configured list labels and IDs are also available through
useRuntimeConfig().public.newsletterSignup for client-side list selectors. Provider credentials
and Mailchimp server values remain server-only.
Supported fields and mapping
The generated endpoint accepts these standard fields plus arbitrary contact properties when they are
configured under fields with a target. Custom values may be strings, numbers, booleans, string
arrays, null, or undefined:
interface NewsletterSignupPayload {
email: string;
firstName?: string;
lastName?: string;
organization?: string;
userId?: string;
userGroup?: string;
source?: string;
listId?: string;
[property: string]: https://github.com/onderwijsin/nuxt-modules/blob/HEAD/string | number | boolean | string[] | null | undefined;
}
The provider defaults are:
| Input | Loops | Mailchimp |
|---|---|---|
email |
email |
email_address |
firstName |
firstName |
FNAME |
lastName |
lastName |
LNAME |
organization |
configurable | ORG |
userId |
userId |
configurable merge field |
userGroup |
userGroup |
configurable merge field |
source |
contact source |
member tag |
source defaults to "api" when omitted. Loops receives it as the contact source. Mailchimp
receives it as tags: [source], not as a merge field.
Use fields.*.target to map provider property names. Loops requires an explicit target for
organization and any custom property. Mailchimp uses custom targets as merge-field names; its
standard firstName, lastName, and organization mappings remain available by default:
fields: {
firstName: { required: true, target: "FIRST_NAME" },
lastName: { target: "LAST_NAME" },
organization: { target: "COMPANY" },
userId: { required: true },
userGroup: { target: "USER_GROUP" }
}
Custom properties use the same format:
fields: {
favoriteColor: {
target: "FAVORITE_COLOR";
}
}
The signup payload may then include favoriteColor: "blue". An arbitrary property without a
configured target is rejected. email remains email_address for Mailchimp, and source remains a
member tag there; their Mailchimp target values are ignored. listId selects the audience and is
never sent as a contact property. The reserved properties listId and subscribed cannot be used
as contact properties.
required controls server-side request validation. Consumer-side form validation is still
recommended for immediate feedback.
Endpoint
The module generates POST /api/newsletter/signup.
The local endpoint uses @onderwijsin/nuxt-simple-rate-limiter: each IP may make five requests per
minute and is then banned for 15 minutes. Rate-limit failures are normalized as rateLimited errors
and include bannedUntil. It does not configure or override other application rate limits. The
limiter is not registered when endpoint.enabled is false. Configure Nitro storage for
production; use a shared driver such as Redis when the application runs on multiple instances.
Duplicate subscriptions are idempotent and always return success, so the endpoint does not reveal
mailing-list membership. Provider requests have a five-second timeout. Mailchimp uses immediate
subscribed status.
Without persistent Nitro storage, the rate limiter works in memory but its counters and bans reset when the application restarts. To persist them for a single-instance deployment, configure storage:
export default defineNuxtConfig({
nitro: {
storage: {
"simple-rate-limiter:%2Fapi%2Fnewsletter%2Fsignup": {
driver: "fs",
base: "./newsletter-signup"
}
}
}
});
The storage key must match the limiter namespace for the endpoint path. If global rate limiting is
enabled separately, configure its simple-rate-limiter:global namespace as well. Use a shared
storage driver such as Redis for multiple application instances.
Single-list request:
await $fetch("/api/newsletter/signup", {
method: "POST",
body: {
email: "person@example.com",
firstName: "Ada",
source: "homepage"
}
});
Multi-list request:
await $fetch("/api/newsletter/signup", {
method: "POST",
body: {
email: "person@example.com",
firstName: "Ada",
listId: "audience-b"
}
});
Consumers normally use useNewsletterSignup() instead of calling the endpoint directly.
Client composable
useNewsletterSignup() is auto-imported and provides the signup ofetch wrapper together with
standardized client-side error handling:
<script setup lang="ts">
const { signup, handleSignupError } = useNewsletterSignup();
const form = {
email: "person@example.com",
firstName: "Ada",
source: "homepage"
};
async function submit() {
try {
await signup(form);
// Custom success behavior, for example showing a confirmation state.
} catch (error) {
handleSignupError(error);
}
}
</script>
handleSignupError uses Nuxt UI toasts with Dutch messages:
- Invalid input:
Ongeldige invoer - Provider or server failure:
Er ging iets mis, probeer het nog een keer
For custom handling, use the helpers returned by the composable:
const { signup, getErrorCode, handleSignupError, ERROR_CODES } = useNewsletterSignup();
try {
await signup(payload);
showSuccessMessage();
} catch (error) {
if (getErrorCode(error) === ERROR_CODES.invalidInput) {
showValidationMessage();
} else {
handleSignupError(error);
}
}
Error contract
The endpoint normalizes provider failures into these client-facing HTTP statuses:
| Status | Meaning |
|---|---|
400 |
Invalid request or invalid configured list selection |
429 |
Rate limited by the local endpoint's abuse protection; data.bannedUntil is present for an active ban. |
500 |
Module configuration is incomplete |
5xx |
Provider or server failure |
The API key is never returned in endpoint responses or exposed through public runtime config.
The @onderwijsin/nuxt-newsletter-signup/runtime export is browser-safe. Server error helpers are
available from @onderwijsin/nuxt-newsletter-signup/runtime/server.
Compatibility
Developed and tested against Node.js 24 and Nuxt 4.5.x. The package declares Node.js >=24 and may work with other Nuxt versions permitted by its package metadata, but versions outside the current CI matrix are not continuously tested. Nuxt 3 is not guaranteed.
Related Packages
Server-safe Directus REST integration for Nuxt applications.
Nuxt Turnstile integration with client helpers and server-side validation.
Nuxt module for runtime theme selection and custom color editing in Nuxt UI.
Shared runtime and Nuxt module utilities for Onderwijs in modules.
Nuxt module that exposes configurable system health endpoints.
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 1.2.2 | ^3.0 | MIT | |
| 1.2.1 | ^3.0 | MIT | |
| 1.2.0 | ^3.0 | MIT | |
| 1.1.0 | ^3.0 | MIT | |
| 1.0.8 | ^3.0 | MIT | |
| 1.0.7 | ^3.0 | MIT | |
| 1.0.6 | ^3.0 | MIT | |
| 1.0.5 | ^3.0 | MIT | |
| 1.0.4 | ^3.0 | MIT | |
| 1.0.3 | ^3.0 | MIT | |
| 1.0.2 | ^3.0 | MIT | |
| 1.0.1 | ^3.0 | MIT | |
| 1.0.0 | ^3.0 | MIT | |
| 0.2.0 | ^3.0 | MIT |