/compose-workshop-2023

Netlify Compose 2023 Workshop: Using core Netlify features to create your first composable project

Primary LanguageTypeScriptMIT LicenseMIT

Netlify Compose 2023 Workshop

Welcome to Compose! In this workshop, you will learn how to create your first composable website with Netlify.

What are we going to build?

In two and a half hours, we are going to build a Halloween-themed e-commerce bookstore. The frontend stack will be composed of React, TypeScript, Vite, and Tailwind, and served from Netlify's global high-performance edge network. It will fetch data sources from Contentstack and Storyblok using Netlify Connect. We will build a custom data connector using the Netlify SDK that reads data from an Amazon S3 bucket. Finally, the site will reach out to third-party services including Stripe and OpenAI using Netlify Functions. The future is composable!

An example of the finished product is available here: https://compose-workshop-2023.netlify.app

What are we going to learn?

In this workshop, you will learn how to:

  • Create your first site on Netlify
  • Trigger builds with Git and embrace a CI/CD workflow
  • Create Deploy Previews and collaborate using the Netlify Drawer
  • Manage environment variables securely in the Netlify CLI and Netlify UI
  • Stream API responses from OpenAI using Netlify Functions
  • Personalize user experiences with Netlify Edge Functions
  • Persist cache from API responses using fine-grain cache control
  • Fetch content from Contentstack and Storyblok using Netlify Connect
  • Build a custom connector using the Netlify SDK to pull data from Amazon S3

Let's get started

Step 0. Initial setup

i. Fork this repo into your personal account, and uncheck the Copy the main branch only checkbox, so that you copy all branches and not just main

Copy all branches

ii. Install the Netlify GitHub app on your org or repo if you have not done so already

iii. Clone your fork, and checkout the start-here branch

git clone <FORK_URL>
git checkout start-here

iv. Install dependencies locally

npm i

v. Ensure you have the latest version of netlify-cli installed globally

npm i netlify-cli -g
netlify --version

💡 Learn more about the Netlify CLI in our docs.

Step 1. Create a new site and run local dev server

i. Create a new site by going to Team overview > Add new site > Import an existing project. Click the Deploy with GitHub button. After you authenticate, search for your fork. For the Branch to deploy field, be sure to select start-here as your default production branch. You can keep the auto-populated values for all other fields. Click the Deploy button to deploy your site.

Deploy your project

ii. Rename site to something more memorable in Site configuration > Site details > Change site name.

Change site name

iii. Log in to the CLI, link your repo to your site, and start local dev server

netlify login
netlify link
netlify dev

💡 Learn more about getting started in our docs.

Step 2. Function primitives

Our site is looking a little bare. Let's add some content! First we'll fetch a list of books that we happen to have as a CSV file saved inside the /public directory.

i. Add the Bookshelf component to src/pages/index.tsx

+import Bookshelf from '~/components/Bookshelf';
import Footer from '~/components/Footer';
import Hero from '~/components/Hero';

export default function Home() {
  return (
    <section>
      <Hero />
+     <Bookshelf />
      <Footer />
    </section>
  );
}

ii. Return data from a CSV in an API response in netlify/functions/books.ts

import csv from 'csvtojson';

export default async (req: Request) => {
  const { origin } = new URL(req.url);
  const response = await fetch(`${origin}/books.csv`);
  const csvContent = await response.text();
  const books = await csv().fromString(csvContent);
  
  return Response.json(books);
};

💡 Invoke your function from the CLI:

netlify functions:invoke books

iii. Fetch from the function in src/context/DataProvider.tsx

function StoreProvider({ children }: Props) {
- const books = [] as Book[];
+ const [books, setBooks] = useState<Book[]>([]);

  const fetchBooks = async () => {
+   if (!books.length) {
+     const response = await fetch(`/.netlify/functions/books`);
+     const data = await response.json();
+     setBooks(data);
+   }
  };
}

That's nice, but we can only return all the books, when sometimes we only want one book at a time. Let's add a custom path with an optional slug in the API route.

iv. Export custom config to control method, route, etc in netlify/functions/books.ts

export const config: Config = {
  method: 'GET',
  path: '/api/books{/:id}?',
};

💡 The path parameter follows the URL Pattern API spec.

v. Change your clientside API call to new route in src/context/DataProvider.tsx

-  const fetchBooks = async () => {
-   if (!books.length) {
-     const response = await fetch(`/.netlify/functions/books`);
-     const data = await response.json();
-     setBooks(data);
-   }
-  };
+  const fetchBooks = async (id: string = '') => {
+    if (books.length <= 1) {
+      const response = await fetch(`/api/books/${id}`);
+      const data = await response.json();
+      setBooks(Array.isArray(data) ? data : [data]);
+    }
+  };

vi. Extract and log the id from the URL params in netlify/functions/books.ts

