
🔧 How to create a template for a custom post type in WordPress
You added a custom post type to your site, say "Promotions," "Portfolio," or "Testimonials." Everything works: posts are created, meta fields are filled in. But when you open one of these posts on the front end, it looks like a regular blog post. Same layout, same sidebars, no difference at all. Yet you created a custom post type precisely so the content would be presented differently.
The problem is that WordPress renders any custom post through single.php by default, the universal template for all single posts. To get a unique layout for a specific type, you need your own template file. And it turns out to be surprisingly easy.
Below is a step-by-step breakdown: from the template hierarchy to two creation methods (via a theme file and via the template_include filter), with working code examples.
💡 Quick overview:
- Understanding how WordPress looks for a template for a custom post type, and the priority of
single-{post_type}.php - Copying your theme's
single.php, renaming it for your CPT, and customizing it - Exploring an alternative method via the
template_includefilter (handy if you don't want to touch the theme) - Creating the custom post type itself using the Custom Post Type UI plugin, quickly and without code
1. How WordPress chooses a template for a custom post type
The WordPress template hierarchy is a chain of files that the core checks to find the right template for rendering a page. For standard posts the chain is long: single-post.php → single.php → singular.php → index.php. For custom post types it is shorter, but the logic is the same.
When a visitor opens a custom post of type aktsii, WordPress checks files in this order:
single-aktsii.php, the template specifically for this custom post typesingle.php, the generic single-post templatesingular.php, the template for any singular content (post, page, CPT)index.php, the final fallback
The first file found in the chain is the one used for rendering. If single-aktsii.php exists, WordPress picks it up and stops. If it is not found, the core moves on to single.php. That is exactly why your custom post type looks like a regular post: there is no single-aktsii.php file, so the core falls back to the generic single.php.
The solution follows directly from this: create single-{post_type}.php, and WordPress picks it up automatically.
2. Creating a template file: copy single.php and rename it
This is the simplest and most reliable approach. It works with any classic theme and requires no plugins or filters.
Step 1: find your theme's single.php. It sits in the root of the theme folder: /wp-content/themes/your-theme/single.php. If the theme uses FSE (Full Site Editing) and is built on blocks, this file may not exist, and the filter method (template_include) will be more convenient (section 4).
Step 2: copy and rename. Copy single.php and name it following the pattern single-{slug}.php: replace {slug} with the slug of your custom post type (the one specified during registration, in Latin characters). For example, for a type with the slug aktsii:
1 single-aktsii.php
For a type with the slug portfolio the file would be named single-portfolio.php. For testimonials it becomes single-testimonials.php. Important: the slug must match the one specified in the 'rewrite' => array('slug' => '...') parameter during registration.
Step 3: place the file back in the theme folder. WordPress will automatically pick it up for all posts of this type. No additional configuration is needed; the template hierarchy handles it automatically.
Step 4: verify. Open any post of the custom type on the front end. If you see the same page as before, check the file name (case sensitivity, hyphens instead of underscores) and clear the cache. If the file was created in a child theme and the parent theme also contains single-{post_type}.php, the child theme takes priority.
3. Customizing the template content
The copied single.php is still identical to the original; it simply renders the same generic layout. Now we fill it with content specific to the custom post type.
3.1. Basic structure: outputting custom fields
Suppose the custom post type aktsii has the fields aktsiya_data_start, aktsiya_data_end, and aktsiya_skidka (created via ACF, Meta Box, or manually). Here is a minimal template that outputs them:
1 <?php 2 /** 3 * Template for custom type "Promotions" (single-aktsii.php) 4 */ 5 6 get_header(); 7 ?> 8 9 <main id="main" class="site-main" role="main"> 10 11 <?php while ( have_posts() ) : the_post(); ?> 12 13 <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>> 14 15 <header class="entry-header"> 16 <?php the_title( '<h1 class="entry-title">', '</h1>' ); ?> 17 </header> 18 19 <?php if ( has_post_thumbnail() ) : ?> 20 <div class="post-thumbnail"> 21 <?php the_post_thumbnail( 'large' ); ?> 22 </div> 23 <?php endif; ?> 24 25 <div class="entry-content"> 26 <?php the_content(); ?> 27 28 <div class="custom-fields"> 29 <?php 30 $data_start = get_post_meta( get_the_ID(), 'aktsiya_data_start', true ); 31 $data_end = get_post_meta( get_the_ID(), 'aktsiya_data_end', true ); 32 $skidka = get_post_meta( get_the_ID(), 'aktsiya_skidka', true ); 33 34 if ( $data_start ) { 35 echo '<p><strong>Start Date:</strong> ' . esc_html( $data_start ) . '</p>'; 36 } 37 if ( $data_end ) { 38 echo '<p><strong>End Date:</strong> ' . esc_html( $data_end ) . '</p>'; 39 } 40 if ( $skidka ) { 41 echo '<p><strong>Discount:</strong> ' . esc_html( $skidka ) . '%</p>'; 42 } 43 ?> 44 </div> 45 </div> 46 47 </article> 48 49 <?php 50 if ( comments_open() || get_comments_number() ) : 51 comments_template(); 52 endif; 53 ?> 54 55 <?php endwhile; ?> 56 57 </main> 58 59 <?php 60 get_sidebar(); 61 get_footer();
This is a fully functional template that you can drop into your theme as-is. It outputs the title, the featured image, the content, and three custom fields in a separate block.
3.2. What else you can do with the template
From here, the possibilities for customization are virtually limitless:
- Remove the sidebar: simply remove the
get_sidebar()call. - Change the HTML wrapper: replace
<article>with a<div>carrying the class you need, add your own grid. - Call a specific template part: for example,
get_template_part( 'template-parts/content', 'aktsii' )and keep the logic in a separate file. - Enqueue custom styles: via
wp_enqueue_style()insidefunctions.phpwith anis_singular( 'aktsii' )check.
An important note: if the theme is updated, a file in the parent theme can be overwritten. So either work in a child theme, or use the filter method from the next section.
4. Alternative approach: the template_include filter
If you prefer not to modify theme files (or the theme is block-based and lacks a classic single.php), you can assign a template via the template_include hook. It fires before WordPress includes the template file and lets you override the path.
Downside: you will need to store the template file either in a plugin or in a child theme, and the code must point to the correct path. Upside: the logic is not lost when the parent theme is updated.
4.1. Example: one CPT, one template
Add this to the child theme's functions.php (or to an MU-plugin):
1 add_filter( 'template_include', 'techblog_cpt_template', 99 ); 2 3 function techblog_cpt_template( $template ) { 4 if ( is_singular( 'aktsii' ) ) { 5 $custom_template = get_stylesheet_directory() . '/single-aktsii.php'; 6 if ( file_exists( $custom_template ) ) { 7 return $custom_template; 8 } 9 } 10 return $template; 11 }
The code checks whether the current page belongs to the custom post type aktsii. If so, it looks for the file single-aktsii.php in the child theme folder (get_stylesheet_directory()) and returns it. If the file is not found, it returns the default $template unchanged.
4.2. Example: multiple CPTs with a single handler
If you have several custom post types, it is convenient to consolidate the logic in one filter:
1 add_filter( 'template_include', 'techblog_cpt_templates', 99 ); 2 3 function techblog_cpt_templates( $template ) { 4 $cpt_templates = array( 5 'aktsii' => 'single-aktsii.php', 6 'portfolio' => 'single-portfolio.php', 7 'testimonials' => 'single-testimonials.php', 8 ); 9 10 foreach ( $cpt_templates as $cpt => $template_file ) { 11 if ( is_singular( $cpt ) ) { 12 $custom_template = get_stylesheet_directory() . '/' . $template_file; 13 if ( file_exists( $custom_template ) ) { 14 return $custom_template; 15 } 16 } 17 } 18 19 return $template; 20 }
Now each CPT gets its own template, and all the logic lives in a single function.
4.3. Storing templates in a plugin
If you distribute the custom post type as a plugin (rather than as part of a theme), it makes sense to store the template in the plugin folder. In that case, specify the path via plugin_dir_path( __FILE__ ):
1 add_filter( 'template_include', 'myplugin_cpt_template', 99 ); 2 3 function myplugin_cpt_template( $template ) { 4 if ( is_singular( 'aktsii' ) ) { 5 $custom_template = plugin_dir_path( __FILE__ ) . 'templates/single-aktsii.php'; 6 if ( file_exists( $custom_template ) ) { 7 return $custom_template; 8 } 9 } 10 return $template; 11 }
This pattern is used by many popular plugins (WooCommerce, Easy Digital Downloads, The Events Calendar): they ship templates internally and include them via the filter.
5. Creating the custom post type
We have the template ready, but for the sake of completeness, a few words about where the custom post type itself comes from.
5.1. Registration via code
A minimal CPT registration in functions.php or an MU-plugin:
1 add_action( 'init', 'techblog_register_cpt_aktsii' ); 2 3 function techblog_register_cpt_aktsii() { 4 $labels = array( 5 'name' => 'Promotions', 6 'singular_name' => 'Promotion', 7 'menu_name' => 'Promotions', 8 ); 9 10 $args = array( 11 'labels' => $labels, 12 'public' => true, 13 'has_archive' => true, 14 'supports' => array( 'title', 'editor', 'thumbnail' ), 15 'rewrite' => array( 'slug' => 'aktsii' ), 16 'show_in_rest' => true, 17 ); 18 19 register_post_type( 'aktsii', $args ); 20 }
The key detail here is that the 'rewrite' => array( 'slug' => 'aktsii' ) parameter sets the slug used in both the URL and the template file name. If the slug is aktsii, the template file must be named single-aktsii.php.
5.2. Using the Custom Post Type UI plugin
If you would rather not edit functions.php, you can create a CPT visually using the free Custom Post Type UI plugin on WordPress.org. After installation:
- In the admin panel, go to CPT UI → Add/Edit Post Types
- Fill in the fields: Post Type Slug (Latin characters), Plural Label, Singular Label
- On the Settings tab, select which editor features you need (title, editor, thumbnail)
- Click Add Post Type
The plugin generates the PHP registration code, which you can export and paste into functions.php. This is convenient if you later want to remove the plugin dependency. The slug you specified in CPT UI is the same one that goes into the template file name.
⁉️🤔 Frequently asked questions
What should I do if the template is not picked up after creating the file?
Flush the permalinks: go to Settings → Permalinks and click "Save Changes" (you don't need to change anything). WordPress will rebuild its routing rules. If that does not help, check the file name: the custom post type slug and the file name must match (case, hyphens, and underscores). As a third step, clear the cache of your caching plugin, if one is installed. Permalinks should be flushed after registering any new custom post type, not only when you run into template issues. This is standard practice: go to Settings → Permalinks → Save, and the new URLs start working immediately.
Can I use one template for multiple custom post types?
Yes, in two ways. First, create a physical file
single-aktsii.php, and for the second type (say,portfolio) copy it assingle-portfolio.php. Second, use thetemplate_includefilter (section 4): in the$cpt_templatesarray, point different types to the same template file. In practice, though, different CPTs almost always have different layouts; copying and editing is simpler than building conditional logic inside a single template. If you truly have many types with identical logic, create a sharedsingle-cpt.php, checkget_post_type()inside it, and include the corresponding template part viaget_template_part( 'template-parts/content', get_post_type() ). This is a clean and scalable approach.
Do I need to create archive-{post_type}.php** for the archive page?**
It is recommended but not required. Without it, WordPress uses
archive.phporindex.php. If the CPT has'has_archive' => trueand the archive opens at/aktsii/but there is noarchive-aktsii.phpfile, the genericarchive.phpwill be used. Creating a separate archive template makes sense when the post grid, column layout, or sidebar should differ from the blog archive.
Does this work with block themes (FSE)?
In block themes, classic PHP template files are not the primary approach. However, the
template_includefilter (section 4) works in FSE as well: you can placesingle-aktsii.phpin a child theme or plugin and include it via the filter. Alternatively, in the Site Editor you can create a template for a specific custom post type through the interface: Appearance → Editor → Templates → Add New Template → Single Item: Aktsii. WordPress will save it as an HTML template in the database, and it will work without any files in the theme.
What to choose in 2026: file or filter?
A quick decision matrix for choosing the approach:
Scenario | What to use |
|---|---|
Classic theme, one or two CPTs |
|
Child theme, you are adding the CPT | File in the child theme, survives parent theme updates |
Block theme (FSE), CPT via a plugin |
|
CPT distributed as a plugin | Filter + template inside the plugin folder |
Many CPTs with a similar structure | Filter + shared |
In practice, at techblog.sdstudio.top we most often use a combination: register the CPT via code in an MU-plugin and place the templates as files in a child theme. This gives automatic pickup without extra filters, and parent theme updates break nothing.
If you are just starting to learn about custom post types, begin with the single-{post_type}.php file. It takes five minutes and delivers instant results. When you feel the file-based approach is no longer enough (you need to load templates from a plugin or swap them on the fly), switch to template_include. Both methods are fully legitimate and supported by the WordPress core.



