Excluding server-only code from admin UI

Because the Admin Panel browser bundle includes your Payload Config file, files using server-only modules need to be excluded. It's common for your config to rely on server only modules to perform logic in access control functions, hooks, and other contexts.

Any file that imports a server-only module such as fs, stripe, authorizenet, nodemailer, etc. cannot be included in the browser bundle.

Example Scenario

Say we have a collection called Subscriptions that has a beforeChange hook that creates a Stripe subscription whenever a Subscription document is created in Payload.

Collection config:

1
// collections/Subscriptions/index.ts
2
3
import { CollectionConfig } from 'payload/types'
4
import createStripeSubscription from './hooks/createStripeSubscription'
5
6
export const Subscription: CollectionConfig = {
7
slug: 'subscriptions',
8
hooks: {
9
beforeChange: [createStripeSubscription],
10
},
11
fields: [
12
{
13
name: 'stripeSubscriptionID',
14
type: 'text',
15
required: true,
16
},
17
],
18
}

Collection hook:

1
// collections/Subscriptions/hooks/createStripeSubscription.ts
2
4
import Stripe from 'stripe' // <-- server-only module
6
7
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
8
9
export const createStripeSubscription = async ({ data, operation }) => {
10
if (operation === 'create') {
11
const dataWithStripeID = { ...data }
12
13
// use Stripe to create a Stripe subscription
14
const subscription = await stripe.subscriptions.create({
15
// Configure the subscription accordingly
16
})
17
18
// Automatically add the Stripe subscription ID
19
// to the data that will be saved to this Subscription doc
20
dataWithStripeID.stripeSubscriptionID = subscription.id
21
22
return dataWithStripeID
23
}
24
25
return data
26
}

As-is, this collection will prevent your Admin panel from bundling or loading correctly, because Stripe relies on some Node-only packages.

How to fix this

You need to make sure that you use aliases to tell your bundler to import "safe" files vs. attempting to import any server-side code that you need to get rid of. Depending on your bundler (Webpack, Vite, etc.) the steps involved may be slightly different.

The basic idea is to create a file that exports an empty object, and then alias import paths of any files that import server-only modules to that empty object file.

This way when your bundler goes to import a file that contains server-only modules, it will instead import the empty object file, which will not break the browser bundle.

Aliasing server-only modules

To remove files that contain server-only modules from your bundle, you can use an alias.

In the Subscriptions config file above, we are importing the hook like so:

1
// collections/Subscriptions/index.ts
2
3
import createStripeSubscription from './hooks/createStripeSubscription'

By default the browser bundle will now include all the code from that file and any files down the tree. We know that the file imports stripe.

To fix this, we need to alias the createStripeSubscription file to a different file that can safely be included in the browser bundle.

First, we will create a mock file to replace the server-only file when bundling:

1
// mocks/modules.js
2
3
export default {}
4
5
/**
6
* NOTE: if you are destructuring an import
7
* the mock file will need to export matching
8
* variables as the destructured object.
9
*
10
* export const namedExport = {}
11
*/

Aliasing with Webpack can be done by:

1
// payload.config.ts
2
3
import { buildConfig } from 'payload/config'
4
import { webpackBundler } from '@payloadcms/bundler-webpack'
5
6
import { Subscriptions } from './collections/Subscriptions'
7
8
const mockModulePath = path.resolve(__dirname, 'mocks/emptyObject.js')
9
const fullFilePath = path.resolve(
10
__dirname,
11
'collections/Subscriptions/hooks/createStripeSubscription'
12
)
13
14
export default buildConfig({
15
collections: [Subscriptions],
16
admin: {
17
bundler: webpackBundler(),
18
webpack: (config) => {
19
return {
20
...config,
21
resolve: {
22
...config.resolve,
24
alias: {
25
...config.resolve.alias,
26
[fullFilePath]: mockModulePath,
27
},
29
},
30
}
31
},
32
},
33
})

Aliasing with Vite can be done by:

1
// payload.config.ts
2
3
import { buildConfig } from 'payload/config'
4
import { viteBundler } from '@payloadcms/bundler-vite'
5
6
import { Subscriptions } from './collections/Subscriptions'
7
8
const mockModulePath = path.resolve(__dirname, 'mocks/emptyObject.js')
9
10
export default buildConfig({
11
collections: [Subscriptions],
12
admin: {
13
bundler: viteBundler(),
14
vite: (incomingViteConfig) => {
15
const existingAliases = incomingViteConfig?.resolve?.alias || {};
16
let aliasArray: { find: string | RegExp; replacement: string; }[] = [];
17
18
// Pass the existing Vite aliases
19
if (Array.isArray(existingAliases)) {
20
aliasArray = existingAliases;
21
} else {
22
aliasArray = Object.values(existingAliases);
23
}
24
25
27
// Add your own aliases using the find and replacement keys
28
// remember, vite aliases are exact-match only
29
aliasArray.push({
30
find: '../server-only-module',
31
replacement: path.resolve(__dirname, './path/to/browser-safe-module.js')
32
});
34
35
return {
36
...incomingViteConfig,
37
resolve: {
38
...(incomingViteConfig?.resolve || {}),
39
alias: aliasArray,
40
}
41
};
42
},
43
},
44
})
Next

Webpack