A small Webflow Cloud app reads HubSpot's API to keep blog teasers current, so no one needs to update the Webflow page after the marketing team publishes each new post.
When the marketing site runs on Webflow, and the blog runs on HubSpot, a "latest posts" block usually means copying each new post's title and summary into the Designer by hand, and the block drifts out of date whenever someone forgets.
Webflow Cloud removes the chore. You mount a Next.js app on a path of your Webflow site, give it a HubSpot legacy private app token as a secret, and let a Route Handler pull the newest published posts on request.
The page renders the teasers server-side, so the token stays on the server, and a Designer page can pull the same data through a shortcode embed, on any plan that can publish custom code.
What do you need to display HubSpot blog teasers in Webflow?
You need five prerequisites:
- Webflow site with Webflow Cloud: Webflow Cloud ships with the free Starter site plan and every paid plan, but mounting the app to a custom domain requires Premium or higher.
- HubSpot private app access: An account with at least one published blog post, plus permission to create private apps or an admin who can create one and hand you the token.
- Node.js and npm: A Webflow Cloud-supported Node.js release installed locally, with npm configured as the package manager.
- Next.js 15+ App Router project: Webflow Cloud supports Next.js 15 or higher; scaffold with the App Router, because Route Handlers and React Server Components require it.
- Connected repository: The repository you connect to the Webflow Cloud project, which is where the code comes from. Keep the token out of it; the token lives in the Webflow Cloud environment.
With these pieces ready, the build hinges on keeping the token server-side and including the injected mount path in browser requests.
6 steps to display HubSpot blog teasers in Webflow Cloud
The build takes six steps, from creating a restricted HubSpot token through deploying the app and embedding its output. Validate each result before continuing so you can easily isolate configuration errors.
Start with the credential, then build and deploy the two server-rendered access paths.
1. Create a HubSpot private app and copy its access token
HubSpot now files private apps under legacy apps, which it says "are still supported by HubSpot, but don't have access to the latest app features"; you create one under Development > Legacy apps, and it takes a super admin.
Grant the content scope, which the Blog Posts API requires, and name the app after the site that will use it so whoever inherits the account knows what the token is for
Know what that scope covers before you copy the token. HubSpot has no read-only blog scope: content "includes sites, landing pages, email, blog, and campaigns", and the same scope that lets this app read posts lets anyone holding the token edit or delete them. Grant nothing beyond it, and treat the token like an admin credential.
The selected scope should allow the site to retrieve published blog content without granting unrelated account access.
Copy the access token from the private app into a password manager.
I prefer a dedicated private app for each consuming site. When an agency hands the site back, revoking that app cuts the site off cleanly without touching anything else in the portal. You should now hold an access token and a private app with only the content scope.
2. Scaffold the Next.js app with npm
Scaffold the project with npm and the App Router, using a Webflow Cloud-supported Next.js and Node.js combination. Keep the project configured for npm throughout the build so local and deployed package installation use the same lockfile and scripts.
Run the scaffolder from the directory that will hold the project:
npx create-next-app@15 hubspot-teasers --typescript --app --no-tailwind --eslint --no-src-dir --import-alias "@/*" --use-npm
cd hubspot-teasers
If the scaffolder prompts instead of taking a flag, pick npm, the App Router and no src directory, because the imports use @/lib/hubspot.
Leave export const runtime = 'edge' out of every route. Webflow Cloud deploys the app on Cloudflare Workers, while the Next.js edge runtime target is a separate build mode that the OpenNext adapter does not support. Leave basePath out of next.config as well; Webflow Cloud injects the mount path at build time.
Create a .env.local file for local development with this content:
HUBSPOT_ACCESS_TOKEN=your-hubspot-private-app-token
Add .env.local to .gitignore if the scaffolder did not. You now have a project that runs locally with npm run dev and a token available to server code as process.env.HUBSPOT_ACCESS_TOKEN.
3. Write the HubSpot fetch module
Put the HubSpot call in one module that both the page and the Route Handler import. Keeping it in one place means the server component can call the module directly, which sidesteps the base path question entirely for the server-rendered path.
The module requests published posts and maps each one onto a small teaser shape. HubSpot cannot sort by publish date: its documented sort fields are createdAt, name, updatedAt, createdBy and updatedBy, and an unsupported value falls back to the default, oldest created first.
So the module asks for the 20 most recently created posts and orders that window by publishDate itself. The one case it can miss is a post drafted long ago, more than 20 posts back, and published today. Any HTML tags in postSummary are stripped before the text reaches a page.
Save this as lib/hubspot.ts:
const HUBSPOT_POSTS_URL = 'https://api.hubapi.com/cms/v3/blogs/posts';
// HubSpot cannot sort by publishDate, so fetch the 20 most recently created published
// posts (its default page size) and order that window by publish date here.
const WINDOW = 20;
export type Teaser = {
title: string;
summary: string;
url: string;
image: string | null;
publishedAt: string;
};
type HubSpotPost = {
name: string;
postSummary?: string;
url: string;
featuredImage?: string;
publishDate: string;
};
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').trim();
}
export async function getTeasers(limit = 3): Promise<Teaser[]> {
const token = process.env.HUBSPOT_ACCESS_TOKEN;
if (!token) {
throw new Error('HUBSPOT_ACCESS_TOKEN is not set');
}
const params = new URLSearchParams({
limit: String(WINDOW),
state: 'PUBLISHED',
sort: '-createdAt',
});
const res = await fetch(`${HUBSPOT_POSTS_URL}?${params.toString()}`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!res.ok) {
throw new Error(`HubSpot responded with ${res.status}`);
}
const data = (await res.json()) as { results?: HubSpotPost[] };
return (data.results ?? [])
.sort((a, b) => Date.parse(b.publishDate) - Date.parse(a.publishDate))
.slice(0, limit)
.map((post) => ({
title: post.name,
summary: stripHtml(post.postSummary ?? ''),
url: post.url,
image: post.featuredImage || null,
publishedAt: post.publishDate,
}));
}
The query asks HubSpot's Blog Posts API for published posts only. In this code, cache: 'no-store' skips the Next.js fetch cache, so every request reads live from HubSpot and a newly published post appears on the next page load.
That also means every page view and every embed load is one HubSpot call. HubSpot's burst limit on Free and Starter is 100 requests per 10 seconds, and its daily limit is shared across every app in the account, so a traffic spike here can break the client's other HubSpot integrations for the rest of the day.
Before a high-traffic launch, cache the teasers in the Webflow Cloud Key Value store for at least its 60-second minimum TTL. Do not reach for a Cache-Control header; Webflow Cloud replaces it with private, no-cache. You now have a typed function that returns up to three teasers or throws with the HubSpot status code.
4. Expose a Route Handler and render the teasers server-side
Add two files: a Route Handler that returns the teasers as JSON, and a page that renders them directly. The Route Handler exists for Designer-built pages that will call it from the browser; the page exists for the mount path itself and calls the shared module directly.
Both import getTeasers.
Save the handler as app/api/posts/route.ts:
import { NextResponse } from 'next/server';
import { getTeasers } from '@/lib/hubspot';
export async function GET() {
try {
const teasers = await getTeasers(3);
return NextResponse.json({ teasers });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(message);
return NextResponse.json({ error: 'Could not load posts' }, { status: 502 });
}
}
I return a 502 on HubSpot failure because this code path failed during an upstream call to HubSpot. The handler writes the HubSpot detail server-side through console.error, and the browser gets only a generic error.
Because nothing is cached, every request to /api/posts makes one HubSpot API call with your token, so traffic to the route turns directly into HubSpot requests.
Security verdict: This sample Route Handler is publicly callable and implements no authentication, rate limiting, quota enforcement, or spend cap. That leaves it open to automated traffic that can consume HubSpot API quota.
Before production, add suitable access control and rate limiting and cache the response as described in step 3. Keep the HubSpot token in HUBSPOT_ACCESS_TOKEN, never behind a NEXT_PUBLIC_ prefix.
Then replace the scaffolded app/page.tsx with this:
import { getTeasers, type Teaser } from '@/lib/hubspot';
export const dynamic = 'force-dynamic';
export default async function Page() {
let teasers: Teaser[] = [];
try {
teasers = await getTeasers(3);
} catch (err) {
console.error(err instanceof Error ? err.message : 'Unknown error');
teasers = [];
}
return (
<section>
<h2>Latest from the blog</h2>
{teasers.length === 0 ? (
<p>No posts yet.</p>
) : (
<ul>
{teasers.map((post) => (
<li key={post.url}>
{post.image && <img src={post.image} alt="" />}
<h3>
<a href={post.url}>{post.title}</a>
</h3>
<p>{post.summary}</p>
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString('en-US')}
</time>
</li>
))}
</ul>
)}
</section>
);
}
Run npm run dev and open http://localhost:3000; you should see up to three recent published posts, each title linking to the post. Hitting http://localhost:3000/api/posts should return the same three as JSON under a teasers key.
A HubSpot failure on this page writes the error through console.error and renders the same empty state as a blog with no posts, so investigate the server-side error before assuming the blog is empty.
5. Add the token to the Webflow Cloud environment and mount the app
Store the token in the Webflow Cloud environment so it stays out of the connected repository. Push the project to the repository you will connect, create the Webflow Cloud project from that repository, and set its mount path to something like /latest-posts.
In the environment's variables, add HUBSPOT_ACCESS_TOKEN and mark it as a secret. If you rotate the token in HubSpot later, paste the new value into this same variable. Secret and non-secret environment variables are available to the build process and the deployed application at runtime, and secret values are redacted from build logs. The variable therefore stays out of the repository.
Confirm the secret setting and mount path in the environment configuration.
Deploy. Once the deploy finishes, open /latest-posts on the domain your site publishes to (a custom domain requires Premium or higher), and you should see the same three teasers you saw locally; with the mount path prefixed, /latest-posts/api/posts should return the JSON.
6. Embed the teasers on a Designer-built page
Embed the teasers by calling the mounted Route Handler from custom code on the published Designer page. The app and Designer page share an origin when the app is mounted on a path of the same site, so the request should not need cross-origin resource sharing (CORS) configuration.
Webflow Cloud injects the mount path into the app at build time, while a browser fetch retains the URL written in the script. Webflow Cloud's bring-your-own-app guidance says it directly: "Client-side fetch calls must manually include the base path." In an embed that lives in the Designer, write the mount path into the URL yourself.
Add this in a Code Embed element where the teasers should appear. Publishing custom code needs a paid Site plan or a Core, Growth, Agency or Freelancer Workspace so that a free Starter site can run the app but not this embed:
<div id="hubspot-teasers"></div>
<script>
(async () => {
const mountPath = '/latest-posts';
const container = document.getElementById('hubspot-teasers');
const res = await fetch(`${mountPath}/api/posts`);
if (!res.ok) return;
const { teasers } = await res.json();
teasers.forEach((t) => {
const a = document.createElement('a');
a.className = 'teaser';
a.href = t.url;
const strong = document.createElement('strong');
strong.textContent = t.title;
const p = document.createElement('p');
p.textContent = t.summary;
a.append(strong, p);
container.append(a);
});
})();
</script>
The embed builds elements and sets textContent, so no HubSpot field is ever parsed as HTML. If a client-side component inside the Next.js app itself needs the handler, read the mount path from a NEXT_PUBLIC_BASE_PATH variable.
Use that prefix only for the non-secret mount path. Publish the Webflow site, load the page, and the embed should fill with the same three teasers the mounted app shows. The script runs on the published site so the Designer canvas will show the empty container.
What causes HubSpot teasers on Webflow Cloud to fail?
HubSpot teasers usually fail because of an unsupported runtime directive, a missing deployed credential, an unprefixed browser request, or a package-manager mismatch between the repository and Webflow Cloud.
Match the visible symptom to the cause and fix below before changing the fetch module.
The Webflow Cloud build fails, or API routes return errors after deploy
Cause: The project usually contains export const runtime = 'edge' in one or more routes. Webflow Cloud deploys through OpenNext on Cloudflare Workers, but the Next.js edge runtime target is a separate build mode that the adapter does not support.
A local development server may not expose the same deployment incompatibility, which is why the failure can first appear during the Webflow Cloud build or after deployment.
Fix: Search every project file for export const runtime = 'edge' and remove each occurrence. Keep basePath out of next.config because Webflow Cloud injects the mount path during the build. Redeploy, then request /latest-posts/api/posts. A successful response should contain a teasers key rather than a platform build error or route failure.
The mounted page shows "No posts yet." and the API route returns 502
Cause: The split between a working local app and a deployed 502 points to the credential source that changes between those environments. Locally, the value comes from .env.local, which is correctly excluded from the repository; on Webflow Cloud, it comes from the environment's variables. A typo in the variable name, trailing whitespace in the pasted token, a rotated token, or a recreated private app can all produce the same visible result.
Fix: Review the deployed environment and paste the token fresh from HubSpot's private app page. Confirm the variable is named HUBSPOT_ACCESS_TOKEN and the private app still has the content scope. The Route Handler passes details to the console.error: "HUBSPOT_ACCESS_TOKEN is not set" means the variable is missing, while "HubSpot responded with" plus a status code means the request reached HubSpot but did not succeed.
The Designer embed stays empty because its request to /api/posts fails
Cause: A request to /api/posts is routed outside the mounted app because the browser uses the literal URL in the embed. Webflow Cloud injects the mount path for the application build, but it cannot rewrite a client-side URL stored in Designer custom code. The same routing error can appear inside the Next.js app when a client component fetches a relative URL without the public mount-path prefix.
Fix: Change the Designer request to /latest-posts/api/posts, or build it from the mountPath constant shown in the embed. Inside the app, use NEXT_PUBLIC_BASE_PATH for the non-secret mount path so a later path change requires one variable update.
Keep next.config free of a manually configured basePath. After publishing, the browser request should reach the mounted handler and fill the empty container.
The Webflow Cloud build fails even though npm works locally
Cause: A pnpm-lock.yaml or yarn.lock file conflicts with this project's npm setup, while package.json scripts that call pnpm or yarn depend on tools the deployment is not configured to use. A local build may still pass if your machine has those package managers installed, hiding the mismatch until Webflow Cloud installs dependencies from the connected repository.
Fix: Migrate the project fully to npm. Remove the pnpm or yarn lockfile and run npm install to generate package-lock.json. Replace any pnpm or yarn calls in package.json scripts with npm run equivalents. Commit the new lockfile and updated scripts, then redeploy. The repository should contain one package-manager lockfile, and Webflow Cloud should run the same npm commands that succeed locally.
What you can build next with HubSpot and Webflow
Once your teasers refresh themselves, you can extend the same server-side boundary into a full blog index with a larger limit, while a related-posts block would require filtering the module does not do yet.
For the other direction, sending Webflow form leads into HubSpot, see HubSpot forms without Zapier. Explore the HubSpot integration for more ways to connect HubSpot and Webflow.
Frequently asked questions
How do I show more than three posts?
Change the 3 passed to getTeasers in both app/page.tsx and app/api/posts/route.ts; the two calls are separate, so update them together. Up to 20 works as written, because that is the window the module fetches. Beyond 20, raise WINDOW as well. After increasing the count, test the mounted page and Designer embed at narrow widths, with long titles and missing images, because a larger response can expose layout problems that three cards did not reveal.
Can I build this in Astro instead of Next.js?
Yes. The HubSpot request and the server-side token boundary port are unchanged, but the token read is not: Webflow documents locals.runtime.env for Astro routes, so read HUBSPOT_ACCESS_TOKEN from there instead of process.env. Replace the Route Handler with an Astro server endpoint and replace page.tsx with a server-rendered .astro page. Keep the mount path out of framework configuration and include it manually in browser requests.
Can I protect the Route Handler with middleware?
Yes. You can add middleware.ts to check requests before they reach the Route Handler, and it runs as Edge runtime middleware on the Workers runtime. Keep the middleware filename rather than renaming it to proxy, because that option runs on the Node runtime and cannot opt into Edge for deployment on Webflow Cloud.
Can I use the same embed on several Designer pages?
Yes, on a plan that can publish custom code: you can add the same embed to each Designer page that needs teasers. Keep mountPath set to /latest-posts in every copy because it identifies the app mount, not the current page. Update every copy if you later change the app's mounted path. Use a shared site-level code location when every page needs identical markup.





