# How to Create a Custom Landing Page Builder Within FlutterFlow

- Tool: FlutterFlow
- Difficulty: Beginner
- Time required: 70-90 min
- Compatibility: FlutterFlow Pro+ (Custom Widgets required for ReorderableListView)
- Last updated: March 2026

## TL;DR

Build a meta-builder in FlutterFlow where your users can create their own landing pages. Store all sections in a single Firestore document as an ordered Array of section objects (not separate documents) to make reordering a single write. Use ReorderableListView for drag-and-drop section ordering, a BottomSheet editor per section type, a ColorFiltered preview, and a published boolean toggle for one-click deployment.

## Let Your Users Build Their Own Pages

A landing page builder inside your FlutterFlow app lets end users create and customize their own promotional pages, product listings, or profile pages without needing a developer. The critical design decision is data modeling: storing each section as a separate Firestore document makes reordering require N writes and creates race conditions. Instead, store all sections as an ordered Array on a single page document — reordering becomes one atomic write, and the entire page loads in a single Firestore read.

## Before you start

- FlutterFlow Pro plan (Custom Widgets required for drag-and-drop)
- Firebase and Firestore configured in your project
- Authentication set up so pages belong to specific users
- Basic Dart knowledge for Custom Widget implementation

## Step-by-step guide

### 1. Design the Page Document Schema in Firestore

Create a 'pages' Firestore collection. Each page document contains: user_id, title (String), slug (String — URL identifier), published (Boolean), created_at, updated_at, and sections (Array of Maps). Each section Map in the Array has: id (UUID), type (String — 'hero', 'text', 'image', 'cta', 'spacer'), order (Integer), and a content Map whose keys vary by type. For a 'hero' section, content has heading, subheading, background_color, and button_text. For 'text', it has body and alignment. For 'image', it has image_url and caption. For 'cta', it has headline, button_text, and button_url. Storing sections as an Array on the parent document means the entire page loads in one read and reordering is one write.

**Expected result:** Firestore shows a 'pages' collection where each document contains a sections Array with multiple section Maps, each with a type and content object.

### 2. Build the Section List with ReorderableListView

Create a Custom Widget called SectionReorderList that wraps Flutter's ReorderableListView. Each item in the list renders a section preview card based on the section type, with a drag handle icon on the right side. When the user finishes dragging, the ReorderableListView's onReorder callback fires with the old and new index. In the callback, create a new List with the section moved to the new position, regenerate the order integers, and write the updated sections Array to Firestore in one update() call. Show a drag-mode indicator bar at the top when dragging is active.

```
import 'package:flutter/material.dart';

class SectionReorderList extends StatefulWidget {
  final List<Map<String, dynamic>> sections;
  final ValueChanged<List<Map<String, dynamic>>> onReorder;
  final ValueChanged<Map<String, dynamic>> onEditSection;
  final ValueChanged<String> onDeleteSection;

  const SectionReorderList({
    super.key,
    required this.sections,
    required this.onReorder,
    required this.onEditSection,
    required this.onDeleteSection,
  });

  @override
  State<SectionReorderList> createState() => _SectionReorderListState();
}

class _SectionReorderListState extends State<SectionReorderList> {
  late List<Map<String, dynamic>> _sections;

  @override
  void initState() {
    super.initState();
    _sections = List.from(widget.sections);
  }

  @override
  Widget build(BuildContext context) {
    return ReorderableListView.builder(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      itemCount: _sections.length,
      onReorder: (oldIndex, newIndex) {
        setState(() {
          if (newIndex > oldIndex) newIndex--;
          final item = _sections.removeAt(oldIndex);
          _sections.insert(newIndex, item);
          // Reindex order field
          for (int i = 0; i < _sections.length; i++) {
            _sections[i] = {..._sections[i], 'order': i};
          }
        });
        widget.onReorder(_sections);
      },
      itemBuilder: (context, index) {
        final section = _sections[index];
        return SectionPreviewCard(
          key: ValueKey(section['id']),
          section: section,
          onEdit: () => widget.onEditSection(section),
          onDelete: () => widget.onDeleteSection(section['id'] as String),
        );
      },
    );
  }
}
```

