├── .env.template ├── .eslintrc.json ├── .prettierrc ├── .env.development ├── .env.production ├── renovate.json ├── .github ├── .kodiak.toml └── workflows │ └── labeller.yml ├── config └── builder.js ├── netlify.toml ├── styles ├── globals.css └── Home.module.css ├── assets └── index.css ├── .gitignore ├── pages ├── api │ └── attributes.js ├── _app.js ├── _middleware.js └── builder │ └── [hash].js ├── package.json ├── next.config.js └── README.md /.env.template: -------------------------------------------------------------------------------- 1 | BUILDER_PUBLIC_KEY= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | BUILDER_PUBLIC_KEY=6d585b9923974f03815f710c4ec541a3 2 | BUILDER_PRIVATE_KEY=bpk-b1a3ae141c1b449d99a3d57399f00db8 3 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | BUILDER_PUBLIC_KEY=6d585b9923974f03815f710c4ec541a3 2 | BUILDER_PRIVATE_KEY=bpk-b1a3ae141c1b449d99a3d57399f00db8 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["local>netlify/renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [merge.automerge_dependencies] 4 | versions = ["minor", "patch"] 5 | usernames = ["renovate"] 6 | 7 | [approve] 8 | auto_approve_usernames = ["renovate"] 9 | -------------------------------------------------------------------------------- /config/builder.js: -------------------------------------------------------------------------------- 1 | if (!process.env.BUILDER_PUBLIC_KEY) { 2 | throw new Error('Missing env varialbe BUILDER_PUBLIC_KEY') 3 | } 4 | 5 | export default { 6 | apiKey: process.env.BUILDER_PUBLIC_KEY 7 | } 8 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "next build" 3 | base = "." 4 | publish = ".next" 5 | 6 | [build.environment] 7 | NEXT_USE_NETLIFY_EDGE = "true" 8 | 9 | [[plugins]] 10 | package = "@netlify/plugin-nextjs" 11 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /assets/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .grayscale { 6 | filter: grayscale(1); 7 | } 8 | 9 | html, 10 | body { 11 | padding: 0; 12 | margin: 0; 13 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 14 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 15 | } 16 | 17 | a { 18 | color: inherit; 19 | text-decoration: none; 20 | } 21 | 22 | * { 23 | box-sizing: border-box; 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # netlify 35 | .netlify 36 | -------------------------------------------------------------------------------- /pages/api/attributes.js: -------------------------------------------------------------------------------- 1 | import { getAttributes } from '@builder.io/personalization-context-menu' 2 | 3 | if (!process.env.BUILDER_PRIVATE_KEY) { 4 | throw new Error('No BUILDER_PRIVATE_KEY defined') 5 | } 6 | 7 | /** 8 | * API to get the custom targeting attributes from Builder, only needed for the context menu to show a configurator and allow toggling of attributes 9 | */ 10 | export default async (req, res) => { 11 | const attributes = await getAttributes(process.env.BUILDER_PRIVATE_KEY) 12 | res.send(attributes) 13 | } 14 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import { builder } from '@builder.io/react' 2 | import builderConfig from '../config/builder' 3 | import '../assets/index.css' 4 | import { ContextMenu } from '@builder.io/personalization-context-menu' 5 | // only needed for context menu styling 6 | import '@szhsin/react-menu/dist/index.css' 7 | import '@szhsin/react-menu/dist/transitions/slide.css' 8 | import '@builder.io/widgets' 9 | 10 | builder.init(builderConfig.apiKey) 11 | 12 | export default function MyApp({ Component, pageProps }) { 13 | return ( 14 | <> 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dev": "yarn next dev", 4 | "build": "yarn next build", 5 | "start": "yarn next start" 6 | }, 7 | "dependencies": { 8 | "@builder.io/admin-sdk": "^0.1.0", 9 | "@builder.io/personalization-context-menu": "^0.0.2", 10 | "@builder.io/personalization-utils": "^0.2.1-3", 11 | "@builder.io/react": "^2.0.0", 12 | "@builder.io/widgets": "^1.2.21", 13 | "next": "12.1.5", 14 | "next-seo": "^5.4.0", 15 | "react": "18.1.0", 16 | "react-dom": "18.1.0" 17 | }, 18 | "devDependencies": { 19 | "@netlify/build": "^26.5.3", 20 | "@netlify/functions": "^1.0.0", 21 | "@netlify/plugin-nextjs": "^4.7.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | images: { 5 | domains: ['cdn.builder.io'] 6 | }, 7 | async headers() { 8 | return [ 9 | { 10 | source: '/:path*', 11 | headers: [ 12 | // this will allow site to be framed under builder.io for wysiwyg editing 13 | { 14 | key: 'Content-Security-Policy', 15 | value: 'frame-ancestors https://*.builder.io https://builder.io' 16 | } 17 | ] 18 | } 19 | ] 20 | }, 21 | env: { 22 | // expose env to the browser 23 | BUILDER_PUBLIC_KEY: process.env.BUILDER_PUBLIC_KEY 24 | } 25 | } 26 | 27 | module.exports = nextConfig 28 | -------------------------------------------------------------------------------- /.github/workflows/labeller.yml: -------------------------------------------------------------------------------- 1 | name: Label PR 2 | on: 3 | pull_request: 4 | types: [opened, edited] 5 | 6 | jobs: 7 | label-pr: 8 | if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | pr: 13 | [ 14 | { prefix: 'fix', type: 'bug' }, 15 | { prefix: 'chore', type: 'chore' }, 16 | { prefix: 'test', type: 'chore' }, 17 | { prefix: 'ci', type: 'chore' }, 18 | { prefix: 'feat', type: 'feature' }, 19 | { prefix: 'security', type: 'security' }, 20 | ] 21 | steps: 22 | - uses: netlify/pr-labeler-action@v1.0.0 23 | if: startsWith(github.event.pull_request.title, matrix.pr.prefix) 24 | with: 25 | token: '${{ secrets.GITHUB_TOKEN }}' 26 | label: 'type: ${{ matrix.pr.type }}' 27 | -------------------------------------------------------------------------------- /pages/_middleware.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import { 3 | PersonalizedURL, 4 | getUserAttributes 5 | } from '@builder.io/personalization-utils' 6 | 7 | const personalizedPrefix = '/builder' 8 | const excludededPrefixes = ['/favicon', '/api', '/sw.js', personalizedPrefix] 9 | 10 | export default function middleware(request) { 11 | const url = request.nextUrl 12 | let response = NextResponse.next() 13 | const usePath = url.pathname 14 | if (!excludededPrefixes.find(path => usePath.startsWith(path))) { 15 | const query = Object.fromEntries(url.searchParams) 16 | const personlizedURL = new PersonalizedURL({ 17 | pathname: usePath, 18 | attributes: { 19 | ...getUserAttributes({ ...request.cookies, ...query }), 20 | domain: request.headers.get('Host') || '', 21 | country: request.geo?.country || '', 22 | } 23 | }) 24 | url.pathname = personlizedURL.rewritePath() 25 | return NextResponse.rewrite(url) 26 | } 27 | return response 28 | } 29 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | min-height: 100vh; 7 | padding: 4rem 0; 8 | flex: 1; 9 | flex-direction: column; 10 | justify-content: center; 11 | align-items: center; 12 | } 13 | 14 | .footer { 15 | display: flex; 16 | flex: 1; 17 | padding: 2rem 0; 18 | border-top: 1px solid #eaeaea; 19 | justify-content: center; 20 | align-items: center; 21 | } 22 | 23 | .footer a { 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | flex-grow: 1; 28 | } 29 | 30 | .title a { 31 | color: #0070f3; 32 | text-decoration: none; 33 | } 34 | 35 | .title a:hover, 36 | .title a:focus, 37 | .title a:active { 38 | text-decoration: underline; 39 | } 40 | 41 | .title { 42 | margin: 0; 43 | line-height: 1.15; 44 | font-size: 4rem; 45 | } 46 | 47 | .title, 48 | .description { 49 | text-align: center; 50 | } 51 | 52 | .description { 53 | margin: 4rem 0; 54 | line-height: 1.5; 55 | font-size: 1.5rem; 56 | } 57 | 58 | .welcome { 59 | margin-left: 0.8em; 60 | } 61 | 62 | .flag { 63 | vertical-align: middle; 64 | } 65 | 66 | .code { 67 | background: #fafafa; 68 | border-radius: 5px; 69 | padding: 0.75rem; 70 | font-size: 1.1rem; 71 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 72 | Bitstream Vera Sans Mono, Courier New, monospace; 73 | } 74 | 75 | .grid { 76 | display: flex; 77 | align-items: center; 78 | justify-content: center; 79 | flex-wrap: wrap; 80 | max-width: 800px; 81 | } 82 | 83 | .card { 84 | margin: 1rem; 85 | padding: 1.5rem; 86 | text-align: left; 87 | color: inherit; 88 | text-decoration: none; 89 | border: 1px solid #eaeaea; 90 | border-radius: 10px; 91 | transition: color 0.15s ease, border-color 0.15s ease; 92 | max-width: 300px; 93 | } 94 | 95 | .card:hover, 96 | .card:focus, 97 | .card:active { 98 | color: #0070f3; 99 | border-color: #0070f3; 100 | } 101 | 102 | .card h2 { 103 | margin: 0 0 1rem 0; 104 | font-size: 1.5rem; 105 | } 106 | 107 | .card p { 108 | margin: 0; 109 | font-size: 1.25rem; 110 | line-height: 1.5; 111 | } 112 | 113 | .logo { 114 | height: 1em; 115 | margin-left: 0.5rem; 116 | } 117 | 118 | @media (max-width: 600px) { 119 | .grid { 120 | width: 100%; 121 | flex-direction: column; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /pages/builder/[hash].js: -------------------------------------------------------------------------------- 1 | import { NextSeo } from 'next-seo' 2 | import { useRouter } from 'next/router' 3 | import { 4 | BuilderComponent, 5 | Builder, 6 | builder, 7 | isPreviewing 8 | } from '@builder.io/react' 9 | import builderConfig from '../../config/builder' 10 | import DefaultErrorPage from 'next/error' 11 | import Head from 'next/head' 12 | import { PersonalizedURL } from '@builder.io/personalization-utils' 13 | import { useEffect } from 'react' 14 | import '@builder.io/widgets' 15 | 16 | builder.init(builderConfig.apiKey) 17 | 18 | export async function getStaticProps({ params }) { 19 | const personlizedURL = PersonalizedURL.fromHash(params.hash) 20 | const attributes = personlizedURL.attributes 21 | const page = 22 | (await builder 23 | .get('page', { 24 | apiKey: builderConfig.apiKey, 25 | userAttributes: attributes, 26 | cachebust: true 27 | }) 28 | .promise()) || null 29 | 30 | return { 31 | props: { 32 | page, 33 | attributes: attributes, 34 | locale: attributes.locale || 'en-US' 35 | }, 36 | // Next.js will attempt to re-generate the page: 37 | // - When a request comes in 38 | // - At most once every 1 seconds 39 | revalidate: 1 40 | } 41 | } 42 | 43 | export async function getStaticPaths() { 44 | return { 45 | paths: [], 46 | fallback: true 47 | } 48 | } 49 | 50 | export default function Path({ page, attributes, locale }) { 51 | const router = useRouter() 52 | const isPreviewingInBuilder = isPreviewing() 53 | const show404 = !page && !isPreviewingInBuilder 54 | 55 | useEffect(() => { 56 | builder.setUserAttributes(attributes) 57 | }, []) 58 | 59 | if (router.isFallback) { 60 | return

Loading...

61 | } 62 | 63 | const { title, description, image } = page?.data || {} 64 | return ( 65 | <> 66 | 67 | {!page && } 68 | 69 | 70 | 87 | {show404 ? ( 88 | 89 | ) : ( 90 | 96 | )} 97 | 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js 12 Edge Middleware Demo with Builder.io 2 | 3 | Deploy to Netlify 4 | 5 | Try the demo live here: https://builder-edge-personalized.netlify.app/ 6 | 7 | ## Introduction 8 | 9 | This is a demo of building personalized landing pages with Builder and Next.js middleware running on Netlify Edge Functions. 10 | All pages are generated with SSG/ISR with a middleware function that rewrites the generic URL to a personalized one. 11 | 12 |

13 | Editor example 14 |

15 | 16 | 17 | ## Prerequisites 18 | 19 | Before using this example, make sure you have the following: 20 | 21 | * A [Builder.io](builder.io) account. Check out the [plans](https://www.builder.io/m/pricing), which range from our free tier to custom. 22 | * Save private key / public key from your space [settings page](https://builder.io/account/space) and create copy both keys to `.env.development` and `.env.production` for the next step. 23 | 24 | 25 | ## Quick Start 26 | 27 | ```shell 28 | git clone git@github.com:BuilderIO/netlify-next-edge-middleware.git 29 | cd netlify-next-edge-middleware 30 | ``` 31 | Then update env files with the keys from last step 32 | ``` 33 | yarn 34 | yarn dev 35 | ``` 36 | 37 | 38 | ## Why Builder.io? 39 |
40 |

41 | BUILDER 42 |

43 |

44 | Drag and drop page builder and CMS for React, Vue, Angular, and more 45 |

46 |

47 | Integrate with any site or app. Drag and drop with the components already in your codebase. High speed, full control, no compromises 48 |

49 |
50 | 51 | 52 | Hardcoding layouts for frequently changing content bottlenecks your team and makes releases messy 53 | 54 | Using an API-driven UI allows you to: 55 | 56 | - Decouple page updates from deploys 57 | - Schedule, a/b test, and personalize via APIs 58 | - Reduce code + increase composability 59 | 60 |
61 | 62 | 63 | ## Why edge middleware for landing pages? 64 | Edge middlewares allow us to factor in many details about the request than just the URLs which is usually a limitation of statically generating pages, you'll see in this example we're using cookies, and geolocation information to fetch the correct content and render it statically. 65 | 66 |
67 | 68 | 69 | Additional framework support: 70 | 71 | - [Gatsby](https://github.com/BuilderIO/builder/tree/master/examples/gatsby) 72 | - [Next.js](https://github.com/BuilderIO/builder/tree/master/examples/next-js) 73 | - [Angular](https://github.com/BuilderIO/builder/tree/master/packages/angular) 74 | - [HTML API (for any framework)](https://builder.io/c/docs/html-api) 75 | 76 | As well as some handy power features like: 77 | 78 | - [Symbols](https://builder.io/c/docs/guides/symbols) 79 | - [Dynamic data fetching and binding](https://builder.io/c/docs/guides/advanced-data) 80 | - [State handling](https://builder.io/c/docs/guides/state-and-actions) 81 | - [Content API](https://builder.io/c/docs/query-api) 82 | - [GraphQL API](https://builder.io/c/docs/graphql-api) 83 | - [Webhooks](https://builder.io/c/docs/webhooks) 84 | - [Targeting and scheduling content](https://builder.io/c/docs/guides/targeting-and-scheduling) 85 | - [Extending Builder.io with plugins](https://github.com/BuilderIO/builder/tree/master/plugins) 86 | 87 | ## Join the community! 88 | 89 | Questions? Requests? Feedback? Chat with us in our [official forum](https://forum.builder.io)! 90 | --------------------------------------------------------------------------------