Shortcodes: still useful — practical tips and trade-offs
If you've built WordPress plugins or themes in the last decade, you've probably asked yourself whether shortcodes are worth the effort. The honest answer: they still have a place, but only if you understand when and how to use them responsibly. We'll walk through practical scenarios where shortcodes make sense, how to keep them maintainable, and why the block editor hasn't made them obsolete—just more specialized.
The state of shortcodes in modern WordPress
Shortcodes have been part of WordPress since version 2.5. They're simple: a user writes [my_plugin param="value"] in the editor, and your PHP function processes it into HTML. But the block editor (Gutenberg) changed the landscape. Blocks are more powerful, offer better UX for content creators, and integrate seamlessly with the editor's toolbar.
That doesn't mean shortcodes are dead. They're useful when:
- You need quick embeds in classic editor environments or legacy sites
- Your team or clients still prefer writing content in plain text with markers
- You're maintaining backward compatibility with existing content
- The feature is genuinely simple and doesn't need visual configuration
- You're supporting migration scenarios where content predates blocks
The key is knowing the trade-offs and implementing them properly.
add_shortcode for simple embeds
Let's start with the simplest, most legitimate use case: a straightforward embed or content injection that doesn't need complex UI.
Imagine you're building a plugin that displays a team member profile card. A content editor should be able to drop a shortcode into a post, and it renders the profile. Here's a clean implementation:
function register_team_shortcode() {
add_shortcode( 'team_member', 'render_team_member' );
}
add_action( 'init', 'register_team_shortcode' );
function render_team_member( $atts, $content, $tag ) {
$atts = shortcode_atts(
array(
'id' => 0,
'style' => 'card', // card, mini, full
),
$atts,
$tag
);
$member_id = absint( $atts['id'] );
if ( ! $member_id ) {
return '';
}
// Fetch member data (custom post type or custom table)
$member = get_post( $member_id );
if ( ! $member || 'team_member' !== $member->post_type ) {
return '';
}
// Sanitize style option
$style = sanitize_key( $atts['style'] );
if ( ! in_array( $style, array( 'card', 'mini', 'full' ), true ) ) {
$style = 'card';
}
ob_start();
?>
<div>
<div>
</div>
<div>
<h3>post_title ); ?></h3>
<p>
</p>
</div>
</div>
This works. A non-technical editor can write [team_member id="42" style="mini"] in the classic editor and get a rendered profile. No UI builder needed, no JavaScript overhead.
The advantage here is simplicity and speed. The disadvantage is that the editor has no visual feedback while writing—they're guessing at the result until they preview.
Blocks are better for editors
Where shortcodes fall short is user experience. Imagine a content creator needs to embed a testimonial with an image, name, role, and star rating. In the block editor, they get a form: upload image, fill in text fields, select rating, see a live preview. In a shortcode, they write [testimonial id="99" rating="5"] and hope it works.
For anything beyond trivial embeds, blocks win. If you're starting a new plugin or updating an existing one, prioritize blocks.
But here's the pragmatic reality: many production sites still run the classic editor. Some teams manage content through REST API calls or scripts that generate shortcodes. Some plugins have been in the wild for years, and their users expect shortcode syntax to remain stable. Migration to blocks is a real effort, and breaking changes create support burden.
The solution: support both. Render the same content from a block AND a shortcode. Here's a pattern:
function register_testimonial_block() {
register_block_type( 'my_plugin/testimonial', array(
'render_callback' => 'render_testimonial_block',
'attributes' => array(
'id' => array( 'type' => 'number', 'default' => 0 ),
'rating' => array( 'type' => 'number', 'default' => 5 ),
),
) );
add_shortcode( 'testimonial', 'render_testimonial_shortcode' );
}
add_action( 'init', 'register_testimonial_block' );
function render_testimonial_shortcode( $atts ) {
$atts = shortcode_atts(
array(
'id' => 0,
'rating' => 5,
),
$atts,
'testimonial'
);
return render_testimonial_content( $atts );
}
function render_testimonial_block( $atts, $content, $block ) {
return render_testimonial_content( $atts );
}
function render_testimonial_content( $atts ) {
$id = absint( $atts['id'] );
$rating = absint( $atts['rating'] );
// Render HTML
// ...
}
Both the block and the shortcode call the same rendering function. Maintain once, serve twice.
Escape attributes
Shortcode attributes are user input. Treat them like form data. If you don't escape and validate, you're opening security holes.
This is critical because shortcodes often live in post content, and any user with editor role can write them. A careless developer might do this:
// WRONG - don't do this
function render_alert( $atts ) {
return '<div>' . $atts['message'] . '</div>';
}
An attacker writes [alert message="<script>alert('hacked')</script>"] and the script runs. It's the same vulnerability as any unsanitized output.
Always escape based on context:
function render_alert( $atts ) {
$atts = shortcode_atts(
array(
'message' => '',
'type' => 'info',
),
$atts,
'alert'
);
$message = sanitize_text_field( $atts['message'] );
$type = sanitize_key( $atts['type'] );
// Validate type against whitelist
if ( ! in_array( $type, array( 'info', 'warning', 'error' ), true ) ) {
$type = 'info';
}
return sprintf(
'<div>%s</div>',
esc_attr( $type ),
wp_kses_post( $message )
);
}
Key principles:
absint()orintval()for numeric IDssanitize_text_field()for plain text; usewp_kses_post()if HTML is allowedsanitize_key()for option names or style classesesc_attr()when outputting in HTML attributesesc_html()when outputting in text content- Always use
shortcode_atts()to normalize input and apply defaults
Keep shortcodes backward-compatible
Once you ship a shortcode in production, changing its behavior is a breaking change. Users have written it into their posts. Clients have built templates around it. Your plugin's reputation depends on stability.
Plan for evolution from day one.
Scenario: You released [banner text="..." color="blue"] two years ago. Now you want to support gradient colors.
Don't change the existing color attribute format. Add a new attribute:
function render_banner( $atts ) {
$atts = shortcode_atts(
array(
'text' => '',
'color' => '', // Legacy: single color
'background_color' => '', // New: single color (preferred name)
'gradient' => '', // New: gradient syntax
),
$atts,
'banner'
);
$bg_color = '';
if ( $atts['gradient'] ) {
$bg_color = sanitize_text_field( $atts['gradient'] );
} elseif ( $atts['background_color'] ) {
$bg_color = sanitize_hex_color( $atts['background_color'] );
} elseif ( $atts['color'] ) {
// Legacy support
$bg_color = sanitize_hex_color( $atts['color'] );
}
return sprintf(
'<div>%s</div>',
esc_attr( $bg_color ),
wp_kses_post( $atts['text'] )
);
}
The old shortcode still works. New users get better options. No breaking changes.
Document your shortcodes clearly in a README or settings page, including:
- All available attributes with types and defaults
- Which attributes are deprecated (if any)
- Examples of usage
- Deprecated since which version
When you eventually deprecate a shortcode entirely, give users a migration path and communication timeline.
Common mistakes
Not using shortcode_atts(): Rolling your own attribute parsing invites bugs. shortcode_atts() normalizes keys, applies defaults, and handles edge cases. Use it.
Over-engineering shortcodes: If your shortcode needs 10 attributes and complex nesting, it should be a block or custom post type view, not a shortcode. Shortcodes excel at simple injections.
Storing shortcodes in options: Sometimes a team dynamically generates shortcodes and saves them in wp_options. This works but creates a maintenance problem—the shortcode is now configuration, not code. Use post metadata or a custom table instead.
Not testing with different WordPress versions: Shortcodes are stable, but your plugin's dependencies might not be. Test your shortcodes with at least the current and prior major WordPress release.
Ignoring caching: If your shortcode queries the database or makes external API calls, it's slow when rendered in loops. Cache aggressively using transients or object cache, and provide ways for users to bust cache if needed.
Key takeaways
- Shortcodes remain useful for simple embeds and backward compatibility, but blocks should be your default for new features
- Use
add_shortcode()withshortcode_atts()to register cleanly; always sanitize and escape attributes - Escape based on context:
esc_attr(),esc_html(),wp_kses_post(), sanitize functions - Build both a block and shortcode that share the same rendering logic for maximum reach with minimal duplication
- Plan for backward compatibility from the start; deprecate gradually, never abruptly
- Document your shortcodes thoroughly so other team members and users understand their purpose and syntax






Comments
Loading comments…