@syted/next
v0.7.0
Published
Render your Syted articles on your own Next.js site. Turnkey pages with reading progress, sticky table of contents and CTA rail, sitemap and llms.txt handlers, or just the data if you want your own design.
Maintainers
Readme
@syted/next
Render your Syted articles on your own Next.js site.
The posts live on yourdomain.com/blog, not on a hosted subdomain, which is
what search engines reward.
Two ways to use it. Pick one.
Turnkey: we render the pages
One file. Works with the App Router.
npm i @syted/next// app/blog/[[...slug]]/page.tsx
import { createBlogPage } from '@syted/next'
const blog = createBlogPage({
title: 'Blog',
siteUrl: 'https://acme.com',
cta: {
eyebrow: 'Acme',
title: 'Ship your changelog in minutes',
description: 'Free while you are under 100 users.',
buttonLabel: 'Start free',
href: '/signup',
},
})
export default blog.Page
export const generateStaticParams = blog.generateStaticParams
export const generateMetadata = blog.generateMetadata# .env.local — server side only
SYTED_API_KEY=ab_live_...What you get:
- A reading progress bar pinned to the top of the window
- A sticky table of contents on the left, built from the article's own headings
- Your call to action on the right, and again at the end of the article
- Index page, article pages, canonical URLs, Open Graph and Twitter cards
BlogPostingandBreadcrumbListJSON-LD
No CSS framework required. The styles ship with the component and everything is a CSS variable. The two you will actually want are options:
createBlogPage({
accent: '#2563eb', // links, active TOC item, buttons, CTA background
font: 'var(--font-inter)',
})Anything else goes through vars, applied inline so it wins over any
stylesheet, no matter the load order:
createBlogPage({
vars: {
'--ab-fg': '#0a0a0a',
'--ab-muted': '#6b7280',
'--ab-border': '#e5e7eb',
'--ab-radius': '4px',
'--ab-max': '1200px', // full grid width
},
})Options
| Option | Default | What it does |
| --- | --- | --- |
| title | 'Blog' | Index heading and breadcrumb label |
| description | — | Line under the index heading |
| basePath | '/blog' | Where the blog is mounted |
| siteUrl | — | Needed for absolute URLs in the breadcrumb JSON-LD |
| cta | — | Card in the right rail, reused at the end of the article |
| footerCta | falls back to cta | Say something different at the end |
| toc | true | Set false to drop the left rail |
| tocLabel | 'On this page' | |
| headerOffset | 0 | Height of your sticky header, in pixels |
| theme | 'light' | 'auto' follows the visitor's dark mode |
| accent | near-black | Links, active TOC item, buttons, CTA background |
| font | inherited | Font family for the blog |
| vars | — | Any other --ab-* variable, applied inline |
| pageSize | 9 | Articles per index page. The index is paginated: page 2 and beyond live at <basePath>/page/2, handled by the same catch-all route. Before 0.7.0 this was a hard cap of 50 and the fifty-first article was listed nowhere. |
| classes | — | Extra classes on root, index, post, prose, … |
If your site has a sticky header, pass its height. The progress bar sits under it and anchors stop landing behind it:
createBlogPage({ headerOffset: 64 })Data only: you render it
For teams who want their own design.
import { createClient } from '@syted/next/client'
const blog = createClient()
const { articles, total } = await blog.list({ limit: 20, tag: 'seo' })
const article = await blog.get('some-slug') // article.body is Markdown
const { tags } = await blog.tags()
const entries = await blog.sitemap() // merge into your sitemap.tsThe pieces are exported individually, so you can take the parts you want:
import { renderArticle, ReadingProgress, TableOfContents } from '@syted/next'
const { html, headings } = renderArticle(article.body)
// headings: [{ depth: 2, text: 'Why it matters', id: 'why-it-matters' }, …]
// html already carries the matching ids, so the TOC links resolverenderMarkdown(body) returns just the HTML if you render your own navigation.
Provenance marker
Syted checks every day that your published articles are actually being served, and tells you when one is not. To do that it has to recognise its own page in the HTML you return.
Turnkey does this for you: the blog root carries data-syted-article="<id>".
So does the jsonLd object the API returns — its @id is
https://syted.ai/a/<id> — so if you inject article.jsonLd in a <script
type="application/ld+json">, you are already covered and there is nothing to
add.
If you render your own design and do not inject jsonLd, add the marker:
import { SytedArticleMarker } from '@syted/next'
<SytedArticleMarker article={article} /> // or articleId={article.id}It renders a single <meta name="syted:article">, which React hoists into the
<head>. Nothing is visible to a reader, no JavaScript is loaded, and nobody
is tracked — it is a serial number, not a beacon.
Without it, Syted falls back to matching the article's title in your HTML. That still works, but a title is a human-readable string: if your template truncates it, appends your site name in a way that splits it across elements, or the article's title is rewritten while your cache still serves the old one, the check can report a perfectly healthy page as unreachable. The marker survives all of that, because it never changes.
Instant updates
Add the revalidation route so a newly published article appears immediately instead of waiting for the cache to expire:
// app/api/syted/revalidate/route.ts
export { POST } from '@syted/next'SYTED_WEBHOOK_SECRET=... # shown in your dashboardSitemap
Search Console only accepts a sitemap served by the domain it lists, so the
blog's entries belong in your /sitemap.xml. The package puts them there.
No app/sitemap.ts yet? One line:
// app/sitemap.ts
export { sitemap as default } from '@syted/next'Already have one? Spread the blog entries in:
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { sitemapEntries } from '@syted/next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const pages = [{ url: 'https://acme.com', lastModified: new Date() }]
return [...pages, ...(await sitemapEntries())]
}Then submit https://acme.com/sitemap.xml in
Search Console → Sitemaps.
Not on Next.js? Syted hosts a sitemap of your articles, current the moment an article is published. Add one line to your own robots.txt:
Sitemap: https://syted.ai/s/<your-site-id>/sitemap.xmlA robots.txt declaration is the one case where a sitemap may live on another
host (cross submission, per sitemaps.org), so Google reads it without any code.
Do not paste that syted.ai URL into Search Console directly — it is rejected
there, because it is not served by your domain.
llms.txt
The plain-text index AI assistants read to find out what a site publishes. Same rule as the sitemap: it only counts served by the domain it describes. One line:
// app/llms.txt/route.ts
export { GET } from '@syted/next/llms'It lists every published article with its title, absolute URL and one-line description, in the llmstxt.org format, and stays fresh through the same cached client as the rest of the package. Already have one? Append the blog section to yours:
// app/llms.txt/route.ts
import { llmsTxt } from '@syted/next/llms'
export async function GET() {
const body = [mySections, await llmsTxt()].join('\n\n')
return new Response(body, {
headers: { 'content-type': 'text/plain; charset=utf-8' },
})
}llmsTxt() takes title, summary, sectionTitle, limit, siteUrl,
basePath and extra if you want to word it yourself.
Which articles bring you customers
Turnkey counts two things on your own domain — a visit on every article page, and a click on your call to action — and shows them per article in your Syted dashboard, under Visibility. Traffic tells you who reads; this tells you who moves.
It also tells you who sent them. Each event carries the referrer reduced to one coarse word, so you can see the visits that came from ChatGPT, Perplexity, Gemini or Claude. That is the proof by traffic that assistants are recommending you, not just a citation count.
Nothing to install, no analytics account, no script. And nothing about the reader is recorded:
- no cookie, no
localStorage, nothing written on their machine; - no identifier, generated or derived — two visits by the same person are indistinguishable from two people;
- never the referring URL. It is classified in the browser into one of
ai_chatgpt,ai_perplexity,ai_gemini,ai_claude,ai_other,search,social,direct,other— and then thrown away. A referrer can carry a session token or someone's exact search terms; sending it would be tracking; - no IP, no user agent, nothing that could form a fingerprint;
- what is kept is a count per article, per placement, per source, per day.
It is a counter, not tracking, so it adds no consent banner to your site. The value that travels to the browser is a public event token that can do exactly one thing — increment your own counters. Your API key never leaves your server.
Rendering your own pages in data-only mode? article.events carries what they
need:
import { PageView, reportEvent } from '@syted/next'
<PageView events={article.events} slug={article.slug} />
<a href="/signup" onClick={() => reportEvent(article.events, article.slug, 'cta_footer')}>
Start free
</a>Notes
Your API key reads your articles, including unpublished drafts. Keep it server
side: createClient() throws if it detects a browser, rather than leaking it.
MIT.
