Skip to content

Commit

Permalink
feat(live-preview): add functionality to subscribe to the save event …
Browse files Browse the repository at this point in the history
…of an entity (#301)

* feat(live-preview): add functionality to subscribe to the save event of an entity

* chore: add example for next 13 with SSR
  • Loading branch information
chrishelgert authored Sep 21, 2023
1 parent 8d765d6 commit 419818b
Show file tree
Hide file tree
Showing 33 changed files with 4,241 additions and 34 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,8 @@ That's it! You should now be able to use the Contentful Live Preview SDK with va

#### Integration with Next.js

You can find an example for the NextJS Pages Router implementation in the [examples/nextjs-graphql](./examples/nextjs-graphql/) folder. If you are using the app router you can look at this [example](./examples/nextjs-13-app-router-graphql/) instead.
You can find an example for the NextJS Pages Router implementation in the [examples/nextjs-graphql](./examples/nextjs-graphql/) folder.
If you are using the app router you can look at this [example](./examples/nextjs-13-app-router-graphql/) or for only serverside rendering this [example](./examples/next-13-app-router-ssr/) instead.

To use the Contentful Live Preview SDK with [Next.js](https://nextjs.org), you can either use one of the Contentful starter templates, or do the following steps to add it to an existing project.

Expand Down
8 changes: 8 additions & 0 deletions examples/next-13-app-router-ssr/.env.local.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# This is the Space ID from your Contentful space.
CONTENTFUL_SPACE_ID=
# This is the Content Delivery API - access token, which is used for fetching published data from your Contentful space.
CONTENTFUL_ACCESS_TOKEN=
# This is the Content Preview API - access token, which is used for fetching draft data from your Contentful space.
CONTENTFUL_PREVIEW_ACCESS_TOKEN=
# This can be any value you want. It must be URL friendly as it will be send as a query parameter to enable draft mode.
CONTENTFUL_PREVIEW_SECRET=
3 changes: 3 additions & 0 deletions examples/next-13-app-router-ssr/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
40 changes: 40 additions & 0 deletions examples/next-13-app-router-ssr/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# next.js
/.next/
/out/

# production
/build
/public/live-preview.mjs
/public/live-preview.umd.js

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

.next
.env
43 changes: 43 additions & 0 deletions examples/next-13-app-router-ssr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Next.js GraphQL Contentful live preview SDK example

This is an example project that demonstrates how to use the `@contentful/live-preview` SDK with a Next.js application that uses the GraphQL API. The live preview SDK will reload the page after **related** content changes and enables the inspector mode for your Contentful space.
It's important that you use the CPA (Content Preview API) when using this functionality.

## 1. Installation

Install the dependencies:

```bash
npm install
```

## 2. Environment variables

To run this project, you will need to add the following environment variables to your `.env.local` file:

- `CONTENTFUL_SPACE_ID`: This is the Space ID from your Contentful space.
- `CONTENTFUL_ACCESS_TOKEN`: This is the Content Delivery API - access token, which is used for fetching **published** data from your Contentful space.
- `CONTENTFUL_PREVIEW_ACCESS_TOKEN`: This is the Content Preview API - access token, which is used for fetching **draft** data from your Contentful space.
- `CONTENTFUL_PREVIEW_SECRET`: This can be any value you want. It must be URL friendly as it will be send as a query parameter to enable draft mode.

## 3. Setting up the content model

You will need to set up a content model within your Contentful space. For this project, we need a `Post` content type with the following fields:

- `slug`
- `title`
- `description`

Once you've set up the `Post` content model, you can populate it with some example entries.

## 4. Setting up Content preview URL

In order to enable the live preview feature in your local development environment, you need to set up the Content preview URL in your Contentful space.

`http://localhost:3000/api/draft?secret=<CONTENTFUL_PREVIEW_SECRET>&slug={entry.fields.slug}`

Replace `<CONTENTFUL_PREVIEW_SECRET>` with its respective value in `.env.local`.

## 5. Running the project locally

To run the project locally, you can use the `npm run dev` command. You can now use the live preview feature.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { draftMode } from 'next/headers';

export async function GET(request: Request) {
draftMode().disable();
return new Response('Draft mode is disabled');
}
45 changes: 45 additions & 0 deletions examples/next-13-app-router-ssr/app/api/draft/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// route handler with secret and slug
import { getPreviewPostBySlug } from '@/lib/api-graphql';
import { cookies, draftMode } from 'next/headers';
import { redirect } from 'next/navigation';

export async function GET(request: Request) {
// Parse query string parameters
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');

// This secret should only be known to this route handler and the CMS
if (secret !== process.env.CONTENTFUL_PREVIEW_SECRET || !slug) {
return new Response('Invalid token', { status: 401 });
}

// Fetch the headless CMS to check if the provided `slug` exists
// getPostBySlug would implement the required fetching logic to the headless CMS
const post = await getPreviewPostBySlug(slug);

// If the slug doesn't exist prevent draft mode from being enabled
if (!post) {
return new Response('Invalid slug', { status: 401 });
}

// Enable Draft Mode by setting the cookie
draftMode().enable();

// Override cookie header for draft mode for usage in live-preview
// https://github.com/vercel/next.js/issues/49927
const cookieStore = cookies();
const cookie = cookieStore.get('__prerender_bypass')!;
cookies().set({
name: '__prerender_bypass',
value: cookie?.value,
httpOnly: true,
path: '/',
secure: true,
sameSite: 'none',
});

// Redirect to the path from the fetched post
// We don't redirect to searchParams.slug as that might lead to open redirect vulnerabilities
redirect(`/posts/${post.slug}`);
}
27 changes: 27 additions & 0 deletions examples/next-13-app-router-ssr/app/components/post-layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Post } from '../../lib/api-graphql';
import { ContentfulLivePreview } from '@contentful/live-preview';

export default function PostLayout({ post }: { post: Post }) {
return (
<>
<h1
{...ContentfulLivePreview.getProps({
entryId: post.sys.id,
fieldId: 'title',
locale: 'en-US',
})}
>
{post.title}
</h1>
<p
{...ContentfulLivePreview.getProps({
entryId: post.sys.id,
fieldId: 'description',
locale: 'en-US',
})}
>
{post.description}
</p>
</>
);
}
Binary file added examples/next-13-app-router-ssr/app/favicon.ico
Binary file not shown.
24 changes: 24 additions & 0 deletions examples/next-13-app-router-ssr/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { draftMode } from 'next/headers';
import Script from 'next/script';

import '@contentful/live-preview/style.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
title: 'Next app with app router and reload entry change',
description: 'Generated by create next app',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
const { isEnabled } = draftMode();

return (
<html lang="en">
<body className={inter.className}>{children}</body>
{isEnabled && <Script src="/live-preview.mjs" />}
</html>
);
}
32 changes: 32 additions & 0 deletions examples/next-13-app-router-ssr/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { getAllPostsForHome } from '@/lib/api-graphql';
import { Metadata } from 'next';
import { draftMode } from 'next/headers';
import Link from 'next/link';

