Page builders make it easy to lay out a page, but they make it hard to keep content consistent, fast and easy for clients to update. Building with WordPress custom post types and ACF fields in a custom theme solves that: every project, team member or service gets a clear editing screen, and the theme controls how it looks. This guide covers how I set that up, from registering post types to reading fields in a way that won’t break the front end if the plugin is ever switched off.
The examples use a “Projects” portfolio, but the same pattern works for services, case studies, team members, locations, events or products.
Why custom post types and ACF beat a page builder for structured content
A page builder stores layout and content together. That is fine for a one-off landing page, but for repeating content it creates problems:
- Inconsistency. Every project page is built by hand, so spacing and structure drift over time.
- Hard to reuse. You can’t easily list “the three latest projects in the healthcare sector” when the data is buried inside layout markup.
- Performance. Builders add their own CSS, JavaScript and deeply nested markup on every page.
- Lock-in. Content stored in builder shortcodes or JSON is painful to migrate.
With custom post types and fields, the client fills in a form (title, client name, URL, gallery, sector), and the theme renders it the same way every time. The content is also structured data, so you can query it, filter it, expose it in the REST API and turn it into schema markup.
Registering custom post types
Register post types on the init hook. Here is a complete, production-ready example:
add_action( 'init', function () {
register_post_type( 'project', [
'labels' => [
'name' => __( 'Projects', 'mytheme' ),
'singular_name' => __( 'Project', 'mytheme' ),
'add_new_item' => __( 'Add New Project', 'mytheme' ),
'edit_item' => __( 'Edit Project', 'mytheme' ),
],
'public' => true,
'has_archive' => true,
'rewrite' => [ 'slug' => 'projects', 'with_front' => false ],
'menu_icon' => 'dashicons-portfolio',
'menu_position' => 20,
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt', 'revisions' ],
'show_in_rest' => true, // Required for the block editor.
] );
} );
A few choices worth explaining:
has_archivegives you/projects/as a listing page with no extra page or template hacks.show_in_restenables the block editor and the REST API. Leave it out if you want the classic editor screen for this post type.- For internal content that should never have its own URL (testimonials shown in a slider, for example), set
'public' => falseand'show_ui' => true. That avoids thin pages that Google might index.
Rewrite rules need flushing after registration changes. Do it once on theme activation, never on every request:
add_action( 'after_switch_theme', 'flush_rewrite_rules' );
Theme or plugin?
Strictly speaking, post types are content, and content should survive a theme change. On larger builds I register post types and taxonomies in a small must-use plugin (wp-content/mu-plugins/) and keep templates in the theme. For a bespoke site where theme and content model are delivered together, registering them in the theme is acceptable, as long as the client knows the trade-off.
Adding custom taxonomies for filtering
Use a taxonomy for anything you will filter, group or list by. Here, projects are grouped by sector:
add_action( 'init', function () {
register_taxonomy( 'sector', [ 'project' ], [
'labels' => [
'name' => __( 'Sectors', 'mytheme' ),
'singular_name' => __( 'Sector', 'mytheme' ),
],
'hierarchical' => true, // Category-style checkboxes.
'show_admin_column' => true, // Shows a Sector column in the Projects list.
'show_in_rest' => true, // Needed for the block editor sidebar panel.
'rewrite' => [ 'slug' => 'projects/sector', 'with_front' => false ],
] );
} );
The rule of thumb I follow: if you will query by it, make it a taxonomy, not a custom field. Taxonomy queries use indexed relationship tables. A meta_query on wp_postmeta compares unindexed values and gets slow as content grows.
ACF field groups with JSON sync in the theme
Advanced Custom Fields gives clients a clean editing form. By default, field groups live only in the database, which means they aren’t version-controlled and have to be recreated by hand on each environment. ACF’s Local JSON fixes that. Create an acf-json folder in your theme, and ACF will save a JSON file there every time a field group is saved. Being explicit about the path helps when you use a child theme or a custom folder:
add_filter( 'acf/settings/save_json', function () {
return get_stylesheet_directory() . '/acf-json';
} );
add_filter( 'acf/settings/load_json', function ( $paths ) {
$paths[] = get_stylesheet_directory() . '/acf-json';
return $paths;
} );
Commit the JSON files to Git along with the theme. When you deploy to staging or production, the field groups load from JSON straight away, and the Field Groups screen shows a “Sync available” notice if the database copy is older. Clicking sync imports the changes. Since ACF 6.1, post types and taxonomies created in the ACF interface are saved to Local JSON as well, if you prefer to register them there instead of in code.
For the Projects example, a field group assigned to the project post type might contain client_name (Text), project_url (URL), hero_image (Image) and a results Repeater with label and value sub-fields. The Repeater field needs ACF PRO.
Reading ACF fields safely (without hard-depending on the plugin)
Calling get_field() directly in templates is the most common way ACF sites break. If the plugin is deactivated, during an update gone wrong or a licence issue, every template that calls it throws a fatal error and the whole front end goes down.
ACF stores simple field values as ordinary post meta, using the field name as the meta key. That means the front end can read them with core functions and keep working even if ACF is not active:
/**
* Read a field value as raw post meta. Works with or without ACF active.
*/
function mytheme_field( string $name, ?int $post_id = null ): mixed {
$post_id = $post_id ?: get_the_ID();
return get_post_meta( $post_id, $name, true );
}
$client = mytheme_field( 'client_name' );
$url = mytheme_field( 'project_url' );
$hero = (int) mytheme_field( 'hero_image' ); // ACF stores the attachment ID.
if ( $client ) {
echo '<p class="project-client">' . esc_html( $client ) . '</p>';
}
if ( $url ) {
echo '<a href="' . esc_url( $url ) . '" rel="noopener">Visit the live site</a>';
}
if ( $hero ) {
echo wp_get_attachment_image( $hero, 'large', false, [ 'fetchpriority' => 'high' ] );
}
Know how ACF stores each field type before you read it raw:
- Text, URL, number, email, select (single): stored as a plain string.
- Image and file: stored as the attachment ID, whatever the field’s return format is set to.
- True/false: stored as
'1'or'0'. - Relationship, post object (multiple), gallery, checkbox: stored as a serialized array, which
get_post_meta()unserializes for you. - Repeater: the row count is stored under the field name, and each sub-field as
{repeater}_{row}_{subfield}. - Options page fields: stored in
wp_optionsasoptions_{field_name}, so useget_option( 'options_phone' ).
Reading a Repeater raw looks like this:
$rows = (int) get_post_meta( get_the_ID(), 'results', true );
if ( $rows > 0 ) {
echo '<ul class="project-results">';
for ( $i = 0; $i < $rows; $i++ ) {
$label = get_post_meta( get_the_ID(), "results_{$i}_label", true );
$value = get_post_meta( get_the_ID(), "results_{$i}_value", true );
printf(
'<li><strong>%s</strong> %s</li>',
esc_html( $value ),
esc_html( $label )
);
}
echo '</ul>';
}
For complex fields such as Flexible Content or nested Repeaters, get_field() is more practical. In that case, guard the call with function_exists( 'get_field' ) and render a sensible fallback. Whichever way you read fields, escape on output with esc_html(), esc_url(), esc_attr() or wp_kses_post() for rich text.
Template hierarchy for custom post types and taxonomies
WordPress picks templates for custom post types automatically, so you rarely need custom routing:
single-project.phpfor a single project.archive-project.phpfor/projects/.taxonomy-sector.php(ortaxonomy-sector-healthcare.phpfor one term) for sector archives.
Keep the markup for a project card in one template part and reuse it on the archive, the taxonomy pages and the home page:
// archive-project.php
get_header();
if ( have_posts() ) {
echo '<div class="project-grid">';
while ( have_posts() ) {
the_post();
get_template_part( 'template-parts/card', 'project' );
}
echo '</div>';
the_posts_pagination();
}
get_footer();
To change how the main archive query behaves (posts per page, ordering), use pre_get_posts instead of running a second WP_Query in the template:
add_action( 'pre_get_posts', function ( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_post_type_archive( 'project' ) || $query->is_tax( 'sector' ) ) {
$query->set( 'posts_per_page', 12 );
$query->set( 'orderby', 'date' );
$query->set( 'order', 'DESC' );
}
} );
Performance considerations
- Avoid
meta_queryfor filtering on large sites. Use taxonomies, as above. - For secondary queries that don’t paginate, pass
'no_found_rows' => trueto skip the expensive row count. Add'update_post_term_cache' => falseif you don’t output terms. - WordPress loads all meta for queried posts in a single query, so reading several fields per post with
get_post_meta()does not add a query per field. - Cache expensive, rarely changing queries (for example a “featured projects” block) in a transient, and clear it on
save_post_project. - A custom theme only loads the CSS and JavaScript you write, which makes the WordPress Core Web Vitals checklist much easier to pass.
SEO considerations for custom post types
- Choose clean, permanent slugs (
/projects/,/projects/sector/healthcare/) before launch. Changing them later needs redirects. - Make archive pages useful. Add an intro paragraph through an options field or the archive description, so
/projects/is more than a grid of links. - Keep internal-only types out of search with
'public' => false, and remove them from the core sitemap with thewp_sitemaps_post_typesfilter if needed. - Turn fields into structured data. Fields like client, date and location map naturally to JSON-LD schema, which you can output from the single template.
- Check that your SEO plugin can see field content. Content in custom fields may not be included in its content analysis without an integration, though that doesn’t affect how Google reads the rendered page.
Key takeaways
- Use custom post types for repeating content, and taxonomies for anything you filter or group by.
- Keep ACF field groups in version control with Local JSON in the theme.
- Read simple fields as post meta so the front end survives ACF being disabled, and escape everything on output.
- Let the template hierarchy and
pre_get_postsdo the routing and query work. - Plan slugs, archives and schema from the start.
The same approach benefits from modern PHP. Typed helpers and enums for things like project status are covered in my post on modern PHP 8 features.
Frequently asked questions
Do I need ACF PRO for custom post types and fields?
No. The free version of ACF covers the common field types and can register post types and taxonomies. ACF PRO adds Repeater, Flexible Content, Gallery, Clone and options pages, which most complex client sites end up using.
Should custom post types go in the theme or a plugin?
Content structure ideally lives in a plugin or must-use plugin so it survives a theme change, while templates live in the theme. For a bespoke site where theme and content model ship together, registering them in the theme is a reasonable trade-off.
What happens to my content if I deactivate ACF?
The values stay in the database as post meta; only the editing screens disappear. If your templates read fields with get_post_meta() or guard get_field() calls, the front end keeps working.
Are ACF fields bad for WordPress performance?
Reading fields is cheap because WordPress loads all post meta in one query. Performance problems come from filtering or sorting large result sets with meta_query, which is why filterable data belongs in taxonomies.
If you want a structured, fast WordPress site that your team can edit without fear of breaking the layout, tell me about your project and I’ll suggest a content model for it.