| layout | default |
|---|---|
| title | Plane Tutorial - Chapter 6: Pages and Wiki |
| nav_order | 6 |
| has_children | false |
| parent | Plane Tutorial |
Welcome to Chapter 6 of the Plane Tutorial. This chapter covers Plane's built-in Pages feature — a collaborative wiki and documentation system integrated directly into your project management workflow.
Build a knowledge base with collaborative pages, rich text editing, and project-linked documentation.
Teams often maintain documentation in a separate tool (Notion, Confluence, Google Docs) that is disconnected from their issue tracker. Plane's Pages bring documentation into the same workspace as issues, cycles, and modules — eliminating context-switching and keeping knowledge close to the work it describes.
Pages in Plane are rich-text documents stored as JSON (for the editor) and HTML (for rendering):
# apiserver/plane/db/models/page.py
class Page(ProjectBaseModel):
name = models.CharField(max_length=255)
description = models.JSONField(default=dict, blank=True)
description_html = models.TextField(default="<p></p>", blank=True)
description_stripped = models.TextField(blank=True, null=True)
owned_by = models.ForeignKey(
"db.User",
on_delete=models.CASCADE,
related_name="pages",
)
access = models.PositiveSmallIntegerField(
choices=((0, "Public"), (1, "Private")),
default=0,
)
color = models.CharField(max_length=255, blank=True)
labels = models.ManyToManyField(
"db.Label",
blank=True,
related_name="pages",
through="PageLabel",
)
is_favorite = models.BooleanField(default=False)
is_locked = models.BooleanField(default=False)
archived_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ("-created_at",)
unique_together = ["project", "name"]
class PageLabel(ProjectBaseModel):
"""Junction table for page labels."""
page = models.ForeignKey(
Page, on_delete=models.CASCADE, related_name="page_labels"
)
label = models.ForeignKey(
"db.Label", on_delete=models.CASCADE, related_name="label_pages"
)
class Meta:
unique_together = ["page", "label"]
class PageFavorite(ProjectBaseModel):
"""Track user favorites for pages."""
user = models.ForeignKey(
"db.User", on_delete=models.CASCADE, related_name="page_favorites"
)
page = models.ForeignKey(
Page, on_delete=models.CASCADE, related_name="page_favorites"
)
class Meta:
unique_together = ["user", "page"]Pages can be organized in a tree structure for building a wiki:
flowchart TD
A[Project Wiki Root] --> B[Architecture Docs]
A --> C[API Reference]
A --> D[Onboarding Guide]
B --> E[System Overview]
B --> F[Database Schema]
B --> G[Deployment Guide]
C --> H[REST Endpoints]
C --> I[Authentication]
C --> J[Webhooks]
D --> K[New Developer Setup]
D --> L[Code Standards]
D --> M[PR Review Process]
classDef root fill:#e1f5fe,stroke:#01579b
classDef section fill:#f3e5f5,stroke:#4a148c
classDef page fill:#e8f5e8,stroke:#1b5e20
class A root
class B,C,D section
class E,F,G,H,I,J,K,L,M page
# apiserver/plane/api/views/page.py
from rest_framework import status
from rest_framework.response import Response
from rest_framework.decorators import action
from plane.db.models import Page, PageFavorite
from plane.api.serializers import PageSerializer
class PageViewSet(ProjectBaseViewSet):
serializer_class = PageSerializer
model = Page
def get_queryset(self):
return Page.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
).select_related("owned_by").prefetch_related("page_labels")
def create(self, request, slug, project_id):
serializer = PageSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
owned_by=request.user,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"])
def lock(self, request, slug, project_id, pk):
"""Lock a page to prevent concurrent edits."""
page = self.get_object()
page.is_locked = True
page.save(update_fields=["is_locked"])
return Response({"is_locked": True})
@action(detail=True, methods=["post"])
def unlock(self, request, slug, project_id, pk):
"""Unlock a page for editing."""
page = self.get_object()
page.is_locked = False
page.save(update_fields=["is_locked"])
return Response({"is_locked": False})
@action(detail=True, methods=["post"])
def favorite(self, request, slug, project_id, pk):
"""Mark a page as favorite for the current user."""
PageFavorite.objects.get_or_create(
user=request.user,
page_id=pk,
project_id=project_id,
workspace_id=request.workspace.id,
)
return Response({"is_favorite": True})
@action(detail=True, methods=["delete"])
def unfavorite(self, request, slug, project_id, pk):
"""Remove a page from user favorites."""
PageFavorite.objects.filter(
user=request.user, page_id=pk
).delete()
return Response({"is_favorite": False})
@action(detail=True, methods=["post"])
def archive(self, request, slug, project_id, pk):
"""Soft-archive a page instead of deleting."""
page = self.get_object()
page.archived_at = timezone.now()
page.save(update_fields=["archived_at"])
return Response({"archived": True})Plane uses a block-based rich text editor (built on TipTap/ProseMirror) for page content:
// web/components/pages/page-editor.tsx
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Table from "@tiptap/extension-table";
import Image from "@tiptap/extension-image";
interface PageEditorProps {
initialContent: object;
onUpdate: (content: { json: object; html: string }) => void;
editable: boolean;
}
export const PageEditor: React.FC<PageEditorProps> = ({
initialContent,
onUpdate,
editable,
}) => {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4] },
codeBlock: { languageClassPrefix: "language-" },
}),
Placeholder.configure({
placeholder: "Start writing your page...",
}),
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: true }),
Image,
],
content: initialContent,
editable,
onUpdate: ({ editor }) => {
onUpdate({
json: editor.getJSON(),
html: editor.getHTML(),
});
},
});
return (
<div className="prose max-w-none">
<EditorContent editor={editor} />
</div>
);
};Pages auto-save as the user types, using debounced API calls:
// web/hooks/use-page-autosave.ts
import { useCallback, useRef } from "react";
import { debounce } from "lodash";
import { PageService } from "services/page.service";
export const usePageAutoSave = (
workspaceSlug: string,
projectId: string,
pageId: string
) => {
const pageService = new PageService();
const lastSaved = useRef<string>("");
const saveContent = useCallback(
debounce(
async (content: { json: object; html: string }) => {
const htmlString = content.html;
if (htmlString === lastSaved.current) return;
await pageService.updatePage(
workspaceSlug,
projectId,
pageId,
{
description: content.json,
description_html: htmlString,
}
);
lastSaved.current = htmlString;
},
1000 // Save at most once per second
),
[workspaceSlug, projectId, pageId]
);
return { saveContent };
};Pages can reference issues and vice versa, creating a connected knowledge graph:
// web/components/pages/issue-embed.tsx
interface IssueEmbedProps {
issueId: string;
projectId: string;
}
export const IssueEmbed: React.FC<IssueEmbedProps> = ({
issueId,
projectId,
}) => {
const { issue } = useIssueDetail(issueId);
if (!issue) return <div className="animate-pulse h-12 bg-gray-100" />;
return (
<a
href={`/projects/${projectId}/issues/${issueId}`}
className="flex items-center gap-2 p-2 border rounded-md hover:bg-gray-50"
>
<PriorityIcon priority={issue.priority} />
<span className="text-sm font-medium">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
<span className="text-sm text-gray-600 truncate">
{issue.name}
</span>
<StateIcon state={issue.state_detail} />
</a>
);
};sequenceDiagram
participant U as User
participant Ed as TipTap Editor
participant FE as Next.js
participant API as Django API
participant DB as PostgreSQL
U->>Ed: Type content
Ed->>Ed: Update editor state
Ed->>FE: onUpdate callback
FE->>FE: Debounce (1s)
FE->>API: PATCH /api/v1/.../pages/{id}/
API->>DB: UPDATE page (description, description_html)
API-->>FE: 200 OK
Note over U,Ed: Content saved automatically
U->>FE: Click "Lock Page"
FE->>API: POST /api/v1/.../pages/{id}/lock/
API->>DB: UPDATE page SET is_locked=true
API-->>FE: 200 OK
FE->>Ed: Set editable=false
Ed-->>U: Page becomes read-only
| Access Level | Who Can View | Who Can Edit |
|---|---|---|
| Public | All project members | Page owner + admins |
| Private | Page owner only | Page owner only |
| Locked | All with access | Nobody (until unlocked) |
| Archived | All with access | Nobody (must unarchive) |
- Pages are rich-text documents stored as JSON (editor format) and HTML (rendered format).
- The TipTap/ProseMirror editor supports headings, code blocks, task lists, tables, and images.
- Auto-save uses debounced API calls to persist changes without manual save actions.
- Pages can be locked, favorited, labeled, and archived.
- Issue embeds create cross-references between documentation and tracked work.
- Access control supports public, private, locked, and archived states.
- Issue linking: Chapter 3: Issue Tracking for the issue model that pages reference.
- AI writing: Chapter 5: AI Features for AI-assisted page content generation.
- API access: Chapter 7: API and Integrations for managing pages programmatically.
Generated by AI Codebase Knowledge Builder