**Expected result:** Long-pressing and dragging a section card moves it to a new position in the list. Releasing saves the new order to Firestore as a single write.

### 3. Create BottomSheet Editors for Each Section Type

When a user taps the Edit button on a section card, show a BottomSheet with the appropriate editor for that section type. Use a switch on section type to determine which editor to show. The Hero BottomSheet contains a TextField for heading, a TextField for subheading, a ColorPicker for background color, and a TextField for button text. The Text BottomSheet has a multiline TextField and an alignment selector. The Image BottomSheet has an image upload button and a TextField for caption. Each BottomSheet has Save and Cancel buttons. On Save, find the section by its id in the sections Array, replace it with the updated version, and write the whole sections Array back to Firestore.

```
import 'package:cloud_firestore/cloud_firestore.dart';

Future<void> updateSection(
  String pageId,
  String sectionId,
  Map<String, dynamic> updatedContent,
) async {
  final docRef = FirebaseFirestore.instance.collection('pages').doc(pageId);

  await FirebaseFirestore.instance.runTransaction((tx) async {
    final snap = await tx.get(docRef);
    final sections = List<Map<String, dynamic>>.from(
      (snap.data()?['sections'] as List? ?? []).map((s) => Map<String, dynamic>.from(s as Map)),
    );

    final index = sections.indexWhere((s) => s['id'] == sectionId);
    if (index == -1) return;

    sections[index] = {
      ...sections[index],
      'content': updatedContent,
      'updated_at': DateTime.now().millisecondsSinceEpoch,
    };

    tx.update(docRef, {
      'sections': sections,
      'updated_at': FieldValue.serverTimestamp(),
    });
  });
}
```

**Expected result:** Tapping Edit on a Hero section opens a bottom sheet with the current heading and subheading pre-populated. Saving updates only that section in the Firestore document.

### 4. Add New Sections with a Section Type Picker

Add an Add Section button at the bottom of the sections list. Tapping it shows a BottomSheet with a grid of section type options: Hero, Text, Image, and CTA — each with an icon and label. Tapping a type creates a new section Map with default content for that type, assigns a UUID as the id, sets order to sections.length, and appends it to the sections Array using FieldValue.arrayUnion(). Show a brief animation (AnimatedList or FadeTransition) as the new section appears. New sections always appear at the bottom and can be dragged to the desired position.

**Expected result:** Tapping Add Section shows a type picker. Choosing Hero adds a new hero section card at the bottom of the list with placeholder text visible.

### 5. Implement the Publish Toggle and Live Preview

Add a Publish toggle switch at the top of the page editor, linked to the page document's published Boolean field. When toggled on, update published to true and set published_at to the current timestamp. Your public-facing page renderer reads the page by slug and checks published — unpublished pages show a 404 or a preview-only message. Add a Preview button that opens the page in a WebView or a Flutter route that renders the sections as real UI (not just the editor cards). The preview renders each section type using its actual content: a full-width Container with color for Hero, a Text widget for Text sections, and an Image.network for Image sections.

```
import 'package:cloud_firestore/cloud_firestore.dart';

Future<void> togglePublish(String pageId, bool publish) async {
  final update = <String, dynamic>{
    'published': publish,
    'updated_at': FieldValue.serverTimestamp(),
  };

  if (publish) {
    update['published_at'] = FieldValue.serverTimestamp();
  }

  await FirebaseFirestore.instance
      .collection('pages')
      .doc(pageId)
      .update(update);
}
```

**Expected result:** Toggling Published on makes the page accessible at its slug URL. Toggling off removes it from public access. The toggle state reflects the current Firestore value instantly via the real-time listener.

## Complete code example

File: `page_builder_service.dart`

