The problem I'm still losing at
Search my app's name on Google and the first results aren't my site. They're other people's articles about it. Directories, aggregators, a Reddit thread, a Medium post. The actual product, CandleDojo, sits somewhere below the fold.
This is normal for a young domain. Google doesn't have enough signal that "candledojo" the query = "candledojo.app" the entity. Every 3rd-party write-up outranks the site itself because those domains are older, have more backlinks, and carry more topical weight than a six-month-old app.
I'm not done fixing this, but one of the levers I'm pulling is structured data. Specifically JSON-LD. And not just the basics that SEO checklists tell you to ship. There's a specific stack that matters when you're trying to:
- Resolve as a clean entity in Google's Knowledge Graph
- Win rich result eligibility on your own content pages
- Get cited by AI tools (Perplexity, ChatGPT search, Claude) when they answer questions in your niche
Here's the stack I ship on every page of CandleDojo, what each schema actually does, and what I'd skip if I were starting from scratch.
The mental model: one helper, many schemas
All my JSON-LD lives in src/lib/structured-data.ts. It's one file of pure TypeScript helpers that return plain objects. Each helper is a schema builder. I inject them via <script type="application/ld+json"> tags from either the root layout (site-wide schemas) or page components (page-specific schemas).
No CMS, no plugin, no markup hacked into <Head>. Just typed functions.
export function buildBreadcrumbJsonLd(items: BreadcrumbItem[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
...(item.item ? { item: item.item } : {}),
})),
}
}
That shape repeats for every schema. Helper in, object out, inject once. Below is what I inject where.
Schema 1: Organization + WebSite in the root layout (the entity anchor)
This is the single most important one for branded search. If you do nothing else, do this.
In src/app/layout.tsx, I run buildOrganizationJsonLd() once per request. It emits:
{
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'CandleDojo',
alternateName: 'Candle Dojo',
url: 'https://candledojo.app',
logo: 'https://candledojo.app/favicon.svg',
description: '...',
contactPoint: [{ '@type': 'ContactPoint', contactType: 'customer support', ... }],
sameAs: [
'https://x.com/candledojo',
'https://discord.gg/...',
'https://www.youtube.com/@candledojo',
'https://www.reddit.com/user/CandleDojo/',
],
}
Two things matter here more than anything else.
sameAs. Every owned profile, listed. X, YouTube, Discord, Reddit, GitHub if public, dev.to author page. This is how Google connects your brand name to a set of identities it can cross-verify. The more consistent profiles you list, the faster "candledojo" becomes a resolved entity.
alternateName. Search users type "candle dojo" as two words too. Telling Google both variants point at the same entity saves you a ranking split.
I pair Organization with a separate WebSite schema on the homepage. Same idea, narrower scope: here's the site, here's its name, here's the canonical URL. Two entity anchors reinforce each other.
Schema 2: WebApplication on the homepage
If you're an app, not a blog, tell Google. A content-only schema stack is why so many SaaS tools get treated as articles.
{
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'CandleDojo',
applicationCategory: 'EducationalApplication',
operatingSystem: 'Web',
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
}
applicationCategory and operatingSystem unlock app-style rich results eligibility. offers is where you declare freemium or paid tiers. If you have honest review volume, add aggregateRating. I don't yet, so I leave it off. Stuffing fake ratings is the fastest way to earn a manual action.
Schema 3: BreadcrumbList on every nested route
The laziest high-leverage schema. Takes 10 lines per route, gives you rich breadcrumb links in SERPs, and tells Google exactly how your site is structured.
On an insights page, I inject:
buildBreadcrumbJsonLd([
{ name: 'Home', item: 'https://candledojo.app' },
{ name: 'Insights', item: 'https://candledojo.app/insights' },
{ name: report.title, item: canonicalUrl },
])
Every nested route on CandleDojo does this: /patterns, /guides, /insights, /vs, /tools. The App Router makes it trivial because each route file already knows its own URL.
Schema 4: Article + HowTo on guide pages (the combo)
For long-form pages that explain something, Article plus HowTo is the pairing.
Article gets you eligibility for the Top Stories carousel, date-surfaced results, and author byline display. HowTo gets you steppable rich results (though Google has narrowed the visual treatment, the entity signal still matters).
My buildArticleJsonLd() helper spits out:
{
'@context': 'https://schema.org',
'@type': 'Article',
headline,
description,
url,
mainEntityOfPage: url,
inLanguage: 'en',
author: { '@type': 'Organization', name: 'CandleDojo', url: '...' },
publisher: buildPublisher(),
datePublished,
dateModified,
image: [image],
keywords,
about: about.map(name => ({ '@type': 'Thing', name })),
articleSection: section,
}
Two details people mess up:
-
mainEntityOfPagemust equal the canonical URL. Not a tracking param version, not a trailing-slash mismatch. Google will silently ignore mismatched entities. -
dateModifiedshould update when the page actually changes. Don't set it equal todatePublishedforever. Freshness signals help.
For the HowTo pair, I only emit it when the page genuinely has steps. Don't synthesize steps just to qualify. Google's Rich Results Test will accept it, then Google Search itself will quietly not surface it and you'll have wasted markup.
Schema 5: DefinedTerm on glossary and pattern pages
This one is underused and it's my favorite.
I have 12 candlestick pattern pages on CandleDojo (hammer, doji, engulfing, etc.). Each page is essentially a definition plus a guide. DefinedTerm is the schema built for exactly this:
{
'@context': 'https://schema.org',
'@type': 'DefinedTerm',
'@id': url,
url,
name: 'Bullish Engulfing',
description: '...',
inDefinedTermSet: 'https://candledojo.app/patterns',
about: [{ '@type': 'Thing', name: 'candlestick patterns' }],
}
Why I like it for AI citations specifically: LLMs building answers for "what is a bullish engulfing pattern" prefer to cite sources that expose structured definitions. DefinedTerm is cleaner than Article for single-concept pages and reduces the chance of the crawler pulling the wrong paragraph.
inDefinedTermSet groups all your terms under one glossary URL. It's the schema equivalent of saying "this term is part of my taxonomy, here's the index."
Validate and measure
The boring but critical part.
- Google Rich Results Test. Run every page type through it after shipping. It will tell you what it parsed and what it rejected. Screenshot the detected schemas list for your own records.
- GSC Enhancements. After ~2 weeks, Google Search Console will populate Enhancement reports for each schema it found (Breadcrumbs, Sitelinks, Logo, etc.). If a schema doesn't show up, it wasn't parsed at scale.
- Brand query panel. GSC Performance, filter query = your brand name. Watch impressions and average position for the branded term specifically. That's your scoreboard.
For AI citations, there's no dashboard. I just periodically run two queries in Perplexity and compare.
The first is a branded query: "what is candledojo". This tells me whether LLMs have resolved my brand as a distinct entity yet. Ideally the top answer cites candledojo.app directly.
The second is a category query: "best candlestick pattern trainer" or "best trainer app for reading price action". This tells me whether my pages show up when someone is not already looking for me by name. That's the harder win and the one that drives new traffic.
Branded citation means entity resolution is working. Category citation means topical authority is starting to stick. Both matter. Right now I get intermittent branded citations and almost zero category citations. The goal of stacking DefinedTerm, Article, and HowTo across my content pages is to move that second needle.
What I'd skip
- FAQPage schema for non-support pages. Google nerfed it in 2023. It's still parseable but only surfaces on government and health sites now. Waste of markup on a product page.
- Product schema for SaaS. Use WebApplication or SoftwareApplication instead. Product is for physical goods and triggers price-snippet rules that don't map cleanly to software.
-
Over-nesting. Don't inline your Organization inside every Article's
publisher. Reference it by@idwhere you can, otherwise you balloon the JSON-LD weight and Google starts dropping payloads. - Review schema you don't control. If testimonials aren't from a verifiable source, leave them out. The risk of a manual action outweighs the rich result.
Closing
Structured data isn't going to single-handedly beat an aggregator that's been indexed since 2019. What it does is stack entity signals so that every page on your site tells the same story: this brand, this URL, these topics, this category.
I'm still losing my brand SERP today. Every week I check GSC, and every week the gap is a bit smaller. The JSON-LD stack above is one of the levers I'm pulling. The other is just shipping more content that gets linked with my brand name as anchor text. That's partly why I'm writing this post.
If you want to see the full schema stack live, pull up CandleDojo and view source. Everything in <script type="application/ld+json"> is the output of one of the helpers above. Copy what's useful.
And if you're fighting the same branded-SERP problem, drop the schemas that worked for you in the comments. I want more data points.





Top comments (0)