How to create pre-designed page layouts for Gutenberg?

You have a number of options. But the best method I have found is to use combination of custom blocks that allow the user to rearrange content themselves and custom post meta (depending on the situation).

For simple things, I stick to custom blocks. I create section blocks, card blocks etc, that have strict functionality and design (so the user doesn’t “mess” with the design too much), but allow the user to assembling things in the order they want.

For more complex things, I use custom post meta. ACF uses the post meta functionality built into WordPress. You can replicate that functionality pretty easily in Gutenberg but there are a couple extra steps. First you’ll need to register the custom post meta:

function pb_register_post_meta() {
    register_post_meta('page', '_pb_meta_name', array(
        'type' => 'string',
        'single' => true,
        'auth_callback' => function() {
            return current_user_can('edit_posts');
        },
        'show_in_rest' => true, // This ensures the meta field is available in Gutenberg
    ));
add_action('init', 'pb_register_post_meta');
));

Then you can build a block, PluginDocumentSettingPanel, or a custom PluginSidebar. I personally mostly use a combination of block and PluginDocumentSettingPanel depending on the amount of custom info I need the user to input. If there is a lot, I’ll make it a block so it uses the primary editor pane for editing. Either way, the process is about the same:

/**
 * WordPress dependencies
 */
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import { registerBlockType } from '@wordpress/blocks';
import { TextControl } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';

registerBlockType('pb/post-details', {
    apiVersion: 2,

    title: __('Post Details', 'pb'),

    icon: 'star',

    category: 'common',

    supports: {
        html: false,
        customClassName: false,
        multiple: false,
        reusable: false,
    },

    edit: () => {
        const blockProps = useBlockProps();

        const postType = useSelect((select) => (
            select('core/editor').getCurrentPostType()
        ));

        // Get the post meta as an object
        const [meta, setMeta] = useEntityProp('postType', postType, 'meta');

        return (
            <div { ...blockProps }>
                <TextControl
                    label={ __('Meta Field Label', 'pb') }
                    value={ meta._pb_meta_name }
                    onChange={ (value) => setMeta({_pb_meta_name: value}) }
                />
            </div>
        );
    },

    save: () => null,
});

(You can also register your block using block.json).

You can also use templates on post types, including the ability to lock a template.

‘template_lock’ (string|false) Whether the block template should be locked if $template is set.

If set to ‘all’, the user is unable to insert new blocks, move existing blocks and delete blocks.

If set to ‘insert’, the user is able to move existing blocks but is unable to insert new blocks and delete blocks. Default false.

(source)

register_post_type('post_type_name', array(
    // ...The rest of your post type settings
    'show_in_rest' => true,
    'template' => array(
        array('pb/post-details'),
    ),
    'template_lock' => 'all',
));