```dart
// Complete landing page builder service for FlutterFlow
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:uuid/uuid.dart';

const _uuid = Uuid();

class PageBuilderService {
  final _db = FirebaseFirestore.instance;
  String? get _uid => FirebaseAuth.instance.currentUser?.uid;

  // ─── Create a new blank page ──────────────────────────────────────────────

  Future<String?> createPage(String title, String slug) async {
    if (_uid == null) return null;

    final docRef = await _db.collection('pages').add({
      'user_id': _uid,
      'title': title,
      'slug': slug.toLowerCase().replaceAll(' ', '-'),
      'published': false,
      'sections': [
        _defaultSection('hero'),
      ],
      'created_at': FieldValue.serverTimestamp(),
      'updated_at': FieldValue.serverTimestamp(),
    });
    return docRef.id;
  }

  // ─── Add a section ────────────────────────────────────────────────────────

  Future<void> addSection(String pageId, String type) async {
    final section = _defaultSection(type);
    await _db.collection('pages').doc(pageId).update({
      'sections': FieldValue.arrayUnion([section]),
      'updated_at': FieldValue.serverTimestamp(),
    });
  }

  // ─── Reorder sections (one atomic write) ─────────────────────────────────

  Future<void> reorderSections(
    String pageId,
    List<Map<String, dynamic>> reorderedSections,
  ) async {
    await _db.collection('pages').doc(pageId).update({
      'sections': reorderedSections,
      'updated_at': FieldValue.serverTimestamp(),
    });
  }

  // ─── Update a section by ID ──────────────────────────────────────────────

  Future<void> updateSection(
    String pageId,
    String sectionId,
    Map<String, dynamic> newContent,
  ) async {
    final ref = _db.collection('pages').doc(pageId);
    await _db.runTransaction((tx) async {
      final snap = await tx.get(ref);
      final sections = _parseSections(snap.data()?['sections']);
      final idx = sections.indexWhere((s) => s['id'] == sectionId);
      if (idx == -1) return;
      sections[idx] = {...sections[idx], 'content': newContent};
      tx.update(ref, {'sections': sections, 'updated_at': FieldValue.serverTimestamp()});
    });
  }

  // ─── Delete a section ────────────────────────────────────────────────────

  Future<void> deleteSection(String pageId, String sectionId) async {
    final ref = _db.collection('pages').doc(pageId);
    await _db.runTransaction((tx) async {
      final snap = await tx.get(ref);
      final sections = _parseSections(snap.data()?['sections'])
          .where((s) => s['id'] != sectionId).toList();
      tx.update(ref, {'sections': sections, 'updated_at': FieldValue.serverTimestamp()});
    });
  }

  // ─── Publish / Unpublish ──────────────────────────────────────────────────

  Future<void> setPublished(String pageId, bool published) async {
    await _db.collection('pages').doc(pageId).update({
      'published': published,
      if (published) 'published_at': FieldValue.serverTimestamp(),
      'updated_at': FieldValue.serverTimestamp(),
    });
  }

  // ─── Helpers ──────────────────────────────────────────────────────────────

  Map<String, dynamic> _defaultSection(String type) {
    final defaults = <String, Map<String, dynamic>>{
      'hero': {'heading': 'Your Headline Here', 'subheading': 'Supporting text', 'background_color': '#4F46E5', 'button_text': 'Get Started'},
      'text': {'body': 'Add your paragraph text here.', 'alignment': 'left'},
      'image': {'image_url': '', 'caption': ''},
      'cta': {'headline': 'Ready to get started?', 'button_text': 'Sign Up Now', 'button_url': ''},
    };
    return {'id': _uuid.v4(), 'type': type, 'order': 0, 'content': defaults[type] ?? {}};
  }

  List<Map<String, dynamic>> _parseSections(dynamic raw) =>
      ((raw as List?) ?? []).map((s) => Map<String, dynamic>.from(s as Map)).toList();
}
```

## Common mistakes

