@justin-netage/netage-seo-client
v0.1.0
Published
SEO toolkit for Vite sites: JSON-LD schema builders, meta/OG/Twitter tags, sitemap + robots.txt, plus React and Vue adapters.
Downloads
69
Maintainers
Readme
@justin-netage/netage-seo-client
A batteries-included SEO toolkit for Vite sites.
- JSON-LD schema builders for Organization, WebSite (sitelinks searchbox), WebPage, Article/BlogPosting/NewsArticle, Product (offer / rating / review), BreadcrumbList, FAQPage, LocalBusiness, Person, Event, Service, VideoObject, HowTo.
- Meta tag helpers for Open Graph, Twitter Cards, canonical, hreflang,
robots, pagination (
rel=prev/rel=next), icons, theme-color, etc. - Vite plugin that injects site-wide tags into
index.html, and emitssitemap.xmlandrobots.txtat build time. - Framework adapters for React (
<Seo />component +useSeo()hook) and Vue (useSeo()composable), each with per-route merge + unmount cleanup. - Runtime DOM updater (
applySeo) for vanilla SPAs — it reconciles tags it owns (markerdata-nseo) without clobbering anything you hand-authored inindex.html. - Works behind the Netage proxy hub's prerender.io middleware — the tags you inject are rendered to HTML for crawlers with zero extra setup.
Install
npm install @justin-netage/netage-seo-client
# optional peers if you want the adapters:
npm install react vueQuick start (Vite + React)
1. Configure site-wide defaults + sitemap in vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { seoPlugin } from '@justin-netage/netage-seo-client/vite';
import { organization, website } from '@justin-netage/netage-seo-client/schema';
const BASE_URL = 'https://www.example.com';
export default defineConfig({
plugins: [
react(),
seoPlugin({
default: {
baseUrl: BASE_URL,
titleTemplate: '%s | Example Co',
defaultTitle: 'Example Co — widgets since 2012',
description: 'Independent widget manufacturer based in Cape Town.',
lang: 'en',
themeColor: '#0b5fff',
openGraph: {
type: 'website',
siteName: 'Example Co',
locale: 'en_US',
images: [{ url: `${BASE_URL}/og-default.png`, width: 1200, height: 630 }],
},
twitter: { card: 'summary_large_image', site: '@examplecoza' },
icons: [
{ rel: 'icon', href: '/favicon.ico' },
{ rel: 'apple-touch-icon', href: '/apple-touch-icon.png', sizes: '180x180' },
],
jsonLd: [
organization({
name: 'Example Co',
url: BASE_URL,
logo: `${BASE_URL}/logo.png`,
sameAs: [
'https://twitter.com/examplecoza',
'https://www.linkedin.com/company/example-co',
],
}),
website({
name: 'Example Co',
url: BASE_URL,
searchUrl: `${BASE_URL}/search?q={search_term_string}`,
}),
],
},
sitemap: {
baseUrl: BASE_URL,
entries: [
{ loc: '/', changefreq: 'weekly', priority: 1.0 },
{ loc: '/about', changefreq: 'monthly', priority: 0.6 },
{ loc: '/contact', changefreq: 'monthly', priority: 0.5 },
],
},
robots: {
groups: [{ userAgent: '*', allow: ['/'], disallow: ['/admin'] }],
},
}),
],
});npm run build now emits dist/sitemap.xml and dist/robots.txt alongside
the rest of your assets, and your index.html gets a <head> populated with
defaults (title, meta, JSON-LD, canonical, OG/Twitter, icons, etc.).
2. Per-page SEO in React
import { Seo, breadcrumbs, article } from '@justin-netage/netage-seo-client/react';
export function BlogPost({ post }) {
return (
<>
<Seo
title={post.title}
description={post.excerpt}
canonical={`/blog/${post.slug}`}
openGraph={{
type: 'article',
images: [{ url: post.coverImage, width: 1200, height: 630 }],
article: {
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
tags: post.tags,
},
}}
jsonLd={[
article({
type: 'BlogPosting',
headline: post.title,
description: post.excerpt,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { name: post.author.name, url: post.author.url },
publisher: { name: 'Example Co', logo: '/logo.png' },
}),
breadcrumbs([
{ name: 'Home', url: '/' },
{ name: 'Blog', url: '/blog' },
{ name: post.title },
]),
]}
/>
{/* …render the post… */}
</>
);
}Quick start (Vue 3)
// main.ts — sitewide defaults via the Vite plugin (same as above)<!-- BlogPost.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { useSeo, article, breadcrumbs } from '@justin-netage/netage-seo-client/vue';
const props = defineProps<{ post: Post }>();
useSeo(
computed(() => ({
title: props.post.title,
description: props.post.excerpt,
canonical: `/blog/${props.post.slug}`,
jsonLd: [
article({
type: 'BlogPosting',
headline: props.post.title,
datePublished: props.post.publishedAt,
author: { name: props.post.author.name },
publisher: { name: 'Example Co', logo: '/logo.png' },
}),
breadcrumbs([
{ name: 'Home', url: '/' },
{ name: 'Blog', url: '/blog' },
{ name: props.post.title },
]),
],
})),
);
</script>Vanilla / framework-less SPA
import { applySeo, organization } from '@justin-netage/netage-seo-client';
applySeo({
title: 'Pricing',
description: 'Plans for solo, team, and enterprise.',
canonical: 'https://example.com/pricing',
jsonLd: organization({ name: 'Example Co', url: 'https://example.com' }),
});applySeo returns a dispose() that removes every tag it added — wire it
into your router's leave hook if you don't want tags from a previous route
to linger.
JSON-LD builders
All builders are pure functions that return a plain JsonLdNode you can pass
via jsonLd: on any config. They all default @context to https://schema.org.
| Builder | Use for |
| ------------------ | --------------------------------------------------------------- |
| organization | Company identity (homepage). Unlocks knowledge-panel data. |
| localBusiness | Brick-and-mortar / service-area biz. Drives Maps + local pack. |
| website | Site root; pair with searchUrl for sitelinks searchbox. |
| webpage | Generic page — useful for AboutPage / ContactPage subtypes. |
| article | Blog / news / tech articles. Pass type: "BlogPosting" etc. |
| product | E-commerce product. Add offers + aggregateRating for stars. |
| breadcrumbs | <BreadcrumbList> — shows as path in search snippets. |
| faqPage | Expanded FAQ rich result. Only emit if content is visible. |
| person | Author / team member pages. |
| event | Concerts, webinars, conferences. Honors online/offline mode. |
| service | Professional services / SaaS offerings. |
| video | VideoObject with thumbnail, clips, live-broadcast support. |
| howto | Step-by-step guides with supplies/tools/cost. |
Sitemap + robots.txt
Outside of the Vite plugin you can generate either file imperatively — handy for server-rendered / dynamic sitemaps:
import { buildSitemap, buildRobots } from '@justin-netage/netage-seo-client';
const xml = buildSitemap({
baseUrl: 'https://www.example.com',
entries: [
{ loc: '/', changefreq: 'weekly', priority: 1.0 },
{
loc: '/blog/hello-world',
lastmod: new Date(),
images: [{ loc: '/covers/hello.jpg', title: 'Hello cover' }],
alternates: [{ hreflang: 'en', href: '/blog/hello-world' }],
},
],
});
const txt = buildRobots({
groups: [{ userAgent: '*', allow: ['/'], disallow: ['/private'] }],
sitemaps: ['https://www.example.com/sitemap.xml'],
});Integration with the Netage proxy hub
If your site sits behind netage-proxy-hub,
you get SEO benefits for free:
- Crawler HTML via prerender.io. The hub detects bot User-Agents
(Googlebot, Facebookbot, Twitterbot, Slackbot, ChatGPT, ClaudeBot, etc.)
and routes them through
service.prerender.io— which fetches your SPA, executes JS, and returns fully-rendered HTML. Every<meta>,<link>, and<script type="application/ld+json">this package injects ends up in that rendered HTML. No extra setup. - Canonical-domain rewriting. The hub's prerender middleware rewrites
<link rel="canonical">/og:urlto match the customer-facing hostname so crawlers don't see your SPA origin. SetbaseUrlin this package to your customer-facing domain and the two agree. - Permissive default robots.txt. The hub serves
User-agent: *\nAllow: /on every hostname unless you opt out — social preview bots (Facebook, Twitter, LinkedIn, Slack) never get blocked by accident. If you emit your ownrobots.txtvia this plugin, it takes precedence once deployed (the plugin's file is served from your SPA origin, which the hub proxies).
API reference
renderSeoHtml(config, options?)
Serialize a config to an HTML string suitable for <head>. Used by the Vite
plugin and any SSR you wire up yourself.
applySeo(config, doc = document)
Mount a config into a live document. Returns a dispose().
mergeSeo(base, override)
Shallow-merge with deep-merge for openGraph / twitter. Arrays are
replaced; if you want to extend meta / jsonLd, spread yourself.
buildSitemap(options) / buildSitemapIndex(baseUrl, sitemaps)
Pure functions returning XML strings. Supports image:image and
xhtml:link hreflang extensions.
buildRobots(options)
Pure function returning a robots.txt string. Sane default is
User-agent: *\nAllow: /\n.
seoPlugin(options) (Vite)
{
default?: SeoConfig;
sitemap?: { baseUrl: string; entries: SitemapEntry[]; filename?: string };
robots?: RobotsOptions & { filename?: string; includeSitemap?: boolean };
}Good SEO hygiene this package encourages
- One canonical per page. The runtime upserts
<link rel="canonical">instead of appending duplicates. - Absolute URLs in OG/JSON-LD. Pass
baseUrlonce and relative canonical / og:url / og:image get resolved for you. - Title wrap template.
titleTemplate: "%s | Site"keeps titles consistent without repeating the site name on every page. - Reconcile, don't duplicate. Runtime tags are marked
data-nseoso the package never clobbers tags you hand-wrote inindex.html, and it never accumulates stale tags across route changes. - Safe JSON-LD escaping.
<script>bodies have</script>and<!--escaped to prevent HTML-injection via author content.
License
MIT © Netage
