GenerateBlocks Pro and GP Premium: eight traps the docs never mention
I built a WordPress site on GeneratePress Premium and GenerateBlocks Pro by reading the plugin source instead of the review blogs. Eight things cost me time. Not one of them is a bug: they are all undocumented design, and that is exactly what makes them expensive, because the code does what it was built to do and nobody wrote down what that was. Each one below: the symptom, what is actually happening, and the way around it.
The paid parts are the least documented parts
GeneratePress and GenerateBlocks have a good free tier and a thin paid manual. The Forms module in GenerateBlocks Pro landed on 23 June 2026, so at the time of writing it is a few weeks old and most of what you find about it online is the vendor's own announcement, repeated. Everything below came out of the plugin source and out of running the thing, not out of a forum thread.
Two of these are pure architecture, not code: the one about where a design belongs, and the one about how you collect an address. Those are the two that cost the most, because no test catches them.
A word on tone, because a list like this reads easily as a complaint. It is not one. Nothing here is a bug report: the plugins do what they were built to do, the design is simply written down nowhere, and a few parts of it behave the opposite of what you would guess. One item did look like a genuine defect, so I tested it on its own instead of assuming. It turned out to be guarded, and the result changed what I could honestly claim. That test is in trap 04.
The licence key unlocks nothing
You are waiting on the client for a licence key before you can start, because the paid features obviously need one.
GP Premium and GenerateBlocks Pro are separate ZIP files from the account area on generatepress.com. They are not a key that unlocks the free plugin. I installed and activated both with no key at all: the Pro blocks register (form, site header, navigation, menu toggle, classic menu), the Global Styles post type registers, the site runs. The key drives automatic updates and nothing else.
Ask the client for the FILES, never for the key. The key is a secret of theirs and it does not need to touch your disk. Ask for the ZIPs, start the same day, and let them keep the credential.
While you are there: the GenerateBlocks terms of use set no numeric cap on the number of sites. They forbid reselling the key to clients, that is all. The "500 sites" figure that circulates comes from review blogs, not from the vendor, so do not repeat it to a client. Tell them to check their own tier, and warn them that a cloned site carries the key with it and will try to activate again.
Modules ship switched off
The plugin is installed, it is active, and the block or the feature you paid for is simply not in the editor.
The modules are opt-in and every one of them is off on a fresh install. There is no error and no notice, the feature is just absent. Three of them matter:
enable_forms: the GB Pro forms module, a key inside the generateblocks option array.generateblocks_pro_classic_menu_support: classic WordPress menus inside the navigation blocks.generate_package_menu_plus: one of the GP Premium modules; each has its own option, and the check compares against the literal string "activated".
Anything that provisions a site has to assert these and then verify them, rather than assuming they are on. The option array is merged with the defaults through wp_parse_args, so writing a partial array back is safe.
// GB Pro forms. The option array is merged with defaults via wp_parse_args,
// so writing a partial array back does not wipe the rest.
$gb_options = get_option( 'generateblocks', array() );
$gb_options['enable_forms'] = true;
update_option( 'generateblocks', $gb_options );
// Classic WP menus inside the GB Pro navigation blocks.
update_option( 'generateblocks_pro_classic_menu_support', true );
// GP Premium modules each carry their own option, and
// generatepress_is_module_active() compares against exactly this string.
update_option( 'generate_package_menu_plus', 'activated' );
Sticky menu lives in the other plugin
You turn on the sticky navigation in GP Premium's Menu Plus, you reload the page, and nothing sticks. No error, no warning, nothing happens at all.
Menu Plus applies to GeneratePress's OWN theme header and nav. If the header of your page is built out of blocks in the page content, which is the normal shape for a landing page, then Menu Plus has nothing to pin. It is not broken, it is aimed somewhere else.
Use the GB Pro Site Header block, which has sticky built in. Its script reads data attributes straight off the block, and the breakpoint attribute takes a full media query, so "sticky on mobile only" is a feature out of the box rather than hand-rolled CSS. The script is only enqueued when the block actually renders as sticky.
data-gb-is-sticky="true"
data-gb-sticky-breakpoint="(max-width: 767px)" // takes a media query
data-gb-sticky-header-type="scroll-up" // always | scroll-up | past-threshold
data-gb-sticky-animation="slide" // slide | fade | none
data-gb-sticky-threshold="200"
data-gb-sticky-speed="400"
Two details that save an hour. While it is pinned, GenerateBlocks adds the class gb-is-sticky, which is your hook for a shadow or a shrink. And the Site Header block refuses a custom class name entirely, because the block declares that it does not support one, so any CSS you aim at your own class on that block matches nothing at all.
The form is not markup
You go looking for the form in the page content and it is not there. All you find is a block with a number in it.
A GB Pro form is a POST, of a custom post type, whose configuration lives in post meta. The page embeds it through a render block that carries a numeric form ID. The submit button is not even a form block: it is a plain text block with the button tag and a submit type attribute, which is exactly what the front-end script goes looking for.
gblocks_form: the post type. Config in post meta _gb_form.generateblocks-pro/form-render: the block in the page, carrying a numeric formId.
This has a consequence people discover late. A clone that copies the database carries the form and its ID along with it, and everything works. A site rebuilt from theme and patterns alone gets a dangling form ID and an embed that renders nothing. Whatever provisions your sites has to assert the form exists, exactly the way it asserts the plugin list.
Do not store the recipient address inside the form. A form with an address baked in is inherited by every site cloned from it, so changing it later means editing all of them. Leave the field empty, which makes GenerateBlocks fall back to the site's admin e-mail, and bridge the real recipient in the child theme:
add_filter( 'generateblocks_form_email_args', function ( array $email_args ): array {
$site_email = get_theme_mod( 'site_contact_email', '' );
if ( '' !== $site_email && is_email( $site_email ) ) {
$email_args['to'] = array( $site_email );
}
// Untouched -> GB's admin_email fallback still delivers.
return $email_args;
} );
Point the reply-to setting at the form's own e-mail field as well, so hitting reply answers the lead instead of the site.
The plan "ship the form wired to a webhook, the client pastes the URL in later" cannot work. The webhook action validates its URL and returns an error on an empty one, and the form processor validates every action BEFORE it runs any of them. A webhook with no URL therefore takes down the whole submission before the e-mail action even starts, and the lead reaches nobody.
I assumed that was a hole in the plugin, because we had only ever set the webhook from a script. It is not, and I only know that because I went and checked. In the panel the toggle does flip on, and the URL field appears empty and is not even marked as required, so up to that point it looks like a hole. Then the save is rejected outright: "Updating failed. Webhook URL is not configured." The stored config is untouched. You cannot reach the broken state from the panel at all.
The guard sits on the REST save, which is the path a human takes. A provisioning or cloning script writes post meta directly and walks straight past it. So the only way to break this form is to break it yourself, from code, which is exactly what your provisioning script does.
Ship the webhook off and document the one toggle: form editor, right sidebar, Form tab, Form Settings, at the bottom, next to storing submissions and the spam options. If you provision forms in code, either save them through the same REST endpoint the editor uses, so the guard applies to you too, or repeat its validation yourself before you write the meta.
And notice who does not see the damage. The visitor gets a message saying the form is not fully configured. The site owner gets nothing at all: no e-mail, and no stored submission either, because storing submissions is off by default as well. The loss is only silent on the side that pays for it.
One last note, on proving the recipient bridge actually works. Set the address in the panel to something DIFFERENT from the site's admin e-mail before you test, then submit the form for real and check where the mail landed. If both addresses are the same, a delivered e-mail proves nothing: it would have arrived anyway.
A GB block is not markup either
You hand-write GenerateBlocks markup into a pattern file. On the front end the wrapper never appears, only the bare content inside it. Or the editor opens the page and says the block contains unexpected or invalid content.
You change the style attributes of a GB Pro block from a script. The serialized markup looks exactly right. The page looks exactly like it did before.
Both symptoms have the same root cause: a GenerateBlocks block is dynamic, and the thing that actually renders is computed by the editor, not written by you.
The containers and grids are server-rendered. The PHP builds the section wrapper only when the block carries a unique ID, and that ID is assigned by the editor's JavaScript when the block mounts. It is not in raw markup, so hand-written markup renders no wrapper. Even createBlock followed immediately by serialize gives you an EMPTY unique ID, because the effect that fills it has not run yet.
The newer GB Pro blocks work the same way one level up. They hold a styles object AND a compiled css attribute, and the front end reads the css. GenerateBlocks compiles one into the other in the editor, on mount. Update only the styles and the block keeps its stale css: the markup is right, the page is unchanged, nothing errors. That one cost me a wrong render before I caught it, and I only caught it by reading getComputedStyle on the front end instead of trusting the markup.
Author the markup inside a loaded editor, give the block time to mount, and only then capture it. The captured markup carries a real unique ID and compiled css, renders on the front end, opens clean in the editor, and still works when a pattern file drops it into a page.
const section = wp.blocks.createBlock( 'generateblocks/container', attributes, children );
wp.data.dispatch( 'core/block-editor' ).resetBlocks( [ section ] );
// GB assigns uniqueId and compiles styles -> css on MOUNT. Serializing sooner
// captures an empty id and stale css, and both fail silently.
await new Promise( ( resolve ) => setTimeout( resolve, 3500 ) );
const patternMarkup = wp.blocks.serialize(
wp.data.select( 'core/block-editor' ).getBlocks()[ 0 ]
);
// Never mutate a Pro block's styles in place: re-insert it, so the mount path
// recompiles css. Carry innerBlocks across or the content is lost.
wp.data.dispatch( 'core/block-editor' ).replaceBlock(
stale.clientId,
wp.blocks.createBlock( stale.name, finalStyles, stale.innerBlocks )
);
Then verify BOTH ends, every time, because each one can pass while the other fails: curl the front end and look for the container class, and open the page in the editor and count the block warnings, which must be zero.
Global Styles is deprecated, and the panel is a trap
You follow a tutorial to GenerateBlocks Global Styles and the panel says "Global Styles (Legacy)" and will not let you create one.
The old post type is deprecated and will not let you create a new one. The living system is global classes: one post per class, holding the selector, the style data, and the compiled CSS. The CSS is compiled IN THE BROWSER and the server never recompiles it, which means anything that writes those records straight into the database has to generate the CSS itself, or the class exists and styles nothing. You create these in the block editor, in the block sidebar, not in the dashboard, which only lists and orders them.
A related snag if you automate the editor: saving the post does not save everything. Global classes are separate entities, and WordPress puts them behind a second confirmation dialog with a checkbox per entity. The value only reaches the database after that second Save is clicked, so a script that calls save and walks away has saved nothing. Verify in the database, not in the editor's state.
If you are building ONE master template to be cloned into many near-identical sites (a chain of location pages, a franchise, a network of company sites), the design belongs in the CODE, not in the panel. Builder "global styles" systems store the design in EACH site's database, and that inverts at scale:
- A global change becomes a migration. "Make the cards less rounded everywhere" is one file and a push script when the look lives in code. When it lives in fifty databases, it is a migration across fifty databases, and it overwrites whatever the client tweaked by hand on individual sites.
- Drift. The whole point of a master is that the sites are identical and differ only in what should differ: name, logo, colour, phone, address. A per-site editable design means that a year later every site is slightly different and none of them is the master any more.
A panel-editable design is the right call when each site is meant to look bespoke. A cloneable master is built precisely so that they do not. Notice the difference before you promise the panel, because this is a design decision, not a technical one, and no amount of testing will catch it. I promised the panel once, in writing, on a build where it was wrong. I reversed it in writing too.
There is a second half to this, and it is the gap nobody writes down. GenerateBlocks Dynamic Data reads post data, post meta, terms, author meta and images. There is NO Customizer source. So anything the client sets in a Customizer panel, the business name, the phone number, the address, the brand colour, cannot reach a content template, its copy, or its JSON-LD through Dynamic Data alone. The child theme has to bridge it, with a shortcode or a custom content source, plus PHP for the schema output. Budget that bridge as real work, not as markup. It is also exactly what stops a clone script from having to find-and-replace a phone number inside every page of every cloned site.
The theme prints a second footer
Your block-built landing page comes out narrow, with a page title you never added, and with a SECOND footer underneath your own. That second footer carries a "Built with GeneratePress" credit, and it ships to the client's live site.
None of that is your markup. It is the theme's chrome, printed around your content, and it keeps printing until you take each piece off. Three of the four have filters or actions. The fourth does not, and that is the one that bites.
Full width is not a filter. It is a per-page checkbox stored in post meta, and GeneratePress reads it straight from meta with no filter on the value, so a page created by a script comes out narrow. Default it through the meta read itself, guarding against re-entry, and only default the UNSET case so an explicit per-page choice still wins.
add_filter( 'generate_show_title', '__return_false' );
add_filter( 'generate_sidebar_layout', fn() => 'no-sidebar' );
remove_action( 'generate_header', 'generate_construct_header' );
// Forget this line and the client's site ships a second footer
// carrying a "Built with GeneratePress" credit.
remove_action( 'generate_footer', 'generate_construct_footer' );
// Full width is post meta with no filter on the value. Default the unset case only.
add_filter( 'get_post_metadata', function ( $short_circuit, $post_id, $meta_key, $is_single ) {
static $reading = false;
if ( $reading || '_generate-full-width-content' !== $meta_key ) {
return $short_circuit;
}
$reading = true;
$stored_flag = get_post_meta( $post_id, $meta_key, true );
$reading = false;
// An array serves both the single and the multiple read.
return '' === $stored_flag ? array( 'true' ) : $short_circuit;
}, 10, 4 );
The sidebar filter is worth keeping even if the site has no sidebar today: the layout comes from a theme default, which is one Customizer click away from breaking every landing page on the site.
Schema that validates and does nothing
The LocalBusiness markup is on the page. The validator is green. Months pass and there are no local rich results.
In Google's own structured-data documentation, the address is a REQUIRED property and it has to be a PostalAddress object with the parts separated out. A plain string address is legal schema.org, it passes the validators, and it does not qualify the page for local rich results. The markup is present and does nothing, which is the worst failure mode there is, because it looks finished.
"address": {
"@type": "PostalAddress",
"streetAddress": "Kwiatowa 12",
"addressLocality": "Warszawa",
"postalCode": "00-001",
"addressCountry": "PL"
}
Collect the address IN PARTS, as separate fields: street, city, postcode, country code. Do not take one free-text field and split it by guesswork. A guessed split survives right up until the first address typed in a format you did not expect, and then it fails silently: the page looks identical while the structured data is invalid. Build the visible footer line FROM the same parts, so the display and the schema cannot drift apart.
Two small ones from the same corner. The country wants a two-letter code, so validate it against a pattern rather than publishing a typo into every page. And keep the country OUT of the visible footer line: a local customer does not need to be told which country they are standing in, and Google reads the JSON-LD, not the footer.
Almost none of these throw an error
Look at what these have in common. A unique ID that is not in the markup. A css attribute that stayed stale. A module that is simply off. A form config that a script can write into a state the panel forbids. Schema that passes the validator and wins nothing. In almost every one of them the broken state looks exactly like the working state, which is why they reach production: there is nothing to see, and nothing to grep for.
None of it is a defect, and that is the useful part. Every one of these is a design decision that nobody wrote down, which means no bug report would fix any of it and no upgrade will make it go away. It is knowledge, not a ticket.
The method that catches them is boring. Read the plugin source instead of a blog post, because the blog post is usually the vendor's announcement with the corners rounded off. Check the result through a path that is independent of the one that produced it: the front end, not the editor; the database, not the panel; getComputedStyle, not the markup you just wrote. A test that travels the same path as the code only proves the code agrees with itself.
That cuts both ways, and the webhook is the reason I say so. I had it written down as a plugin hole for weeks, on solid evidence: I had read the source, and the source was right. What I had never done was try it the way a human does. Five minutes in the panel turned a bug report into a much more useful sentence, which is that the guard exists and my script was walking around it.
And the two architectural ones stay true no matter which builder you pick. If a design is meant to be identical across many sites, it does not belong in a panel. If an address is meant to be machine-readable, it does not belong in one free-text field.
Building on GeneratePress or GenerateBlocks?
Tell me what the site has to do. I will tell you where this stack will push back, before you pay for the first hour.
Ask about your build