- **Storing each section as a separate Firestore document instead of an Array** — Reordering 10 sections requires updating the order field on all 10 documents — 10 separate writes. With concurrent users, these writes can interleave and produce incorrect orderings. Loading the page requires 10 reads instead of 1. Fix: Store all sections as an ordered Array in a single page document. Reordering is one atomic array replacement. Loading is one document read. The 1MB document limit supports hundreds of rich text sections before becoming a concern.
- **Not using a transaction when updating a single section in the Array** — To update one section, you must read the current Array, modify one element, and write the whole Array back. Without a transaction, a concurrent edit could overwrite changes made between your read and write. Fix: Wrap all Array-modify-and-write operations in a Firestore transaction. The transaction retries automatically on contention and guarantees the read and write are atomic.
- **Updating the published URL without checking for slug uniqueness** — If two users create pages with the slug 'homepage', the public URL resolves ambiguously. One page will be unreachable. Fix: Before saving a new slug, query Firestore for existing pages with that slug and show a validation error if one exists. Consider scoping slugs by user ID in the URL: /page/{userId}/{slug}.

## Best practices

- Always include a unique id field inside each section Map — you need it to identify which section to update without relying on fragile Array index positions.
- Cap the maximum number of sections per page (e.g., 20) to prevent the document from approaching the 1MB Firestore limit.
- Add a lastSaved indicator showing when the page was last saved, so users know their changes are persisted.
- Implement autosave: debounce content changes by 2 seconds and save automatically rather than requiring users to tap a Save button.
- Store a screenshot or thumbnail of the published page in Firebase Storage for the page list view — generating it client-side with RepaintBoundary is the same technique as the photo editor guide.
- Add a duplicate page feature: copy the entire page document with a new ID and a modified slug suffix (-copy) for fast page cloning.
- Gate the publish action behind a completion check: validate that required fields (heading, at least one section) are populated before allowing publish.

## Frequently asked questions

### What is the maximum number of sections I can store in a single Firestore document?

Firestore documents have a 1MB size limit. A typical section Map with text content is 500 bytes to 2KB. This means you can store 500-2,000 sections before hitting the limit. For a landing page builder, capping at 20-30 sections per page is a reasonable UX limit that keeps the document well within bounds even with image URLs and rich text.

### How do I give pages a public URL that anyone can view without logging in?

Create a PublicPageView route in FlutterFlow that accepts a slug as a URL parameter. This page uses a Firestore query with no authentication requirement to fetch the page by slug (where published == true). Configure Firestore Security Rules to allow unauthenticated reads of published page documents. In FlutterFlow, set this page as accessible without login in the authentication settings.

### Can I add video sections to the landing page builder?

Yes. Add 'video' as a section type with a video_url field in the content Map. In the section renderer, detect the YouTube or Vimeo URL format and use the youtube_player_flutter or webview_flutter package to embed the video. Add a poster_image_url field for the thumbnail shown before the video plays.

### How do I let users preview their page before publishing?

Create a PagePreviewRoute that accepts the pageId and renders all sections using the same rendering logic as the public page view, but fetches unpublished pages and shows a 'Preview Mode' banner. In FlutterFlow, add a Preview button on the editor page that navigates to this route with the current pageId. This shows the user exactly what their published page will look like.

### What happens to existing pages if I add a new section type later?

Existing pages simply do not have any sections of the new type — they are unaffected. New section types only appear when users add them. In your section renderer, add a default case that shows a simple 'Unsupported section type' placeholder for any unknown types, so old pages do not break if you ever remove a section type.

### How do I implement undo/redo for section edits?

Maintain a Page State variable editHistory as a List of complete sections Array snapshots (the whole Array, not diffs). Before each edit, push the current state to the history stack. Undo pops the last snapshot and writes it back to Firestore. Limit the history to 10-20 snapshots to control memory usage. This simple snapshot-based undo works for landing page editors where sections change infrequently.

---

Source: https://www.rapidevelopers.com/flutterflow-tutorials/how-to-create-a-custom-landing-page-builder-within-flutterflow
© RapidDev — https://www.rapidevelopers.com/flutterflow-tutorials/how-to-create-a-custom-landing-page-builder-within-flutterflow
