
Rich Text Publishing with Portable Text and ShadCN UI
Stewart Moreland
This guide explains how administrators can publish rich text blog posts with images and media in a Next.js app using Portable Text (structured JSON content) and a custom editor built with ShadCN UI components. We cover the overall architecture, data model, admin authoring interface, media handling pipeline, front-end rendering of content, ways to extend the editor, and known limitations. The implementation uses Next.js (App Router) and is backed by AWS Amplify Gen2 (AWS AppSync GraphQL with DynamoDB for data storage).
Key Outcomes
- Structured content with Portable Text (JSON): Blog post content is stored as structured JSON (Portable Text blocks) rather than raw HTML. Portable Text is a JSON-based rich text specification that represents content as an array of block objects with styles, types, and mark definitions sanity.io sanity.io. This approach makes content more future-proof and safer to render across different interfaces.
- Rich text editor with ShadCN UI: Admins compose posts in a WYSIWYG editor built on Tiptap (a ProseMirror-based rich text toolkit) and ShadCN UI components for the toolbar, dialogs, and form controls. The editor supports formatting (headings, bold, lists, etc.), links, and image insertion, all with a modern React/Tailwind UI.
- Media uploads via cloud storage: Images and other media are uploaded to a cloud storage bucket (e.g. Amazon S3). Custom serverless functions handle upload processing, image optimization, and provide a media library browser for selecting previously uploaded images. The editor integrates with this media pipeline to embed images in content.
- Portable Text rendering on the front-end: The public blog pages render the stored JSON content using a Portable Text React component, converting the structured blocks into proper HTML elements. This ensures the blog displays rich text and media as intended, and we can easily adjust the rendering (for example, customizing how images or code blocks appear).
- Seamless integration with Amplify & AppSync: The solution is built for AWS Amplify Hosting (Gen 2) with an AppSync GraphQL API. Blog posts are fetched and mutated via GraphQL. The content JSON is stored in DynamoDB (via AppSync) using the AWSJSON scalar type (which allows any JSON structure) docs.aws.amazon.com.
Architecture Overview
The blogging system consists of an admin portal for content authors and the public blog for readers. Key points of the architecture include:
- Next.js App structure: The admin interface lives under the
/adminroute and is protected by role-based access (only users with an editor/admin role can access it). The public blog pages live under/blog/[slug]routes. Both are part of the same Next.js application (using the App Router for routing and React server components where appropriate). - Admin post creation/editing: Authorized users can create and edit blog posts via a form (
BlogPostForm). This form includes fields like title, slug, excerpt, publish status, cover image, categories, and the main content editor. The content editor is a rich text editor component (built with Tiptap + ShadCN UI) where authors write the post body. All form state is managed on the client (with possible use of React Hook Form for integration). - Content format and storage: Each post is stored with a format identifier and content fields in a DynamoDB table (exposed via AppSync GraphQL). In our case we use
content_format = 'portable-text'for rich text posts. The post’s content is saved as JSON in acontentfield (Portable Text block array). For example, in the GraphQL schema thecontentfield might be of typeAWSJSONto accommodate arbitrary JSON structure docs.aws.amazon.com. Other fields (title, slug, author, etc.) are stored as normal strings or IDs. This structured content approach allows mixing text, images, and other elements safely. - Publishing flow: When an admin saves or publishes a post, the app calls an AppSync GraphQL mutation to upsert the record in DynamoDB. If the post is marked “published”, a timestamp is set. After a successful save/publish, the system triggers a revalidation of relevant Next.js pages (using Incremental Static Regeneration) so that public pages show the updated content on the next visit.
- Public blog rendering: The blog page (
app/blog/[slug]/page.tsx) fetches the post data (via a GraphQL query by slug). Based on thecontent_format, it chooses the appropriate renderer. For Portable Text, it uses a customPortableTextRendererReact component (wrapping the@portabletext/reactlibrary) to render the JSON into HTML elements. This separation means the front-end does not rely ondangerouslySetInnerHTML; instead it iterates over the structured content and renders images, headings, paragraphs, etc. with proper components and styling.
Key files in the project structure:
- Admin post list page:
app/admin/blog/page.tsx(lists existing posts, with links to edit or create new) - Admin create page:
app/admin/blog/new/page.tsx(form for a new post) - Admin edit page:
app/admin/blog/[id]/edit-client.tsx(form for editing an existing post) - Blog post form component:
components/admin/blog-post-form.tsx(implements the form inputs and content editor integration) - Rich text editor component:
components/admin/rich-text-editor.tsx(the WYSIWYG editor built with Tiptap/ShadCN UI) - Media management components:
components/admin/media-selector.tsx,components/admin/enhanced-media-catalog.tsx,components/admin/image-uploader.tsx(for image upload & selection dialogs) - Storage utility library:
lib/storage.ts(abstraction for interacting with storage bucket and signing upload URLs or requests) - Content serialization and validation:
lib/content-api.ts(e.g., functions to validate Portable Text structure) - Public blog page and renderer:
app/blog/[slug]/page.tsx(fetches post and renders), andapp/blog/[slug]/blog-content.tsx(contains the logic to render Portable Text content via the renderer component)
Data Model and Content Format
We use a single database table/GraphQL type for all blog posts, with fields to accommodate the rich text content. Key fields in the Post model include:
id– unique identifier (primary key, e.g. UUID).title– text title of the post.slug– URL slug.excerpt– short summary.cover_image– URL or storage key for the cover image.published– boolean flag or status (and possiblypublished_attimestamp).content_format– a string flag indicating the format ofcontent. For our use-case this will be'portable-text'for all rich text posts (we include this field for extensibility, but currently only one format is used).content– a JSON field storing the Portable Text block array (in DynamoDB via AppSync, this is stored as anAWSJSONscalar, which accepts any valid JSON structure docs.aws.amazon.com).
Why Portable Text (JSON)? Portable Text is an open specification for rich text content that stores text along with its formatting and embedded objects in a structured JSON format sanity.io. Instead of storing HTML (which can be error-prone or a security risk to render directly), we store an array of content blocks. Each block can have a type (paragraph, heading, image, list, etc.), textual spans with marks (bold, italic, links), and child elements. This structure makes it easier to transform or render content in different contexts (web, email, native apps) without brittle HTML parsing. It’s essentially a more portable and robust representation of rich text sanity.io. For example, a simple Portable Text document might look like:
[{"_type": "block","style": "normal","children": [{ "_type": "span", "text": "Hello ", "marks": [] },{ "_type": "span", "text": "World", "marks": ["strong"] }]},{"_type": "image","url": "https://example.com/image.jpg","alt": "An example image"}]
In the above JSON, the first element is a text block with a bold text span (“World”), and the second element is an image block with a URL and alt text. By using JSON, we avoid directly storing HTML, and we can validate the structure (for instance, ensuring that only allowed tags or attributes are present).
The AppSync GraphQL schema uses AWSJSON for the content field to store this JSON. The AWSJSON scalar essentially means the data is stored as a JSON string in DynamoDB but parsed to native JSON in resolvers docs.aws.amazon.com. This allows us to send/receive the content as a JSON object in our GraphQL queries and mutations.
Admin Authoring Experience
The admin UI for blog authors provides a form-driven interface to compose posts, including rich text editing and media management.
Blog Post Form UI
The main component driving post creation/editing is the BlogPostForm (found in components/admin/blog-post-form.tsx). This form presents:
- Text fields: Inputs for title, slug (which can be auto-generated from title), and excerpt. These are basic text or textarea fields.
- Cover Image: An image uploader/selector for the post’s cover image. The form shows a preview of the selected image and allows uploading a new image or choosing from the media library (more on the media pipeline below).
- Categories/Tags: (If applicable) a multi-select or checkbox group for categorizing the post.
- Publish toggle: A switch or checkbox to mark the post as "Published". If published, the post will be visible on the public blog (once deployed) and a publication timestamp is recorded.
- Content editor: The rich text editor component for the post body. This is the most complex part of the form, described in detail below.
All these fields are managed together. When the admin submits the form, a server action or handler will validate the input (for example, ensuring slug uniqueness, validating that content JSON isn’t empty), then call a GraphQL mutation to save the post. On success, it might redirect the user back to the list or to the newly created post’s edit screen.
Rich Text Editor (Portable Text WYSIWYG)
The content editor in the admin form is a custom rich text editor built with the Tiptap library and UI components from ShadCN UI. This editor provides a user-friendly way to format text and embed media, while ultimately producing Portable Text JSON under the hood. Key aspects of the editor:
- It is implemented as a React component (e.g.,
RichTextEditor) using Tiptap’suseEditorhook. Tiptap is based on ProseMirror, which uses a JSON document model for rich text. - We include the StarterKit extension from Tiptap, which brings common formatting nodes and marks (paragraphs, headings, bold, italic, bullet list, ordered list, blockquote, code block, etc.) so we don’t have to configure each basic feature from scratch.
- The editor is rendered in the form as a stylized contenteditable area (
<EditorContent />from Tiptap) along with a custom toolbar of buttons for formatting actions. We use ShadCN UI’s pre-built components like Button, Toggle, Dialog, and icons from Lucide to create this toolbar.
Toolbar features: The editor’s toolbar provides controls for all the major rich text functions:
- Text styles: Bold, Italic (and we could easily add underline, strikethrough, etc. with additional extensions).
- Headings: H1, H2, H3 block styles for section headings.
- Lists: Bulleted list and Numbered list toggles.
- Blocks: Blockquote and Code block formatting.
- Insert elements: Horizontal rule (divider line) and Link insertion (hyperlinking selected text).
- Image insertion: a button that opens an image selection dialog (allowing upload or choosing an existing image) and then inserts an image block at the cursor position.
- Undo/Redo: to revert or reapply changes (integrated with Tiptap’s history extension, which StarterKit includes).
Each toolbar control is wired to a Tiptap command. For example, clicking the Bold button will execute editor.chain().focus().toggleBold().run(), which toggles the strong mark on the selected text. The buttons also reflect the editor’s current state: e.g., the Bold button is highlighted (active) if the cursor is inside bold text. We achieve this using ShadCN UI’s Toggle component, setting its pressed state based on editor.isActive('bold'). This way, the button appears pressed when bold is active shadcn.io. Similarly, heading buttons check for editor.isActive('heading', { level: 1 }) and so on for H1, H2, H3 shadcn.io.
Implementation steps: To integrate the Portable Text editor into your app:
- Install dependencies. Add Tiptap and its extensions (e.g. StarterKit, Link, Placeholder, Image) to your project, as well as Lucide icons if not already included via ShadCN UI.
- Create the editor component. In a client-side React component (e.g.
RichTextEditor.tsx), useuseEditorfrom Tiptap to initialize an editor instance. IncludeStarterKitinextensionsand set anonUpdatecallback to handle content changes (e.g. update form state witheditor.getJSON()output tiptap.dev). - Build the toolbar UI. Use ShadCN UI components to create buttons/toggles for each formatting action. For example, a Toggle for bold that calls
editor.chain().focus().toggleBold().run()and ispressedwheneditor.isActive('bold')shadcn.io. Repeat for italic, headings, lists, etc., grouping and styling buttons as needed (you can use icons from Lucide for each). - Handle special actions. Implement dialogs for actions like inserting links or images. For a link, open a small input dialog and on submit call
editor.chain().focus().extendMarkRange('link').setLink({ href }).run(). For images, open the media selector dialog and upon choosing an image, calleditor.chain().focus().setImage({ src: imageUrl, alt: '...' }).run()(after installing an Image extension). - Render the editor. Include the
<EditorContent editor={editor} />component in your JSX to render the editable area. Apply Tailwind classes (e.g. using the Typography plugin) to style content inside the editor, and a placeholder text for when it's empty.
Once this component is ready, integrate it into the BlogPostForm and pass the current content value and onChange handler so that the form captures the JSON output.
Below is a simplified example of how the toolbar is implemented in the React component:
const editor = useEditor({extensions: [StarterKit],content: initialContent, // could be an HTML string or JSONonUpdate: ({ editor }) => {// Sync content to form state on each changeconst json = editor.getJSON();onChange(json);},});...return (<div className="border rounded-lg overflow-hidden"><div className="border-b p-2 flex flex-wrap items-center gap-2"><Togglesize="sm"pressed={editor?.isActive('bold')}onPressedChange={() => editor?.chain().focus().toggleBold().run()}disabled={!editor?.can().chain().focus().toggleBold().run()}><Bold className="h-4 w-4" /></Toggle><Togglesize="sm"pressed={editor?.isActive('italic')}onPressedChange={() => editor?.chain().focus().toggleItalic().run()}><Italic className="h-4 w-4" /></Toggle>{/* ...other formatting buttons like headings, lists... */}<Buttonvariant="ghost" size="sm"onClick={() => editor?.chain().focus().undo().run()}disabled={!editor?.can().chain().focus().undo().run()}><Undo className="h-4 w-4" /></Button><Buttonvariant="ghost" size="sm"onClick={() => editor?.chain().focus().redo().run()}disabled={!editor?.can().chain().focus().redo().run()}><Redo className="h-4 w-4" /></Button></div><EditorContent editor={editor} placeholder="Start writing your post..." className="prose max-w-none p-4" /></div>);
In the code above, we initialize Tiptap with StarterKit and set an onUpdate callback to get the editor’s JSON content whenever it changes (using editor.getJSON() – Tiptap can output content as JSON or HTML tiptap.dev tiptap.dev). We then render a series of Toggle buttons for bold, italic, etc., using Lucide icons. The EditorContent component is where the editable area renders, with some Tailwind CSS classes (using the Typography plugin to style prose, etc.).
Handling content state: We typically lift the editor content state up to the form. The BlogPostForm might hold a state variable for content (the JSON), and pass an onChange handler into the RichTextEditor component as shown. When the form is submitted, this JSON content is included in the data and sent to the backend via GraphQL mutation. Alternatively, if using react-hook-form, the RichTextEditor can be integrated as a controlled component by registering a custom field that updates on editor changes.
Link and image dialogs: Some toolbar actions require additional UI, such as inserting a hyperlink (needing a URL input) or choosing an image. For these, we use ShadCN UI’s Dialog component:
- Clicking the Link button could open a small modal dialog prompting for a URL, and then the editor command
setLinkis executed with that href on confirmation. - Clicking the Image button opens a larger dialog – implemented in the
MediaSelectorcomponent – which contains two tabs: one for uploading a new image, and one for browsing the existing media library.
Media Management (Images & Media Pipeline)
In a rich blog, authors need to include images or other media in their posts. Our admin interface provides an integrated media management system powered by cloud storage and serverless functions:
- Storage bucket: We use an Amazon S3 bucket (or similar) to store images. Authors can upload images through the admin UI. For example, the
ImageUploadercomponent might use a pre-signed URL or an API call (to an AWS Lambda function) to upload the file to the bucket. The image files could be organized in folders (e.g., ablog/prefix, or separate folders for covers vs in-post images). - Edge function / Lambda for upload: Instead of directly exposing S3, the app could call a secure endpoint (like an AWS Lambda) to handle the upload. In our case, the admin UI calls an API route or function (with the user’s auth token) to handle the upload. This function verifies the user’s permissions and either generates a pre-signed upload URL or streams the upload to the bucket. It may also perform image processing (e.g., creating thumbnails, optimizing image format) or simply store the original and rely on on-the-fly resizing later.
- Media library browsing: The
MediaSelectorcomponent opens a dialog with two Tabs (ShadCN UITabscomponent): “Upload” and “Library”. The Upload tab contains theImageUploader(file picker and upload button). The Library tab displays a grid of previously uploaded images – this is implemented by theEnhancedMediaCatalogcomponent, which likely calls a backend function to list files in the S3 bucket (for example, an AWS Lambda that lists objects with a certain prefix, returning metadata and URLs). The images are shown as thumbnails; the author can search or filter (if implemented), and select an image. - Selecting & inserting images: When the author selects an image from the library (or after a new upload is completed), the media dialog closes and returns the image’s public URL (or a reference ID). The editor then inserts an image node into the document at the current cursor location. In Tiptap, this could be done via an
Imageextension (if using Tiptap’s built-in image extension) or by directly manipulating the JSON. For example, if using a Tiptap image extension, one would calleditor.chain().focus().setImage({ src: imageUrl, alt: altText }).run(). In the Portable Text JSON, we represent this as an{ "_type": "image", "url": "...", "alt": "..." }block, as shown earlier. - Image rendering: We store just the image URL (which could be an S3 signed URL or a CloudFront URL). On the public site, the Portable Text renderer will create an
<img>tag with that URL, or possibly use Next.js’<Image>component if further optimization is desired. (If using Next<Image>, ensure to configure the domain innext.config.jsfor remote images.)
All media operations are secured: only authorized users can upload or delete images. The Amplify Auth context (or AppSync authentication) is used to ensure the functions are called with proper privileges. For example, an AppSync mutation for image management could be restricted to admins, or if using a REST API route, it checks the user’s JWT for admin role.
Rendering on the Public Blog
Once a post is written and published, it needs to be displayed to end users on the blog. Our blog pages use the stored content to render a rich reading experience:
- The Next.js page component for a blog post (e.g.
app/blog/[slug]/page.tsx) will fetch the post data, typically using a server-side GraphQL query via AppSync to get the post by slug. This returns the title, author, date, and importantly thecontentfield which is a Portable Text JSON array. - We then use a component called
BlogContent(or directly in the page) to render the content. If thecontent_formatindicates Portable Text, we use our PortableTextRenderer component to output the content.
PortableTextRenderer Component
We utilize the @portabletext/react package to help convert Portable Text JSON into React elements. This package is typically used with content from Sanity.io, but since Portable Text is an open spec, we can use it for our content structure as well. We define a set of React component mappings for the various block types and marks in our content. For example:
import { PortableText, PortableTextReactComponents } from '@portabletext/react';const components: PortableTextReactComponents = {types: {image: ({ value }) => (<img src={value.url} alt={value.alt || ''} className="my-4 rounded-md" />),code: ({ value }) => (<pre className="bg-muted p-4 rounded"><code>{value.code}</code></pre>),// ... you can add custom types like video, tweet embeds, etc.},marks: {link: ({ children, value }) => (<a href={value.href} target="_blank" rel="noopener noreferrer" className="underline text-blue-600">{children}</a>),strong: ({ children }) => <strong>{children}</strong>,em: ({ children }) => <em>{children}</em>,// ... marks for underline, highlight could be added},block: {h1: ({ children }) => <h1 className="text-3xl font-bold my-6">{children}</h1>,h2: ({ children }) => <h2 className="text-2xl font-semibold my-4">{children}</h2>,h3: ({ children }) => <h3 className="text-xl font-semibold my-3">{children}</h3>,blockquote: ({ children }) => <blockquote className="border-l-4 pl-4 italic text-gray-600 my-4">{children}</blockquote>,normal: ({ children }) => <p className="my-2">{children}</p>,// Note: "normal" is default for paragraph text}};export function PortableTextRenderer({ content }) {return (<div className="prose prose-lg max-w-none"><PortableText value={content} components={components} /></div>);}
In the above code, we specify how to render:
- Block types: e.g., if a block has
_type: "image", we render an<img>with the given URL and alt text, adding some Tailwind classes for styling. For_type: "code"(if we choose to include code blocks in Portable Text), we render a<pre><code>with styling. - Marks: e.g., for
marks: { link: ... }we render an anchor tag with target_blank. Bold and italic are straightforward. If we had a custom mark (like highlight), we could map it to a span with a background color. - Block styles: Portable Text uses the concept of block style (like "normal" for a paragraph, "h1"..."h6" for headings, etc.). We map these to actual HTML elements with appropriate CSS classes.
Using @portabletext/react simplifies a lot of the work – it will traverse the JSON and call our custom renderers for each piece. The result is we get safe, structured DOM output. We can wrap it in a <div className="prose"> to apply typography styles globally.
Implementation note
In our current implementation, we are finalizing Portable Text support. Initially, the editor might store content as HTML (via editor.getHTML()), and the public page could inject that HTML. Our goal is to store and render true Portable Text JSON. The code above represents the intended approach.
If your content is still HTML during a transition, temporarily use a sanitizer with dangerouslySetInnerHTML or convert HTML to Portable Text using a parser. Adopting structured JSON end-to-end is the recommended path for maintainability and consistency.
Using the approach above, adding new content types is straightforward – we just define a new _type in the JSON and provide a React component for it in the renderer. This could be used for embedding tweets, videos, etc., without risking arbitrary HTML.
(For reference, the @portabletext/react library is commonly used in Sanity projects to render Portable Text content sanity.io.)
Extending the Editor and Toolbar
One advantage of using Tiptap and a custom toolbar is that we can extend the editor with new features as needed. Here are some ideas and how we could implement them:
- Additional text styles: To support underline, strikethrough, or highlight, we can include the corresponding Tiptap extensions (for example, there are built-in extensions or community extensions for these marks). Then add buttons in the toolbar that call
toggleUnderline(),toggleStrike(), etc. The pattern follows what we did for bold/italic – include the extension inuseEditorand add a Toggle button that checkseditor.isActive('underline')to set its state. - Text alignment or indentation: If needed, we could add extensions for text alignment (left/center/right) or indent/outdent for lists. Tiptap has extensions like
TextAlignwhich we can configure with allowed alignments. We would then add, say, a dropdown or a set of buttons for the alignment options that triggereditor.setTextAlign('center')and so on. - Custom Image captions or styles: We currently insert a basic image. If we want to support captions or alignment for images, we might extend the image node schema. For example, include a caption text in the image node’s attributes and provide a UI in the editor to edit that caption (perhaps by selecting the image and showing a caption input). This requires a bit more custom extension work in ProseMirror schema to allow nested content (caption as a child of image).
- Syntax-highlighted code blocks: Tiptap’s StarterKit provides a basic code block, but not syntax highlighting. We could integrate a library like Lowlight with Tiptap’s CodeBlock extension to highlight code. Another approach is to store code blocks as a custom Portable Text type (as shown in the renderer) and do highlighting at render time. For a richer editing experience, one could implement a dropdown to select the programming language for the code block.
- Embed content (videos, tweets, etc.): We can create custom Tiptap node extensions for embeddable content. For instance, a
Tweetnode that stores a tweet ID and renders an embedded tweet, or aVideonode that stores a YouTube URL and renders an iframe. The toolbar would get a new button (perhaps under a dropdown like “Embed”) which opens a ShadCN UI Dialog prompting for the embed link/ID. On confirm, the extension’s insert command is used to add that node to the doc. The Portable Text would then have a block like{ _type: 'video', embedUrl: 'https://...'}, and our renderer needs to handle that. - Keyboard shortcuts: Since Tiptap is built on ProseMirror, many standard shortcuts are already supported (e.g. Ctrl/Cmd+B for bold, Ctrl+I for italic, lists, etc. via the StarterKit). We can add more or customize them via the
addKeyboardShortcuts()in extensions. For example, adding a shortcut for underline or a custom style. - Placeholder text: We use the Tiptap Placeholder extension (often included by default or easy to add) to show “Start writing…” when the editor is empty. This can be configured (we set a custom placeholder in the code snippet above). For multiple editors or different contexts, ensure each has an appropriate placeholder to guide the user.
The general process to extend the editor is:
- Include or create a Tiptap Extension for the new feature (this could be as simple as adding an existing extension to the extensions array, or writing a new one for completely custom behavior).
- Add a UI control in the toolbar or elsewhere in the form. This could be a new Button/Toggle or even a custom React component (for complex inputs). Wire this control to call the editor commands (e.g.,
editor.chain().focus().toggleSomething().run()). - Handle the rendering of the new content type in the PortableTextRenderer (if it affects the output). If it’s a new mark or block type that will appear in the JSON, add it to the
componentsmapping so it displays correctly on the blog.
Because our editor is headless (the core logic is separate from UI), we have full control over how we surface new features in the UI using ShadCN components. The ShadCN design system ensures that any new dialogs, buttons, or inputs we add will have a consistent look and feel.
Access Control and Roles
It’s crucial that only authorized users can access the admin interface and perform content changes. We implement security at multiple levels:
- Route protection: The Next.js
/adminroutes are wrapped with an authentication check. For instance, inapp/admin/layout.tsx(or a middleware), we might fetch the current user and verify their role includes “admin” or “editor”. If not, we redirect them to a login page or show a 404. This ensures that the admin UI is not even rendered for unauthorized users. - Action authorization: All API calls or GraphQL mutations for creating/updating posts are secured. With AWS AppSync, we can use Cognito or API Key authentication combined with fine-grained AppSync resolvers. For example, we could attach an IAM policy to allow only certain groups to perform mutations, or use AppSync’s @auth directive in the GraphQL schema to restrict operations by user group/role.
- Storage security: The image upload and listing functions (whether they are AppSync mutations or REST endpoints) also check the user’s credentials. If using Amplify, the Storage category can be configured such that only certain users can write to the protected bucket paths. If using a Lambda, the Lambda should verify the JWT token it receives from the client before proceeding with the file operation.
- Roles definition: In our app, we have roles like “admin”, “editor”, and “reader”. Admins/editors can create and edit content; readers are normal site visitors with no special privileges. These roles might be managed via an Amplify Auth (Cognito User Pool) group or a custom user management system. Regardless, the role information is passed along with requests (e.g., in the JWT) and used in the checks described.
By combining front-end route guarding and backend authorization, we ensure that even if someone discovers the admin URL, they cannot do anything without proper auth, and any attempted unauthorized API calls will fail.
Publishing Workflow and Cache Invalidation
When a post is ready to go live, the author will toggle “Publish” in the form and save. Publishing involves a couple of important steps:
- Setting publish status: The
publishedflag is set to true, and apublished_attimestamp is recorded (often set to current time). Unpublished drafts remain withpublished= false and may not be queryable on the public site depending on how the queries are written. - Revalidating pages: Our blog uses Next.js Incremental Static Regeneration (ISR) to serve pages. This means once a page is generated, it might be cached for a certain revalidate period. To ensure a newly published or updated post is immediately visible, we manually trigger a revalidation for relevant pages. In Next 13+, we can use the
revalidatePathorrevalidateTagutilities (for example, in a server action or API route) after a successful save. Specifically, we would revalidate the blog index page (listing posts) and the page for the updated slug. This causes Next.js to fetch fresh data on the next request to those paths. In Amplify’s hosting environment, these revalidations are supported similar to Vercel. - Feedback to author: After saving/publishing, the UI can provide feedback like “Post published successfully” and perhaps offer a direct link “View post” which goes to the public URL (which now should show the content).
Because we separate the concerns, the admin portal uses dynamic (mostly client-side) rendering for flexibility, whereas the public site can use static generation for performance. The revalidation marries these two by invalidating the cache when content changes.
(If not using ISR, one could also fully server-render the blog pages on each request or implement a caching layer. ISR is a good balance here to cache pages but update them when the CMS changes.)
Troubleshooting and Known Gaps
Despite the setup above, there are some areas to watch out for or that are in progress:
- Portable Text conversion: Ensure that the content saved from the editor is indeed in the expected JSON format. At the moment, our editor captures content as HTML on update (because
StarterKitoutputs HTML by default). We plan to switch this to capturing the JSON and possibly converting it to the Portable Text structure. If the content in the database is HTML currently, the PortableTextRenderer will not work directly. A temporary measure is to use a library or custom function to parse HTML into Portable Text or to adjust the renderer to accept raw HTML (e.g., by wrapping it in a single block). The ultimate goal is to have the database store proper block JSON. This may involve writing a conversion function or using a ProseMirror to Portable Text serializer. - Edge cases in rendering: The Portable Text React library will ignore unknown types or marks. If you see missing content, check that your JSON structure exactly matches what the renderer expects (e.g.,
_type: 'block'for paragraphs, etc.). Also, any custom block types need corresponding components. Test with a variety of content (multiple paragraphs, images, links, etc.) to ensure everything appears. - Image domain and optimization: If your image URLs are coming directly from S3 or another CDN, make sure to configure Next.js to recognize those domains (in
next.config.jsunderimages.domains) if you use the Next<Image>component. If you just use<img>tags, this is not an issue, but you then rely on the browser to load the full image. Ideally, use a combination of Next Image or a CloudFront distribution that auto-optimizes images. Amplify Hosting supports Next’s Image component, but you need to declare the domains. - ShadCN UI styling issues: If toolbar buttons or dialogs don’t look right, verify that the ShadCN UI styles (Tailwind CSS and Radix CSS) are properly configured. For example, toggles should show a pressed state (usually a different background) when active. If not, ensure the CSS from ShadCN UI is included and the class names haven’t been purged by accident.
- Collaborative editing or locking: Currently, the editor is single-user. If multiple admins try to edit the same post simultaneously, they might overwrite each other’s changes. AppSync does not automatically handle concurrency on the same item. In the future, consider adding a “locking” mechanism (e.g., mark a post as “being edited by X”) or merging changes carefully. Tiptap does have collaboration capabilities (using Y.js), but that’s an advanced setup.
- Validation: Since the content is complex, ensure you validate on the backend as well. For example, an AppSync resolver (or DynamoDB Streams trigger) could validate that the JSON structure of
contentfits the Portable Text schema and reject invalid input. This is extra safe if you ever expose an API for content creation beyond the admin UI.
By being mindful of these areas, you can avoid common pitfalls. The system is built to be robust, but as with any CMS, thorough testing with real-world content and media is important.
Quick Start for Authors (Using the Admin UI)
For completeness, here’s how an author would use the system to publish a blog post:
- Access the Admin Portal: Log in with an account that has the editor or admin role. Navigate to
/admin/blogin the application. You’ll see a list of posts (drafts and published). - Create a New Post: Click the “New Post” button. This opens the blog post form.
- Fill in the basics: Enter a title for the post. The slug may auto-fill (and can be edited). Write a short excerpt/summary.
- Cover Image: Click “Upload Cover Image” to select a cover picture. You can upload a new file or choose from the library of images. After selecting, you’ll see a preview of the cover image.
- Write Content: In the rich text editor area, start writing your post. Use the toolbar to format headings, emphasize text, create lists, etc. For example, make your section titles as H2 or H3, highlight key terms in bold, etc.
- Insert Images in content: To add an image inside the post, click the image button on the toolbar. In the dialog, upload a new image or choose one from your library. Once selected, it will be inserted at the cursor position in the editor. You might add alt text by editing the image block (depending on the editor’s capabilities, perhaps via a right-click or a side input).
- Links and other embeds: Select text and click the link icon to add a hyperlink (enter the URL when prompted). For any custom embeds (if supported, e.g., a YouTube video), use the respective toolbar option and follow the prompt.
- Publish: If you’re ready to go live, toggle the “Publish this post” option. If you just want to save a draft, leave it unchecked. Click the Save button.
- Revisions: After saving, you can continue editing if needed. If it was published, you can also unpublish (depending on if we built that feature – likely by toggling off publish and saving again).
- View on Site: Once published, go to the public blog (usually
/blog/[slug]). You should see your new post with all the formatting and images in place. If it doesn’t show up immediately, there might be a slight delay due to caching (or you may need to ensure the site revalidated as described above).
The workflow is designed to be similar to familiar blog CMS experiences (like WordPress or Sanity Studio), but all within our Next.js app.
Appendix: Component Responsibilities
To summarize, here are the key components/modules and their roles in this Portable Text blogging system:
- BlogPostForm (component): The primary form used in admin create/edit pages. It gathers all post fields (metadata and content) and handles form submission via actions or mutations.
- RichTextEditor (component): The portable text editor UI used inside the form for the content field. Built with Tiptap, this encapsulates the editor instance and toolbar logic.
- MediaSelector (component): A dialog for inserting media. It contains the Tabs for “Upload” vs “Library” and uses the ImageUploader and EnhancedMediaCatalog.
- ImageUploader (component): Handles uploading a new image file from the user’s computer to the storage (S3). It shows a file picker, upload progress, and returns the uploaded file URL.
- EnhancedMediaCatalog (component): Displays existing media items (thumbnails with possibly name/size). It may allow selecting an item, deleting, or organizing files. “Enhanced” indicates it might have search/filter or pagination of media.
- StorageManager (utility module): An abstraction for all storage operations. For example, it might have methods like
uploadImage(file),listImages(folder),getImageUrl(path, transformations). This centralizes how we talk to S3 or our Lambdas. It likely wraps AWS SDK calls or AppSync mutations for media. - BlogContent (component): Used on the public blog page, it takes a post record and decides how to render the content. In our case, it will call PortableTextRenderer for portable text content.
- PortableTextRenderer (component): As shown above, it defines the mapping for Portable Text to React elements. It’s a reusable renderer – you pass it the JSON content and it outputs a styled article.
- AppSync GraphQL API (backend): While not a component in code, it’s worth noting: the GraphQL schema and resolvers handle the data fetching/saving. For instance, a
createPostmutation will take the input (including the JSON content, likely passed as a string or JSON object), and store it in DynamoDB. AgetPost(slug)query will retrieve the item and return the JSON content (AppSync will serialize it as a JSON string in the JSON response, which our Next.js app then parses). - AWS Amplify configuration: Also not a front-end component, but Amplify ties together the hosting and API. Amplify CLI or console was used to set up the Auth (for roles) and the AppSync API + DynamoDB. The Amplify Hosting (Gen 2) is used to deploy the Next.js app with SSR/ISR support.
Each of these pieces works in concert to provide a smooth content authoring and publishing experience entirely within our application stack.
By following this guide, you can build a full-featured blog system that empowers content editors with rich text editing (backed by a structured JSON format) and delivers content to end users with reliable rendering and performance. tiptap.dev docs.aws.amazon.com