export const metadata: Metadata = {
title: 'Contentful live preview example with Next.js and GraphQL',
};

export default async function Home() {
const { isEnabled } = draftMode();

const posts = await getAllPostsForHome(isEnabled);

if (!posts || posts.length === 0) {
return (
<main>
<p>No posts found.</p>
</main>
);
}

return (
<>
{posts.map((post) => (
<Link href={`/posts/${post.slug}`} key={post.sys.id}>
{post.title}
</Link>
))}
</>
);
}
24 changes: 24 additions & 0 deletions examples/next-13-app-router-ssr/app/posts/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { draftMode } from 'next/headers';
import { getAllPostsWithSlug, getPost } from '../../../lib/api-graphql';

import PostLayout from '../../components/post-layout';

export default async function Post({ params }: { params: { slug: string } }) {
const { isEnabled } = draftMode();

const { post } = await getPost(params.slug, isEnabled);

if (!post) {
return <h1>Post "{params.slug}" not found</h1>;
}

return <PostLayout post={post} />;
}

export async function generateStaticParams() {
const posts = await getAllPostsWithSlug();

return posts?.map((post) => ({
slug: post.slug,
}));
}
121 changes: 121 additions & 0 deletions examples/next-13-app-router-ssr/lib/api-graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
interface Sys {
id: string;
}

export interface Post {
__typename: string;
sys: Sys;
slug: string;
title: string;
description: string;
}

interface PostCollection {
items: Post[];
}

interface FetchResponse {
data?: {
postCollection?: PostCollection;
};
}

const POST_GRAPHQL_FIELDS = `
__typename
sys {
id
}
slug
title
description
`;

async function fetchGraphQL(query: string, draftMode = false): Promise<FetchResponse> {
return fetch(
`https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${
draftMode
? process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN
: process.env.CONTENTFUL_ACCESS_TOKEN
}`,
},
body: JSON.stringify({ query }),
}
).then((response) => response.json());
}

function extractPost(fetchResponse: FetchResponse): Post | undefined {
return fetchResponse?.data?.postCollection?.items?.[0];
}

function extractPostEntries(fetchResponse: FetchResponse): Post[] | undefined {
return fetchResponse?.data?.postCollection?.items;
}

export async function getPreviewPostBySlug(slug: string): Promise<Post | undefined> {
const entry = await fetchGraphQL(
`query {
postCollection(where: { slug: "${slug}" }, preview: true, limit: 1) {
items {
${POST_GRAPHQL_FIELDS}
}
}
}`,
true
);
return extractPost(entry);
}

export async function getAllPostsWithSlug(): Promise<Post[] | undefined> {
const entries = await fetchGraphQL(
`query {
postCollection(where: { slug_exists: true }) {
items {
${POST_GRAPHQL_FIELDS}
}
}
}`
);
return extractPostEntries(entries);
}

export async function getAllPostsForHome(draftMode: boolean): Promise<Post[] | undefined> {
const entries = await fetchGraphQL(
`query {
postCollection(preview: ${draftMode ? 'true' : 'false'}) {
items {
${POST_GRAPHQL_FIELDS}
}
}
}`,
draftMode
);

console.log(entries);
return extractPostEntries(entries);
}

export async function getPost(
slug: string,
draftMode: boolean
): Promise<{ post: Post | undefined }> {
const entry = await fetchGraphQL(
`query {
postCollection(where: { slug: "${slug}" }, preview: ${
draftMode ? 'true' : 'false'
}, limit: 1) {
items {
${POST_GRAPHQL_FIELDS}
}
}
}`,
draftMode
);
return {
post: extractPost(entry),
};
}
Loading

0 comments on commit 419818b

Please sign in to comment.