Building a gallery in WordPress is easy. Building one that is extensible, performant, multisite-aware, and does not fight the block editor — one an agency can ship to a hundred client sites and still maintain — is the part that separates a weekend hack from production software. This guide is the architecture-level view: the decisions you make up front, the freemium patterns, the block and REST surfaces, and the extensibility model, written for the developer reading the docs at 11pm. Where it references FotoGrids internals, those are real, verified against the plugin source, because a developer audience deserves accurate hook names, not marketing approximations.
It is the pillar of our developers and agencies cluster; the deep-dives — custom blocks, ACF patterns, white-labelling, multisite, hooks, and REST — branch from here.
The architecture decisions up front
Before any code, three decisions shape everything: how you store galleries, how you render them, and how (if at all) you split free from paid. Storage: custom post type versus custom tables — CPTs give you the WordPress ecosystem (REST, queries, capabilities) for free; custom tables give you control at the cost of reimplementing all of that (the trade-off NextGEN made, and why it is hard to migrate from). Rendering: block versus shortcode versus both — in 2026 the block editor is the default, but shortcodes remain useful for portability. Free/paid split: if you are building a freemium plugin, the architecture of that split is the most consequential early decision, covered next. Get these three right and the rest follows; get them wrong and you fight your own foundation later.
The reason these three are “up front” decisions rather than things you can defer is that each one is expensive to reverse. Storage choice determines your data-migration story forever: move from custom tables to a CPT later and you are writing a migration that has to run on every install without losing data. Rendering choice determines how content authors interact with the plugin, and switching from shortcode to block (or vice versa) means existing content has to be converted or supported indefinitely. And the free/paid split, as the next section shows, is nearly impossible to retrofit once feature code assumes a particular structure. A weekend hack ignores all three and works fine for one site; production software that an agency ships across a portfolio cannot, because the cost of a wrong early decision multiplies by the number of installs.
The three freemium architectures
For a freemium gallery plugin there are three ways to structure free versus paid, and they have very different consequences:
- Ship everything in free, gate by licence — all the code is in the free plugin, paid features are unlocked by a licence check. Simple to build, but the Pro code ships to everyone (inspectable, and a larger free footprint).
- Pro replaces free — installing Pro swaps out the free plugin. Clean separation, but upgrades and downgrades are disruptive (deactivate one, activate the other).
- Pro installs alongside free and extends it — the free plugin is the base; Pro is a separate plugin that hooks into the free one to add capabilities. No code duplication, clean upgrade path, and the free plugin works standalone.
The third is the most durable for a serious plugin, and it is the model FotoGrids uses, with a specific twist: the free core exposes filter hooks that default to “off,” and Pro hooks in to flip them. Concretely, the free plugin calls apply_filters('fotogrids/features/pro/can_use', false, $feature_name) and apply_filters('fotogrids/features/pro/is_active', false) — both default to false in free. The Pro plugin, when active and licensed, hooks those filters and returns true, unlocking the gated feature. This is filter-gated extension rather than file replacement: the free plugin never contains the Pro logic, Pro adds it by hooking in, and the two compose cleanly. It is worth studying as a pattern because it gives you the no-duplication benefit of “extend” with a clean, testable gate.
The mechanism is simpler than it sounds. In the free plugin, every place that needs to know whether a paid feature is allowed asks the same question through a filter that defaults to false:
// In the FREE plugin — the gate. Defaults to false everywhere.
function fg_can_use( $feature ) {
return apply_filters( 'fotogrids/features/pro/can_use', false, $feature );
}
// Usage at a feature boundary in free:
if ( fg_can_use( 'masonry_layout' ) ) {
// render the Pro layout
} else {
// render the free fallback (or an upsell)
}
// In the PRO plugin — flips the gate, but only when licensed.
add_filter( 'fotogrids/features/pro/can_use', function ( $allowed, $feature ) {
if ( ! fg_pro_license_is_active() ) {
return $allowed; // licence lapsed → behaves like free
}
$pro_features = array( 'masonry_layout', 'lightbox_pro', 'password_galleries' );
return in_array( $feature, $pro_features, true ) ? true : $allowed;
}, 10, 2 );
Three properties make this worth copying. The free plugin has no Pro logic in it at all — nothing to inspect, nothing to disable, a smaller footprint. The gate is one function, so testing “does this feature gate correctly?” is testing a single point rather than auditing scattered licence checks. And degradation is automatic: if the licence lapses, the Pro filter stops returning true and every feature falls back to its free behaviour without a single explicit “is this still paid?” check at the call sites. That last property is the one teams underestimate — graceful downgrade is a feature, and this architecture gives it to you for free.
Do this now. If you are architecting a freemium plugin, decide your free/paid split before writing feature code, because retrofitting it is painful. The filter-gated “extend” model — free core exposes can_use-style filters defaulting to false, Pro hooks in to flip them — is worth copying: it keeps Pro code out of the free plugin, makes the gate a single testable point, and lets free run standalone. WooCommerce’s hooks ecosystem and this pattern are the two references to study.
Custom post types for galleries
For most gallery plugins, custom post types beat custom tables. A CPT gives you the REST API (with show_in_rest => true), WP_Query, the capability system, revisions, and the block editor’s integration — all for free, all things you would otherwise reimplement. FotoGrids registers fotogrids_gallery and fotogrids_album as CPTs, and notably the Pro plugin enhances these existing post types rather than declaring its own — Pro adds layouts and capabilities to the same fotogrids_gallery objects via hooks, keeping a single data model across free and paid. That is the payoff of CPTs plus the extend architecture: one post type, extended in place, rather than parallel data structures. Reach for custom tables only when you have a genuine reason (extreme scale, a data shape WP_Query handles poorly), accepting that you give up the ecosystem.
The registration itself is where you decide how much of the ecosystem you inherit. The two arguments that matter most are show_in_rest (true gives you the REST API and block-editor support) and capability_type (controls who can edit galleries):
register_post_type( 'myplugin_gallery', array(
'labels' => array( 'name' => 'Galleries', 'singular_name' => 'Gallery' ),
'public' => false, // galleries render through your block, not as standalone URLs
'show_ui' => true, // but still manageable in wp-admin
'show_in_rest' => true, // REST API + block editor — the line that unlocks the ecosystem
'supports' => array( 'title', 'revisions', 'custom-fields' ),
'capability_type' => 'post',
) );
Note 'public' => false with 'show_ui' => true: a gallery is usually not a page you visit at its own URL — it is content embedded in other pages through a block — so you do not want WordPress generating a single-gallery permalink, but you do want it editable in the admin. Getting this pair right avoids a common confusion where every gallery accidentally becomes a thin, indexable URL competing with your real pages. And 'supports' => array( ..., 'custom-fields' ) is what lets you attach per-gallery settings as post meta, the foundation the ACF patterns piece builds on.
The block editor path
In 2026 the block editor is the default rendering surface, and a gallery is the canonical “real” block — it has attributes, renders dynamically, and needs frontend interactivity. The modern approach: block.json as the single source of truth (it auto-enqueues your JS and CSS and declares the block to WordPress), @wordpress/scripts for the build, and a dynamic block (attributes saved to the database, a PHP render callback producing the markup) so the gallery is server-rendered for SEO and crawlers. The 2026 detail worth knowing: the Interactivity API is now the standard way to add frontend behaviour (lightboxes, filters) without shipping React to the front end, and WordPress 6.9 extended it with nested router regions and dynamic overlays well suited to gallery modals. We cover block building in depth in custom block development for galleries.
Responsive images the WordPress way
Do not hand-roll responsive images; WordPress does it well if you use its functions. wp_get_attachment_image() generates the <img> with srcset and sizes automatically (via wp_calculate_image_srcset() and wp_calculate_image_sizes()), register additional sizes with add_image_size(), and lazy-loading has been automatic since 5.5 with sizes="auto" added where applicable. The developer’s job is to use these correctly and to get the LCP handling right (eager-load the first row, as covered in the gallery page speed guide), not to reinvent the responsive-image pipeline. A gallery built on the core image functions inherits correct srcset behaviour; one that hand-builds <img> tags usually gets it subtly wrong.
Performance budgets for a gallery
A gallery is the heaviest thing on most pages, so a developer should treat performance as a budget, not an afterthought. The budget items: keep the frontend JavaScript small (the Interactivity API helps by avoiding front-end React), serve correctly-sized modern-format images, set dimensions to prevent CLS, eager-load only the LCP image, and keep the lightbox lightweight to protect INP. These map directly to Core Web Vitals (see the CWV guide). For an agency shipping to many clients, baking the budget into the plugin’s defaults means every client site is fast without per-site tuning — which is the difference between a gallery plugin that scales across a portfolio and one that generates support tickets.
Extensibility: the hook surface
A plugin agencies can build a business on is one they can extend without forking. The model is a clean, prefixed hook system — actions for side effects, filters for transforming values. FotoGrids uses a slash-namespaced convention (fotogrids/...) that is distinctive and collision-resistant: actions like fotogrids/actions/item/added and fotogrids/actions/gallery/reordered fire on events, and filters like fotogrids/settings/defaults/gallery and fotogrids/render/breakpoint_config let you transform configuration. Pro itself extends the core through these same hooks — registering layouts via do_action('fotogrids/render/register_modules') and adding catalog JSON via add_filter('fotogrids/catalog/json_files', ...) — which is the proof the hook surface is real and load-bearing, not decorative. We go deep in hook-driven extensibility.
The REST surface
Galleries increasingly need to talk to things outside WordPress — headless frontends, mobile apps, build pipelines — which means a REST surface. Expose CPTs with show_in_rest => true for standard access, and register custom routes with register_rest_route() inside rest_api_init for bespoke needs, always with a real permission_callback (never __return_true on writes). FotoGrids scaffolds Pro REST routes under a FotoGrids_Pro\REST\* namespace registered in rest_api_init. Authentication in 2026 is Application Passwords (core since 5.6) for external clients and nonces for same-origin. The full treatment is in building a gallery API endpoint.
Multisite and licensing
For agencies, multisite is the deployment model and licensing is the friction point. The key realities: network activation puts the plugin on every subsite (site admins cannot deactivate), per-site activation is opt-in, and each subsite generally counts as a separate licence activation — the network is not one licence. FotoGrids ties its licence to the de-www’d domain plus a per-install UUID, with licensing handled through Freemius (its bootstrap and fs_dynamic_init are in the Pro source) plus a custom verification layer. The agency takeaways — white-labelling for client sites, multisite licensing patterns — get full articles (white-labelling, multisite licensing).
WordPress 6.9 / 7.0: what’s relevant
Two current-platform developments matter for gallery builders. The Interactivity API (now standard) is the way to add frontend interactivity — lightboxes, filtering — without front-end React, and WordPress 6.9 added nested router regions and dynamic overlays suited to gallery modals. The Abilities API (PHP in 6.9, JS via @wordpress/abilities in 7.0) is the genuinely fresh one: it exposes a plugin’s capabilities to AI and MCP agents in a structured way, which for a gallery plugin means an agent could, say, create or query galleries through a declared ability. It is early, but a developer building a gallery plugin in 2026 should know it exists, because AI-agent integration is becoming a real surface. Both are worth designing toward rather than retrofitting.
The developer’s checklist
- Store galleries as CPTs (ecosystem for free) unless you have a specific reason for custom tables.
- Choose your freemium split deliberately; filter-gated “extend” is the most durable pattern.
- Build dynamic blocks with
block.json+ a server-side render callback; use the Interactivity API for frontend behaviour. - Use core image functions for
srcset/sizes; get LCP handling right. - Treat performance as a budget baked into defaults.
- Expose a clean, prefixed hook surface and a properly-permissioned REST surface.
- Plan for multisite and per-site licensing realities up front.
Key takeaways
- The hard part isn’t displaying images — it’s extensibility, performance, multisite, and not fighting the block editor.
- Of the three freemium models, filter-gated “extend” (free core exposes filters, Pro flips them) is the most durable.
- CPTs give you the WordPress ecosystem free; the Interactivity API gives frontend behaviour without front-end React.
- The Abilities API (6.9/7.0) is the fresh surface — gallery capabilities exposed to AI/MCP agents.
Should a gallery plugin use custom post types or custom tables?
Custom post types for most cases — a CPT gives you the REST API, WP_Query, capabilities, revisions, and block-editor integration for free, all of which you would otherwise reimplement. Custom tables (the NextGEN approach) give control at the cost of rebuilding that ecosystem and being hard to migrate from. Use custom tables only for genuine extreme-scale or unusual-data-shape needs.
What is the best freemium architecture for a WordPress plugin?
Of the three common models — ship-everything-gated-by-licence, Pro-replaces-free, and Pro-extends-free — the extend model is the most durable: the free plugin is the base and Pro hooks in to add capabilities, with no code duplication and a clean upgrade path. A filter-gated variant (free core exposes filters defaulting to false, Pro flips them) makes the gate a single testable point.
How should a gallery block handle frontend interactivity in 2026?
Through the WordPress Interactivity API, which is now the standard way to add behaviour like lightboxes and filters without shipping React to the front end. WordPress 6.9 added nested router regions and dynamic overlays suited to gallery modals. Build the block as a dynamic block (server-rendered via a PHP render callback) so it stays SEO- and crawler-friendly.
How do I expose galleries to a headless frontend or app?
Through the REST API. Expose your gallery CPT with show_in_rest set to true for standard access, and register custom routes with register_rest_route inside rest_api_init for bespoke needs — always with a real permission_callback, never __return_true on writes. Authenticate external clients with Application Passwords (core since 5.6) and same-origin requests with nonces.