-export default async (req: Request) => {
+export default async (req: Request, context: Context) => {
+  const { id } = context.params;
+  console.log(`Looking up ${id || 'all books'}...`);

vii. Return a single book if the slug is present before the last return statement

if (id) {
  const book = books.find(b => b.id === id);
  if (!book) {
    return new Response('Not found', { status: 404 });
  }
  return Response.json(book);
}

💡 Learn more about Functions in our docs.

Step 3. Branches, CI/CD, and Deploy Previews

Create a new branch, commit changes, push the branch, and open a pull request against the start-here branch of your own repo.

git checkout -b feat/bookshelf
git add -A
git commit -m "Adding a list of books to the home page"
git push origin feat/bookshelf

Since you're working in a fork, be sure to change the base repo and branch:

Before:

After:

You should see a link to the Deploy Preview as a comment by the Netlify bot on the pull request. Pushing to an open pull request will kick off a new build in the Continuous Integration pipeline, and you can inspect the deploy logs as the build is building and deploying.

In addition to deploy logs, the Netlify UI gives you access to function logs as well. You can change the region a function executes by changing the region selector in Site configuration > Build & deploy > Functions.

In the Deploy Preview itself, you'll notice a floating toolbar anchored to the bottom of your screen. This is the Netlify Drawer. You and your teammates can use this to leave feedback to each other about the Deploy Preview. Any comments you make will sync back to the pull request on GitHub (or any Git service that you may use).

Back in the pull request, merge to main. This will kick off a production build. Every deploy is atomic and immutable, which makes instant rollbacks a breeze.

In your local repo, sync up with the changes from start-here again:

git checkout start-here
git pull origin start-here

💡 Learn more about Git workflows and site deploys in our docs.

Step 4. Headers and redirects

You'll notice that when you refresh a page on the /books/{id} route, the site 404s. Why is that? Since this frontend stack utilizes React as an SPA (Single Page Application), there is only one single HTML file (/index.html) inside of the deploy, and routing is managed exclusively by JavaScript referenced in that file. We'll need to add a redirect that routes 404s to /index.html.

Inside your publish directory (for this repo, /public), add a _redirects file that contains the following:

/*  /index.html  200

For every fallthrough case (i.e. whenever a route is accessed and there isn't a file match), it will now redirect back to /index.html, where react-router will route accordingly.

Similar to the _redirects file is the _headers file. Here you can set custom headers for routes of your choosing. Create a /public/_headers file, and save the following:

/* 
  X-Frame-Options: SAMEORIGIN

This will prevent your site from being loaded in an iframe, a technique that help your site prevent clickjacking attacks.

You can also configure both redirects and headers inside the /netlify.toml file. Here is the netlify.toml equivalents of the above:

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

[[headers]]
  for = "/*"
  [headers.values]
    X-Frame-Options = "SAMEORIGIN"

💡 Learn more about redirects and custom headers in our docs.

Step 5. Advanced fine-grained cache control

i. Set fine-grained cache-control headers before fetching in netlify/functions/books.ts

const etag = createHash('md5')
  .update(id || 'all')
  .digest('hex');

const headers = {
  'Cache-Control': 'public, max-age=0, must-revalidate', // Tell browsers to always revalidate
  'Netlify-CDN-Cache-Control': 'public, max-age=31536000, must-revalidate', // Tell Edge to cache asset for up to a year
  'Cache-Tag': `books,promotions`,
  ETag: `"${etag}"`,
};

if (req.headers.get('if-none-match') === etag) {
  return new Response('Not modified', { status: 304, headers });
}

ii. Return headers on all Response objects

if (id) {
  const book = books.find(b => b.id === id);
  if (!book) {
-   return new Response('Not found', { status: 404 });
+   return new Response('Not found', { status: 404, headers });
  }
- return Response.json(book);
+ return Response.json(book, { headers });
}

-return Response.json(books);
+return Response.json(books, { headers });

iii. Purge cache of specific tags using an API call

curl -X POST 'https://api.netlify.com/api/v1/purge' \
  -H 'Authorization: Bearer <token>' \
  -H 'Content-Type: application/json' \
  -d '{"site_id":"$SITE_ID","cache_tags":["books"]}'

💡 Learn more about caching in our docs.

Step 6. Edge Functions and personalization

We're going to make a swag section of the site that is personalized to the user based on their geolocation. Edge functions act as middleware for the CDN — they run in front of other routes!

i. Add the Swag component to the home page in src/pages/index.tsx

import Bookshelf from '~/components/Bookshelf';
import Footer from '~/components/ui/Footer';
import Hero from '~/components/Hero';
+import Swag from '~/components/Swag';

export default function Home() {
  return (
    <section>
      <Hero />
+     <Swag />
      <Bookshelf />
      <Footer />
    </section>
  );
}

ii. Fetch the swag in netlify/context/DataProvider.tsx

- const swag = [] as Swag[];
+ const [swag, setSwag] = useState<Swag[]>([]);

  const fetchSwag = async () => {
+    if (!swag.length) {
+      const response = await fetch('/api/swag');
+      const data = await response.json();
+      setSwag(data);
+    }
  };

iii. Sort items ascending based on distance to user in netlify/functions/swag.ts

import { Config, Context } from '@netlify/functions'
+import haversine from 'haversine';

-export default async (req: Request) => {
+export default async (req: Request, context: Context) => {
   // ...
+  const hasGeo = context.geo?.latitude && context.geo?.longitude;
-  const items = selectRandomItems(merchandise, ITEMS_COUNT);
+  const items = hasGeo
+    ? merchandise
+        .sort(
+          (a, b) =>
+            haversine(a.location, context.geo) -
+            haversine(b.location, context.geo)
+        )
+        .slice(0, ITEMS_COUNT)
+    : selectRandomItems(merchandise, ITEMS_COUNT);

  return Response.json(items);
};

iv. Rewrite response bodies to contain geolocation data in netlify/edge-functions/geo.ts

import { Config, Context } from '@netlify/edge-functions';

export default async (request: Request, context: Context) => {
  const response = await context.next();
  response.headers.set('x-custom-header', 'invoked');

  // html GETs only
  const isGET = request.method?.toUpperCase() === 'GET';
  const isHTMLResponse = response.headers
    .get('content-type')
    ?.startsWith('text/html');
  if (!isGET || !isHTMLResponse) {
    return response;
  }

  const body = await response.text();
  const transformedBody = body.replace(
    'window.geo = {}',
    `window.geo = ${JSON.stringify(context.geo)}`
  );

  return new Response(transformedBody, response);
};

export const config: Config = {
  path: '/*',
  excludedPath: '/(api|assets|images)/*',
};

v. Edge Functions are also great places to add A/B testing. You can add a cookie at the edge to segment user traffic into groups (also known as buckets) to run experimentation. Set a new cookie in netlify/edge-functions/abtest.ts:

+ // set the new "ab-test-bucket" cookie
+ context.cookies.set({
+   name: bucketName,
+   value: newBucketValue,
+ });
  
  return response;

💡 Learn more about Edge Functions in our docs.

Step 7. Environment variables

You can manage environment variables in the UI and CLI.

Go to Site configuration > Environment variables to add site-specific env vars to your site.

Environment variables form

In the CLI, enter the following command to create an environment variable that is scoped to the Functions runtime:

netlify env:set OPENAI_KEY <YOUR_VALUE> --scope functions

💡 Learn more about environment variables in our docs.

Step 8. Building a content-driven app

i. Create a new Connect data layer in Connect > Add a new data layer. Then, in Data layer settings, save the API URL as the VITE_CONNECT_API_URL environment variable.

ii. Create a new Connect API token in Data layer settings > API tokens. Save this as the VITE_CONNECT_API_AUTH_TOKEN environment variable.

iii. Create new data sources for Contentstack and Storyblok in Data layer settings > Data sources.

iv. Replace swag products with data from Contentstack in src/context/DataProvider.tsx

+import { getProducts } from '~/graphql';
-import type { Book, Swag } from '~/types/interfaces';
+import type { Book, Swag, ContentstackProduct } from '~/types/interfaces';

// ...

const fetchSwag = async () => {
  if (!swag.length) {
-   const response = await fetch('/api/swag');
-   const data = await response.json();
-   setSwag(data);
+   const response = await getProducts();
+   const products = response.map((product: ContentstackProduct) => {
+     return {
+       ...product,
+       imagePath: product?.image?.url,
+       name: product?.title,
+       slug: product?.id,
+     };
+   });
+   setSwag(products);
  }
};

v. Fetch About page content from Storyblok in src/pages/about.tsx

+import { getAbout } from '~/graphql';
+import type { AboutPage } from '~/types/interfaces';

export default function About() {
+ const [aboutData, setAboutData] = useState<AboutPage>();

+ useEffect(() => {
+   getAbout()
+     .then(data => {
+       const content = JSON.parse(data?.content);
+       setAboutData({
+         ...data,
+         content,
+       });
+     })
+     .catch(error => console.error(error));
+ }, []);

+ if (!aboutData) {
+   return null;
+ }

+ const titleSplit = aboutData.content?.title?.split('Netlify Compose 2023');
  const linkStyles = 'text-[#30e6e2] hover:underline hover:text-[#defffe]';
  return (

vi. Replace hardcoded images with dynamic image content from Storyblok in src/pages/about.tsx

-  src={netlifyLogo}
+  src={aboutData.content?.headerImage?.filename}
// ...
-  src={composeLogo}
+  src={aboutData.content?.subHeaderImage?.filename}
// ...
-  src={netlifyMonogram}
+  src={aboutData.content?.footerImage?.filename}

vii. Replace hardcoded list items with dynamic list from Storyblok in src/pages/about.tsx

<ul className="mt-8 list-disc pl-5">
-  <li>...</li>
-  // ...
+  {aboutData.content?.body?.map(
+    ({ items }) =>
+      items?.map(i => <li key={i._uid}>{i.itemValue}</li>)
+  )}
</ul>

💡 Learn more about Netlify Connect in our docs.

Step 9. Utilizing existing custom data sources

Follow the instructions in the Dynamic CMS Connector repo!

We also have the S3 Connector available.

Step 10. Bonus features of the Netlify platform

Congrats! You just built a composable website. If we have time, we'll walk through some additional features that you might not know about the Netlify platform.

Recent Enterprise-focused resources from our blog

Read these recent blog posts focused on Enterprise releases, features, and use cases.