Compare commits

...

13 Commits

Author SHA1 Message Date
johannes.gasser e3cdb466a3 updated font & text width
Build and Deploy / build-and-deploy (push) Successful in 10m55s
2026-07-21 22:25:10 +02:00
johannes.gasser ce83c36f3a remove translations
Build and Deploy / build-and-deploy (push) Successful in 7m40s
2026-05-18 14:37:33 +02:00
johannes.gasser 10c7ab699f remove translations
Build and Deploy / build-and-deploy (push) Failing after 1m5s
2026-05-18 12:57:33 +02:00
johannes.gasser 0d02c1f1c9 fix adapter
Build and Deploy / build-and-deploy (push) Successful in 7m33s
2026-05-18 11:25:03 +02:00
johannes.gasser 8d11befb6e add ssr
Build and Deploy / build-and-deploy (push) Successful in 6m24s
2026-05-18 11:06:00 +02:00
johannes.gasser baaef6e528 add ssr
Build and Deploy / build-and-deploy (push) Successful in 8m0s
2026-05-18 10:45:42 +02:00
johannes.gasser fb5aa07373 add ssr
Build and Deploy / build-and-deploy (push) Failing after 36s
2026-05-18 10:34:38 +02:00
johannes.gasser 57af0b8386 add skills
Build and Deploy / build-and-deploy (push) Successful in 2m53s
2026-05-18 08:39:42 +02:00
johannes.gasser 92a80e8759 added image support
Build and Deploy / build-and-deploy (push) Successful in 6m50s
2026-05-18 07:01:11 +02:00
johannes.gasser 58f7ca2d5c mobile optimization
Build and Deploy / build-and-deploy (push) Successful in 4m40s
2026-05-09 08:05:09 +02:00
johannes.gasser 2d80e7ad49 fix build
Build and Deploy / build-and-deploy (push) Successful in 3m32s
2026-05-07 10:05:24 +02:00
johannes.gasser 2a0f7d5bcf add missing files
Build and Deploy / build-and-deploy (push) Failing after 2m42s
2026-05-07 09:46:44 +02:00
johannes.gasser bf04f8679d switch from tinacms to sanity 2026-05-07 09:36:34 +02:00
102 changed files with 21716 additions and 25003 deletions
@@ -0,0 +1,39 @@
---
name: content-experimentation-best-practices
description: Content experimentation and A/B testing guidance covering experiment design, hypotheses, metrics, sample size, statistical foundations, CMS-managed variants, and common analysis pitfalls. Use this skill when planning experiments, setting up variants, choosing success metrics, interpreting statistical results, or building experimentation workflows in a CMS or frontend stack.
---
# Content Experimentation Best Practices
Principles and patterns for running effective content experiments to improve conversion rates, engagement, and user experience.
## When to Apply
Reference these guidelines when:
- Setting up A/B or multivariate testing infrastructure
- Designing experiments for content changes
- Analyzing and interpreting test results
- Building CMS integrations for experimentation
- Deciding what to test and how
## Core Concepts
### A/B Testing
Comparing two variants (A vs B) to determine which performs better.
### Multivariate Testing
Testing multiple variables simultaneously to find optimal combinations.
### Statistical Significance
The confidence level that results aren't due to random chance.
### Experimentation Culture
Making decisions based on data rather than opinions (HiPPO avoidance).
## References
Start with the reference that matches the current problem, such as design, statistics, CMS integration, or pitfalls. See `references/` for detailed guidance:
- `references/experiment-design.md` — Hypothesis framework, metrics, sample size, and what to test
- `references/statistical-foundations.md` — p-values, confidence intervals, power analysis, Bayesian methods
- `references/cms-integration.md` — CMS-managed variants, field-level variants, external platforms
- `references/common-pitfalls.md` — 17 common mistakes across statistics, design, execution, and interpretation
@@ -0,0 +1,223 @@
# CMS Integration Patterns
Integrating experimentation with your CMS enables content teams to run tests without developer intervention.
## Architecture Options
### 1. CMS-Managed Variants
Store experiment variants as content in the CMS.
**Pros:** Content team autonomy, version controlled
**Cons:** More complex queries, potential publish coordination
```typescript
// Experiment document
defineType({
name: 'experiment',
type: 'document',
fields: [
defineField({ name: 'name', type: 'string' }),
defineField({ name: 'status', type: 'string', options: {
list: ['draft', 'running', 'paused', 'concluded']
}}),
defineField({
name: 'variants',
type: 'array',
of: [{
type: 'object',
fields: [
defineField({ name: 'name', type: 'string' }),
defineField({ name: 'weight', type: 'number' }),
defineField({ name: 'content', type: 'reference', to: [{ type: 'page' }] }),
]
}]
}),
defineField({ name: 'startDate', type: 'datetime' }),
defineField({ name: 'endDate', type: 'datetime' }),
]
})
```
### 2. Field-Level Variants
Store variants as fields on the content document.
**Pros:** Simpler queries, content stays together
**Cons:** Less flexible, schema complexity
```typescript
defineType({
name: 'landingPage',
fields: [
defineField({ name: 'headline', type: 'string' }),
defineField({
name: 'headlineVariantB',
type: 'string',
description: 'A/B test variant (leave empty if not testing)'
}),
defineField({ name: 'activeExperiment', type: 'string' }),
]
})
```
### 3. External Experimentation Platform
Use dedicated tools (Optimizely, LaunchDarkly, VWO) with CMS content.
**Pros:** Robust analytics, proven platforms
**Cons:** Additional cost, integration complexity
```typescript
// CMS stores experiment IDs, platform handles assignment
defineField({
name: 'experimentId',
type: 'string',
description: 'Optimizely experiment ID'
})
```
## Implementation Pattern (CMS-Managed)
### 1. Experiment Schema
```typescript
defineType({
name: 'experiment',
type: 'document',
fields: [
defineField({ name: 'name', type: 'string', validation: r => r.required() }),
defineField({ name: 'hypothesis', type: 'text' }),
defineField({
name: 'status',
type: 'string',
options: { list: ['draft', 'running', 'concluded'] },
initialValue: 'draft'
}),
defineField({
name: 'variants',
type: 'array',
of: [{
type: 'object',
name: 'variant',
fields: [
defineField({ name: 'id', type: 'string' }),
defineField({ name: 'name', type: 'string' }),
defineField({ name: 'weight', type: 'number', initialValue: 50 }),
]
}],
validation: r => r.min(2).error('Need at least 2 variants')
}),
defineField({ name: 'targetPage', type: 'reference', to: [{ type: 'page' }] }),
defineField({ name: 'targetField', type: 'string' }),
]
})
```
### 2. Variant Content
```typescript
// On the page being tested
defineField({
name: 'experimentVariants',
type: 'array',
of: [{
type: 'object',
fields: [
defineField({ name: 'experimentId', type: 'reference', to: [{ type: 'experiment' }] }),
defineField({ name: 'variantId', type: 'string' }),
defineField({ name: 'headline', type: 'string' }),
// Other variant-specific fields
]
}]
})
```
### 3. Frontend Assignment
```typescript
// Middleware or server-side
function assignVariant(experimentId: string, variants: Variant[]): string {
// Check for existing assignment in cookie
const cookieKey = `exp_${experimentId}`
const existing = getCookie(cookieKey)
if (existing) return existing
// Random assignment based on weights
const rand = Math.random() * 100
let cumulative = 0
for (const variant of variants) {
cumulative += variant.weight
if (rand <= cumulative) {
setCookie(cookieKey, variant.id, { maxAge: 30 * 24 * 60 * 60 })
return variant.id
}
}
return variants[0].id
}
```
### 4. Query with Variant
```groq
*[_type == "page" && slug.current == $slug][0]{
...,
"experiment": experimentVariants[experimentId->status == "running"][0]{
experimentId->{name, _id},
variantId,
headline
}
}
```
## Analytics Integration
### Event Tracking
```typescript
// Track experiment exposure
function trackExposure(experimentId: string, variantId: string) {
analytics.track('Experiment Viewed', {
experimentId,
variantId,
timestamp: new Date().toISOString()
})
}
// Track conversion
function trackConversion(experimentId: string, variantId: string, metric: string) {
analytics.track('Experiment Conversion', {
experimentId,
variantId,
metric,
timestamp: new Date().toISOString()
})
}
```
### Data Layer
```typescript
// Push to data layer for analytics tools
window.dataLayer.push({
event: 'experiment_assignment',
experiment_id: experimentId,
variant_id: variantId
})
```
## Best Practices
### Content Team Workflow
1. Create experiment document with hypothesis
2. Create variant content
3. Set status to "running"
4. Monitor results
5. Set status to "concluded" and record winner
### Avoid Flicker
- Assign variants server-side when possible
- Use CSS to hide content until variant determined
- Pre-render both variants, show based on assignment
### Clean Up
- Archive concluded experiments
- Remove losing variant content
- Implement winner as default
@@ -0,0 +1,200 @@
# Common Experimentation Pitfalls
Avoid these mistakes that invalidate results or lead to wrong conclusions.
## Statistical Mistakes
### 1. Stopping Early (Peeking)
**The problem:** Checking results daily and stopping when you see significance.
**Why it's wrong:** Statistical significance fluctuates. At any point during a test, you might see "significance" that disappears with more data. This is called the "peeking problem" or "repeated significance testing."
**The fix:**
- Pre-calculate required sample size
- Commit to running until you reach it
- If you must peek, use sequential testing methods that account for multiple looks
### 2. Underpowered Tests
**The problem:** Running tests without enough traffic to detect realistic effect sizes.
**Why it's wrong:** You'll conclude "no difference" when there actually is one—you just couldn't detect it.
**The fix:**
- Calculate required sample size before starting
- Be realistic about minimum detectable effect (can you act on a 0.5% improvement?)
- If traffic is low, test bigger changes
### 3. Multiple Comparisons
**The problem:** Testing many variants or metrics and celebrating any that reach significance.
**Why it's wrong:** With 20 metrics, you expect 1 false positive at 95% confidence—by chance alone.
**The fix:**
- Define ONE primary metric before starting
- Use Bonferroni correction or similar for multiple comparisons
- Treat secondary metrics as directional, not conclusive
### 4. Ignoring Segments
**The problem:** Only looking at aggregate results.
**Why it's wrong:** Simpson's Paradox—overall winner might be loser for your key segments.
**The fix:**
- Always segment by device, traffic source, user type
- Check if results are consistent across segments
- If segments differ dramatically, investigate why
## Design Mistakes
### 5. Testing Too Many Things
**The problem:** Changing headline, image, CTA, and layout simultaneously.
**Why it's wrong:** You won't know which change caused the result. And each variable multiplies required sample size.
**The fix:**
- Test one variable at a time (A/B testing)
- If testing multiple, use proper multivariate testing with adequate sample size
- Prioritize highest-impact changes first
### 6. Vague Hypothesis
**The problem:** "Let's see if this new design is better."
**Why it's wrong:** Without a hypothesis, you can't learn WHY something worked (or didn't).
**The fix:**
- State: "We believe [change] will [impact metric] because [reasoning]"
- Even if you're wrong, you learn something
### 7. No Control
**The problem:** Changing the control during the test, or not having one.
**Why it's wrong:** You need a stable baseline to compare against.
**The fix:**
- Never modify the control mid-test
- If you must change it, start a new test
- Document exactly what the control is
## Execution Mistakes
### 8. External Contamination
**The problem:** Running a test during a sale, holiday, or major event.
**Why it's wrong:** External factors affect both variants differently, contaminating results.
**The fix:**
- Avoid tests during unusual periods
- If unavoidable, note it and extend the test past the event
- Compare to the same period historically
### 9. Selection Bias
**The problem:** Testing on a non-representative sample (e.g., only logged-in users).
**Why it's wrong:** Results won't generalize to your full audience.
**The fix:**
- Test on representative traffic
- Be explicit about who's included/excluded
- Note limitations when reporting results
### 10. Implementation Bugs
**The problem:** Variants don't render correctly, tracking fires incorrectly, assignment is biased.
**Why it's wrong:** You're not testing what you think you're testing.
**The fix:**
- QA both variants thoroughly before launch
- Verify tracking events fire correctly
- Check assignment distribution matches weights
## Interpretation Mistakes
### 11. Celebrating Trivial Wins
**The problem:** Implementing a change because it was "statistically significant" even though the effect was tiny.
**Why it's wrong:** Statistical significance ≠ practical significance. A 0.01% improvement isn't worth the complexity.
**The fix:**
- Define minimum meaningful effect before starting
- Consider implementation cost vs. benefit
- Don't over-optimize
### 12. Ignoring Confidence Intervals
**The problem:** Only reporting point estimates ("5% improvement!").
**Why it's wrong:** The true effect could be anywhere in the confidence interval.
**The fix:**
- Report confidence intervals: "5% improvement (95% CI: 2%-8%)"
- Base decisions on the lower bound for conservative estimates
- Wider intervals = more uncertainty
### 13. Not Documenting Learnings
**The problem:** Running tests but not recording what you learned.
**Why it's wrong:** You'll repeat mistakes, forget context, lose institutional knowledge.
**The fix:**
- Document every test: hypothesis, results, learnings
- Include what surprised you
- Build a searchable knowledge base
## Organizational Mistakes
### 14. HiPPO (Highest Paid Person's Opinion)
**The problem:** Running experiments but ignoring results when leadership disagrees.
**Why it's wrong:** Defeats the purpose of data-driven decision making.
**The fix:**
- Get buy-in before testing that results will be honored
- Present data clearly to stakeholders
- Frame as "learning" not "winning/losing"
### 15. Testing Everything
**The problem:** Running experiments on trivial changes that don't matter.
**Why it's wrong:** Wastes resources, creates testing fatigue, delays important experiments.
**The fix:**
- Prioritize tests by potential impact
- Not everything needs a test—use judgment for low-risk changes
- Focus experimentation resources on high-value decisions
### 16. Sample Ratio Mismatch (SRM)
**The problem:** The actual traffic split doesn't match the intended split (e.g., you expect 50/50 but observe 52/48).
**Why it's wrong:** SRM is a strong signal of an implementation bug — broken randomization, bot contamination, or redirect issues. Results from experiments with SRM cannot be trusted.
**The fix:**
- Check the actual split ratio against expected before analyzing results
- Use a chi-squared test to detect statistically significant mismatches
- If SRM is detected, investigate the root cause before drawing any conclusions
- Common causes: bot traffic, browser redirects dropping users, bucketing bugs
### 17. Novelty and Primacy Effects
**The problem:** Users react differently to new designs initially, and the effect fades over time.
**Why it's wrong:** Short experiments may show inflated effects that don't persist. Returning users may click more simply because something looks new.
**The fix:**
- Run experiments for at least 2 full business cycles
- Segment results by new vs. returning users
- If possible, check whether the effect holds in the second week vs. the first
@@ -0,0 +1,109 @@
# Experiment Design Principles
Well-designed experiments produce actionable insights. Poorly designed ones waste time and can mislead.
## The Experiment Framework
### 1. Hypothesis
State what you believe and why.
**Bad:** "Let's test a new headline"
**Good:** "We believe a benefit-focused headline will increase signup rate by 10% because users are currently confused about our value proposition"
Structure: "We believe [change] will [impact metric] because [reasoning]"
### 2. Success Metric
Define primary and guardrail metrics.
**Primary metric:** The main thing you're trying to improve (conversion rate, engagement time)
**Guardrail metrics:** Things that shouldn't get worse (bounce rate, page load time)
### 3. Sample Size
Calculate required sample size before starting.
Factors:
- Baseline conversion rate
- Minimum detectable effect (MDE)
- Statistical significance level (usually 95%)
- Statistical power (usually 80%)
Use calculators like [Evan Miller's](https://www.evanmiller.org/ab-testing/sample-size.html).
### 4. Duration
Run tests for full business cycles.
- Minimum: 1-2 weeks (capture weekly patterns)
- Include weekends
- Avoid holidays and major events
- Don't stop early when you see "winning" results
## What to Test
### High-Impact Areas
- Headlines and value propositions
- Call-to-action text and placement
- Form length and fields
- Pricing presentation
- Social proof placement
### Lower-Impact (Usually)
- Button colors
- Minor copy tweaks
- Image variations (unless hero)
- Footer changes
### Test Priority Matrix
| Impact | Effort | Priority |
|--------|--------|----------|
| High | Low | Do first |
| High | High | Plan carefully |
| Low | Low | Quick wins |
| Low | High | Avoid |
## Sanity Integration Pattern
```typescript
// Experiment variant schema
defineType({
name: 'experimentVariant',
type: 'object',
fields: [
defineField({ name: 'name', type: 'string' }),
defineField({ name: 'weight', type: 'number', description: 'Traffic allocation (0-100)' }),
defineField({ name: 'content', type: 'reference', to: [{ type: 'page' }] }),
]
})
// Experiment document
defineType({
name: 'experiment',
type: 'document',
fields: [
defineField({ name: 'name', type: 'string' }),
defineField({ name: 'hypothesis', type: 'text' }),
defineField({ name: 'status', type: 'string', options: {
list: ['draft', 'running', 'concluded']
}}),
defineField({ name: 'variants', type: 'array', of: [{ type: 'experimentVariant' }] }),
defineField({ name: 'startDate', type: 'datetime' }),
defineField({ name: 'endDate', type: 'datetime' }),
defineField({ name: 'winner', type: 'string' }),
defineField({ name: 'learnings', type: 'text' }),
]
})
```
## Avoiding Common Mistakes
### Don't peek and stop early
Statistical significance can fluctuate. Commit to your sample size.
### Don't test too many things at once
Each variable multiplies required sample size.
### Don't ignore segmentation
Winners may differ by device, traffic source, or user type.
### Document everything
Future you (and your team) will thank you.
@@ -0,0 +1,150 @@
# Statistical Foundations
Understanding basic statistics prevents misinterpreting experiment results.
## Table of Contents
- Key concepts
- Sample size calculation
- Common statistical mistakes
- Interpreting results
- Alternative approaches
- When to trust results
## Key Concepts
### Statistical Significance
A measure of whether observed differences are likely real or due to chance.
- **p-value < 0.05:** "Statistically significant" at 95% confidence
- Means: If there were no real difference, there's less than a 5% chance of seeing results this extreme
- Does NOT mean: The change is important or meaningful
- **Common misconception:** The p-value is NOT "the probability the result is due to chance." It's the probability of observing data this extreme *assuming* the null hypothesis is true.
### Confidence Interval
A range of plausible values for the true effect.
Example: "Conversion rate increased by 5% (95% CI: 2% to 8%)"
- Best estimate: 5% improvement
- Could be as low as 2% or as high as 8%
- Narrower intervals = more certainty
### Statistical Power
The ability to detect a real effect when it exists.
- Standard: 80% power
- Higher power = larger sample size needed
- Low power = might miss real improvements
### Minimum Detectable Effect (MDE)
The smallest improvement worth detecting.
- Smaller MDE = larger sample size needed
- Be realistic: Can you act on a 0.5% improvement?
## Sample Size Calculation
Before running a test, calculate required sample size:
```
Required per variant = 16 × σ² / MDE²
Where:
- σ² = variance (for conversion rate: p × (1-p))
- MDE = minimum detectable effect (absolute)
```
For a 5% baseline conversion rate, detecting a 1% absolute lift (5% → 6%):
- σ² = 0.05 × 0.95 = 0.0475
- MDE² = 0.01² = 0.0001
- n = 16 × 0.0475 / 0.0001 = **7,600 per variant**
- Total: ~15,200 visitors minimum
## Common Statistical Mistakes
### Multiple Comparisons Problem
Testing 10 variants increases false positive rate.
**Solution:** Adjust significance threshold (Bonferroni correction) or use sequential testing methods.
### Peeking Problem
Checking results daily and stopping when significant.
**Why it's wrong:** Significance fluctuates. Early "winners" often regress.
**Solution:** Pre-commit to sample size and duration. Use sequential testing if you must peek.
### Simpson's Paradox
Overall results hide segmented truths.
Example:
- Overall: Variant B wins
- Mobile users: Variant A wins
- Desktop users: Variant A wins
- How? Different traffic mix per variant
**Solution:** Always segment by major factors (device, traffic source).
### Survivorship Bias
Only analyzing users who completed the funnel.
**Solution:** Include all visitors, not just converters.
## Interpreting Results
### Significant + Meaningful
Clear win. Implement the change.
### Significant + Trivial
Statistically different but tiny effect. Consider if worth the complexity.
### Not Significant + Large Effect
Might be real but underpowered. Extend the test or accept uncertainty.
### Not Significant + Small Effect
No detectable difference. Either no real effect or test was underpowered.
## Alternative Approaches
### Bayesian A/B Testing
An alternative to traditional (frequentist) hypothesis testing. Bayesian methods provide:
- **Direct probability statements:** "There's a 95% probability Variant B is better" (more intuitive than p-values)
- **No peeking problem:** Continuous monitoring is built in — you can check results at any time
- **Credible intervals:** Directly interpretable as "the true value falls in this range with X% probability"
Bayesian methods are offered by platforms like VWO and are useful when you need to make decisions with limited traffic or want more intuitive reporting for stakeholders.
### Multi-Armed Bandits
Dynamically allocate more traffic to winning variants while still learning:
- **Thompson Sampling:** Balances exploration (learning) with exploitation (serving the best variant)
- **Best for:** Ongoing optimization where you want to minimize regret during the test
- **Trade-off:** Faster convergence to the winner, but less statistical rigor than fixed-allocation A/B tests
Consider bandits for content recommendations, personalization, or situations where the cost of showing a losing variant is high.
### Sequential Testing
For teams that need to monitor experiments continuously:
- **Group sequential designs** (O'Brien-Fleming, Lan-DeMets) allow pre-planned interim analyses
- **Always-valid p-values** let you check results at any time without inflating false positive rates
- Use when you must balance the peeking problem with business pressure to act on results quickly
## When to Trust Results
Checklist before declaring a winner:
- [ ] Reached pre-calculated sample size
- [ ] Ran for full business cycle (1-2 weeks minimum)
- [ ] p-value < 0.05 (or your chosen threshold)
- [ ] Effect size is meaningful for business
- [ ] Results consistent across major segments
- [ ] No external factors contaminated results
@@ -0,0 +1,32 @@
---
name: content-modeling-best-practices
description: Structured content modeling guidance for schema design, content architecture, content reuse, references versus embedded objects, separation of concerns, and taxonomies across Sanity and other headless CMSes. Use this skill when designing or refactoring content types, deciding field shapes, debating reusable versus nested content, planning omnichannel content models, or reviewing whether a schema is too page-shaped or presentation-driven.
---
# Content Modeling Best Practices
Principles for designing structured content that's flexible, reusable, and maintainable. These concepts apply to any headless CMS but include Sanity-specific implementation notes.
## When to Apply
Reference these guidelines when:
- Starting a new project and designing the content model
- Evaluating whether content should be structured or free-form
- Deciding between references and embedded content
- Planning for multi-channel content delivery
- Refactoring existing content structures
## Core Principles
1. **Content is data, not pages** — Structure content for meaning, not presentation
2. **Single source of truth** — Avoid content duplication
3. **Future-proof** — Design for channels that don't exist yet
4. **Editor-centric** — Optimize for the people creating content
## References
Start with the reference that matches the modeling decision in front of you, instead of loading every topic at once. See `references/` for detailed guidance on specific topics:
- `references/separation-of-concerns.md` — Separating content from presentation
- `references/reference-vs-embedding.md` — When to use references vs embedded objects
- `references/content-reuse.md` — Content reuse patterns and the reuse spectrum
- `references/taxonomy-classification.md` — Flat, hierarchical, and faceted classification
@@ -0,0 +1,134 @@
# Content Reuse Patterns
Effective content models maximize reuse while minimizing duplication. Here are patterns for achieving both.
## The Content Reuse Spectrum
```
Full Duplication ←————————————————→ Full Reference
(Copy everything) (Link to one source)
```
Most real-world content sits somewhere in between.
## Pattern 1: Shared Components
Create reusable content blocks that can be embedded anywhere.
**Use case:** Testimonials, FAQs, CTAs that appear on multiple pages.
```typescript
// Standalone testimonial documents
defineType({
name: 'testimonial',
type: 'document',
fields: [
defineField({ name: 'quote', type: 'text' }),
defineField({ name: 'author', type: 'string' }),
defineField({ name: 'company', type: 'string' }),
]
})
// Reference in page builders
defineField({
name: 'pageBuilder',
type: 'array',
of: [
{ type: 'reference', to: [{ type: 'testimonial' }] }
]
})
```
## Pattern 2: Shared Field Sets
Extract common fields into reusable definitions.
**Use case:** SEO fields, social metadata, common dates.
```typescript
// Shared field definition
export const seoFields = [
defineField({ name: 'seoTitle', type: 'string' }),
defineField({ name: 'seoDescription', type: 'text' }),
defineField({ name: 'ogImage', type: 'image' }),
]
// Spread into multiple types
defineType({
name: 'page',
fields: [
defineField({ name: 'title', type: 'string' }),
...seoFields
]
})
defineType({
name: 'post',
fields: [
defineField({ name: 'title', type: 'string' }),
...seoFields
]
})
```
## Pattern 3: Taxonomy References
Centralize classification for consistent tagging.
**Use case:** Categories, tags, topics that span content types.
```typescript
// Central taxonomy
defineType({
name: 'category',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'slug', type: 'slug' }),
]
})
// Used across content types
defineField({
name: 'categories',
type: 'array',
of: [{ type: 'reference', to: [{ type: 'category' }] }]
})
```
## Pattern 4: Content Fragments
Small, reusable pieces that combine into larger content.
**Use case:** Bios, addresses, contact info.
```typescript
// Fragment type
defineType({
name: 'contactInfo',
type: 'object',
fields: [
defineField({ name: 'email', type: 'email' }),
defineField({ name: 'phone', type: 'string' }),
defineField({ name: 'address', type: 'text' }),
]
})
// Reused across types
defineType({
name: 'office',
fields: [
defineField({ name: 'name', type: 'string' }),
defineField({ name: 'contact', type: 'contactInfo' }),
]
})
```
## Anti-Pattern: Over-Abstraction
Not everything needs to be reusable. If content is only used in one place, embedding is simpler.
**Signs of over-abstraction:**
- References that are only used once
- Editors navigating multiple documents for one page
- Complex queries joining rarely-shared content
@@ -0,0 +1,89 @@
# Reference vs Embedding Content
When should content be linked (referenced) vs copied (embedded)? This decision affects reusability, query complexity, and editing workflows.
## The Trade-offs
| Aspect | Reference | Embedded Object |
|--------|-----------|-----------------|
| Reusability | ✅ Shared across documents | ❌ Copied per document |
| Single source | ✅ Update once, reflects everywhere | ❌ Must update each copy |
| Query complexity | Requires joins/expansion | Inline, simpler queries |
| Editing UX | Separate editing interface | All fields in one place |
| Independence | Can exist on its own | Only exists within parent |
## When to Reference
Use references when content:
- **Is reusable** — Same author across many articles
- **Needs central management** — Update product info once
- **Has its own lifecycle** — Published/draft independent of parent
- **Should stay in sync** — Price changes reflect everywhere
**Examples:**
- Author profiles
- Product catalog items
- Shared testimonials
- Category taxonomy
- Reusable CTAs
## When to Embed
Use embedded objects when content:
- **Is unique to this document** — Page-specific hero
- **Doesn't make sense alone** — SEO metadata
- **Should be copied, not linked** — Historical snapshot
- **Simplifies editing** — All fields in one form
**Examples:**
- SEO metadata
- Page-specific sections
- Address information
- Social links
- Configuration options
## Sanity Implementation
```typescript
// Reference: Author is reusable
defineField({
name: 'author',
type: 'reference',
to: [{ type: 'author' }]
})
// Embedded: SEO is page-specific
defineField({
name: 'seo',
type: 'object',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'description', type: 'text' })
]
})
```
## The Hybrid Approach
Sometimes you want both: a reference for the canonical data, plus embedded overrides.
```typescript
defineField({
name: 'featuredProduct',
type: 'object',
fields: [
defineField({
name: 'product',
type: 'reference',
to: [{ type: 'product' }]
}),
defineField({
name: 'overrideTitle',
type: 'string',
description: 'Optional: Override the product title for this context'
}),
]
})
```
Query uses `coalesce(overrideTitle, product->title)`.
@@ -0,0 +1,60 @@
# Separation of Content and Presentation
The most important principle in structured content: **separate what content IS from how it LOOKS**.
## The Problem
When content is tied to presentation:
- Redesigns require content migration
- Content can't be reused across channels (web, mobile, voice)
- Editors make design decisions instead of content decisions
- A/B testing requires duplicate content
## The Principle
Model content based on **meaning and purpose**, not visual appearance.
### Bad: Presentation-Focused
```
BigHeroText → What if we want small heroes?
RedButton → What if brand colors change?
ThreeColumnLayout → What if mobile needs one column?
LeftSidebar → Position is a frontend concern
MobileImage → Device-specific content is fragile
```
### Good: Meaning-Focused
```
Headline → The main message (render however)
CallToAction → An action we want users to take
Features → A list of things (columns decided by frontend)
RelatedContent → Content relationships (position by context)
Image → One image with responsive crops
```
## Testing Your Model
Ask: "If we completely redesigned the site, would these field names still make sense?"
- `threeColumnFeatures` → ❌ Fails (what if 2 columns?)
- `features` → ✅ Works (describes the content's purpose: a list of product features)
- `blueHighlightBox` → ❌ Fails (what if we go purple?)
- `callout` → ✅ Works (describes the content's role: an attention-grabbing aside)
## Sanity Implementation
```typescript
// ❌ Avoid presentation-focused names
defineField({ name: 'bigHeroText', type: 'string' })
defineField({ name: 'fontSize', type: 'number' })
defineField({ name: 'backgroundColor', type: 'color' })
// ✅ Use meaning-focused names
defineField({ name: 'headline', type: 'string' })
defineField({ name: 'emphasis', type: 'string', options: { list: ['standard', 'prominent'] } })
defineField({ name: 'tone', type: 'string', options: { list: ['neutral', 'warning', 'success'] } })
```
The frontend translates `tone: 'warning'` to visual styles. Content stays semantic.
@@ -0,0 +1,136 @@
# Taxonomy and Classification
Organizing content with taxonomies enables filtering, navigation, and content relationships. Well-designed taxonomies scale; poorly designed ones become maintenance nightmares.
## Types of Classification
### Flat Taxonomy
Simple list of terms with no hierarchy.
**Use for:** Tags, simple categories
**Example:** Blog tags: "javascript", "react", "tutorial"
```typescript
defineType({
name: 'tag',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'slug', type: 'slug' }),
]
})
```
### Hierarchical Taxonomy
Terms with parent-child relationships.
**Use for:** Product categories, content sections
**Example:** Electronics > Phones > Smartphones
```typescript
defineType({
name: 'category',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'slug', type: 'slug' }),
defineField({
name: 'parent',
type: 'reference',
to: [{ type: 'category' }],
description: 'Parent category (leave empty for top-level)'
}),
]
})
```
### Faceted Classification
Multiple independent dimensions.
**Use for:** Complex filtering (e-commerce)
**Example:** Filter by color AND size AND price range
```typescript
// Multiple taxonomy types
defineField({ name: 'color', type: 'reference', to: [{ type: 'color' }] })
defineField({ name: 'size', type: 'reference', to: [{ type: 'size' }] })
defineField({ name: 'material', type: 'reference', to: [{ type: 'material' }] })
```
## Design Principles
### 1. Mutual Exclusivity (When Appropriate)
Categories should be distinct. If items frequently belong to multiple categories, consider tags instead.
**Categories:** One primary classification
**Tags:** Many optional classifications
### 2. User-Centric Naming
Use terms your audience uses, not internal jargon.
**Bad:** "Content Assets" (internal term)
**Good:** "Resources" or "Downloads" (user term)
### 3. Balanced Depth
Too shallow: Everything lumped together
Too deep: Users can't find anything
**Rule of thumb:** 3-4 levels max for hierarchies
### 4. Scalable Structure
Design for 10x growth. Will your structure work with 10,000 items?
## Querying Taxonomies
### Get all items in a category
```groq
*[_type == "product" && category._ref == $categoryId]
```
### Get items in category OR children
```groq
// First get all descendant category IDs
*[_type == "product" && category._ref in
*[_type == "category" && (
_id == $categoryId ||
parent._ref == $categoryId ||
parent->parent._ref == $categoryId
)]._id
]
```
### Get category tree
```groq
*[_type == "category" && !defined(parent)]{
title,
slug,
"children": *[_type == "category" && parent._ref == ^._id]{
title,
slug,
"children": *[_type == "category" && parent._ref == ^._id]{
title,
slug
}
}
}
```
## Common Mistakes
### Over-categorization
Creating a category for everything results in mostly-empty categories.
**Fix:** Start minimal, add categories as content grows.
### Inconsistent Granularity
Some categories broad ("Technology"), others narrow ("React 18 Server Components").
**Fix:** Define clear criteria for category creation.
### No Governance
Anyone can create taxonomy terms, leading to duplicates and inconsistency.
**Fix:** Limit who can create/edit taxonomy documents. Use validation.
@@ -0,0 +1,65 @@
---
name: portable-text-conversion
description: Convert HTML and Markdown content into Portable Text blocks for Sanity. Use when migrating content from legacy CMSs, importing HTML or Markdown into Sanity, building content pipelines that ingest external content, converting rich text between formats, or programmatically creating Portable Text documents. Covers @portabletext/markdown (markdownToPortableText), @portabletext/block-tools (htmlToBlocks), custom deserializers, and the Portable Text specification for manual block construction.
license: MIT
metadata:
author: sanity
version: "1.0.0"
---
# Portable Text Conversion
Convert external content (HTML, Markdown) into Portable Text for Sanity. Three main approaches:
1. **`markdownToPortableText`** — Convert Markdown directly using `@portabletext/markdown` (recommended for Markdown)
2. **`htmlToBlocks`** — Parse HTML into PT blocks using `@portabletext/block-tools` (for HTML migration)
3. **Manual construction** — Build PT blocks directly from any source (APIs, databases, etc.)
## Portable Text Specification
Understand the target format before converting. PT is an array of blocks:
```json
[
{
"_type": "block",
"_key": "abc123",
"style": "normal",
"children": [
{"_type": "span", "_key": "def456", "text": "Hello ", "marks": []},
{"_type": "span", "_key": "ghi789", "text": "world", "marks": ["strong"]}
],
"markDefs": []
},
{
"_type": "block",
"_key": "jkl012",
"style": "h2",
"children": [
{"_type": "span", "_key": "mno345", "text": "A heading", "marks": []}
],
"markDefs": []
},
{
"_type": "image",
"_key": "pqr678",
"asset": {"_type": "reference", "_ref": "image-abc-200x200-png"}
}
]
```
**Key rules:**
- Every block and span needs `_key` (unique within the array)
- `_type: "block"` is for text blocks; custom types use their own `_type`
- `markDefs` holds annotation data; `marks` on spans reference `markDefs[*]._key` or are decorator strings
- Lists use `listItem` ("bullet" | "number") and `level` (1, 2, 3...) on regular blocks
## Conversion Rules
Read the rule file matching your source format:
- **Markdown → Portable Text**: `rules/markdown-to-pt.md``@portabletext/markdown` with `markdownToPortableText` (recommended)
- **HTML → Portable Text**: `rules/html-to-pt.md``@portabletext/block-tools` with `htmlToBlocks`
- **Manual PT Construction**: `rules/manual-construction.md` — build blocks programmatically from any source
> **Note:** `@sanity/block-tools` is the legacy package name. Always use `@portabletext/block-tools` for new projects. The API is the same.
@@ -0,0 +1,242 @@
---
title: Convert HTML to Portable Text
description: Use @portabletext/block-tools with htmlToBlocks to convert HTML content into Portable Text blocks
tags: [portable-text, html, conversion, migration, import]
---
# Convert HTML to Portable Text
Use `@portabletext/block-tools` to parse HTML into Portable Text blocks. This is the primary tool for migrating HTML content from legacy CMSs. It has built-in support for content from Google Docs, Microsoft Word, and Notion.
> **Note:** For Markdown sources, use `@portabletext/markdown` instead — it's simpler and more direct. See `rules/markdown-to-pt.md`.
> **Note:** `@sanity/block-tools` is the legacy package name. Use `@portabletext/block-tools` for new projects. The API is identical.
## Setup
```bash
npm install @portabletext/block-tools jsdom @sanity/schema
```
In Node.js, you must provide a `parseHtml` function that returns a DOM `Document`. Use JSDOM for this:
```ts
import {htmlToBlocks} from '@portabletext/block-tools'
import {JSDOM} from 'jsdom'
import Schema from '@sanity/schema'
// JSDOM is passed to htmlToBlocks via the parseHtml option:
// htmlToBlocks(html, blockContentType, {
// parseHtml: (html) => new JSDOM(html).window.document,
// })
```
## Define Your Schema
`htmlToBlocks` needs a compiled Sanity block content type to know which marks, styles, and custom types are valid. Use `@sanity/schema` to compile it:
```ts
const defaultSchema = Schema.compile({
name: 'mySchema',
types: [
{
name: 'post',
type: 'document',
fields: [
{
name: 'body',
type: 'array',
of: [
{
type: 'block',
marks: {
decorators: [
{title: 'Strong', value: 'strong'},
{title: 'Emphasis', value: 'em'},
{title: 'Code', value: 'code'},
],
annotations: [
{
name: 'link',
type: 'object',
fields: [{name: 'href', type: 'url'}],
},
],
},
styles: [
{title: 'Normal', value: 'normal'},
{title: 'H2', value: 'h2'},
{title: 'H3', value: 'h3'},
{title: 'Quote', value: 'blockquote'},
],
lists: [
{title: 'Bullet', value: 'bullet'},
{title: 'Number', value: 'number'},
],
},
{
name: 'image',
type: 'image',
fields: [{name: 'alt', type: 'string'}],
},
],
},
],
},
],
})
const blockContentType = defaultSchema
.get('post')
.fields.find((f) => f.name === 'body').type
```
## Basic Conversion
```ts
const html = '<p>Hello <strong>world</strong></p><h2>Heading</h2>'
const blocks = htmlToBlocks(html, blockContentType, {
parseHtml: (html) => new JSDOM(html).window.document,
})
```
## Custom Deserializers
Handle HTML elements that don't map directly to standard PT:
```ts
const blocks = htmlToBlocks(html, blockContentType, {
parseHtml: (html) => new JSDOM(html).window.document,
rules: [
// Convert <img> to image blocks
{
deserialize(el, next, block) {
if (el.tagName?.toLowerCase() !== 'img') return undefined
return block({
_type: 'image',
asset: {
_type: 'reference',
_ref: '', // Upload image separately, set ref after
},
alt: el.getAttribute('alt') || '',
_sanityAsset: `image@${el.getAttribute('src')}`, // for migration tooling
})
},
},
// Convert <a> with custom attributes
{
deserialize(el, next, block) {
if (el.tagName?.toLowerCase() !== 'a') return undefined
const href = el.getAttribute('href') || ''
const target = el.getAttribute('target') || ''
return {
_type: '__annotation',
markDef: {
_type: 'link',
href,
...(target ? {target} : {}),
},
children: next(el.childNodes),
}
},
},
// Convert <iframe> to embed blocks
{
deserialize(el, next, block) {
if (el.tagName?.toLowerCase() !== 'iframe') return undefined
return block({
_type: 'embed',
url: el.getAttribute('src') || '',
})
},
},
],
})
```
## Pre-Process HTML Before Conversion
Strip layout elements and extract metadata:
```ts
function preprocessHtml(rawHtml: string) {
const dom = new JSDOM(rawHtml)
const doc = dom.window.document
// Remove layout elements
const removeSelectors = ['header', 'footer', 'nav', '.sidebar', '.menu', 'script', 'style']
removeSelectors.forEach((sel) => {
doc.querySelectorAll(sel).forEach((el) => el.remove())
})
// Extract metadata
const title = doc.querySelector('h1')?.textContent || doc.title || ''
const description = doc.querySelector('meta[name="description"]')?.getAttribute('content') || ''
// Get cleaned body
const body = doc.querySelector('article')?.innerHTML || doc.body.innerHTML
return {title, description, body}
}
```
## Upload Images During Migration
Don't just link external images — upload them to Sanity:
```ts
import type {SanityClient} from '@sanity/client'
async function uploadImage(client: SanityClient, url: string) {
const response = await fetch(url)
const buffer = await response.arrayBuffer()
const asset = await client.assets.upload('image', Buffer.from(buffer), {
filename: url.split('/').pop(),
})
return {
_type: 'image',
asset: {_type: 'reference', _ref: asset._id},
}
}
```
## Full Migration Example
```ts
import {defineMigration, createOrReplace} from 'sanity/migrate'
export default defineMigration({
title: 'Import WordPress posts',
async *migrate(documents, context) {
const posts = await fetchWordPressPosts()
for (const post of posts) {
const {title, description, body} = preprocessHtml(post.content)
const blocks = htmlToBlocks(body, blockContentType, {
parseHtml: (html) => new JSDOM(html).window.document,
rules: [/* custom rules */],
})
yield createOrReplace({
_id: `post-${post.slug}`,
_type: 'post',
title: title || post.title,
body: blocks,
})
}
},
})
```
Run with: `sanity migration run import-wordpress-posts --no-dry-run`
## Reference
- [@portabletext/block-tools](https://github.com/portabletext/editor/tree/main/packages/block-tools) — part of the `portabletext/editor` monorepo
- [Sanity Migration docs](https://www.sanity.io/docs/schema-and-content-migrations)
- [portabletext.org](https://www.portabletext.org) — Editor docs and serializer list
@@ -0,0 +1,210 @@
---
title: Manually Construct Portable Text Blocks
description: Build Portable Text blocks programmatically from any data source
tags: [portable-text, construction, api, programmatic, migration]
---
# Manually Construct Portable Text Blocks
Build PT blocks directly when converting from non-HTML sources (APIs, databases, custom formats) or when you need precise control over the output.
## Key Generation
Every block, span, and markDef needs a unique `_key`:
```ts
import {randomKey} from '@sanity/util/content'
const key = randomKey(12) // e.g., "a1b2c3d4e5f6"
```
Or use a simple helper:
```ts
const randomKey = () => Math.random().toString(36).slice(2, 14)
```
## Building Blocks
### Simple Paragraph
```ts
{
_type: 'block',
_key: randomKey(),
style: 'normal',
children: [
{_type: 'span', _key: randomKey(), text: 'Hello world', marks: []}
],
markDefs: []
}
```
### Heading
```ts
{
_type: 'block',
_key: randomKey(),
style: 'h2', // h1, h2, h3, h4, h5, h6
children: [
{_type: 'span', _key: randomKey(), text: 'Section Title', marks: []}
],
markDefs: []
}
```
### Text with Decorators (Bold, Italic, Code)
```ts
{
_type: 'block',
_key: randomKey(),
style: 'normal',
children: [
{_type: 'span', _key: randomKey(), text: 'This is ', marks: []},
{_type: 'span', _key: randomKey(), text: 'bold', marks: ['strong']},
{_type: 'span', _key: randomKey(), text: ' and ', marks: []},
{_type: 'span', _key: randomKey(), text: 'italic', marks: ['em']},
{_type: 'span', _key: randomKey(), text: ' text.', marks: []},
],
markDefs: []
}
```
### Text with Annotations (Links)
Annotations require a `markDef` entry and a matching key in `marks`:
```ts
const linkKey = randomKey()
{
_type: 'block',
_key: randomKey(),
style: 'normal',
children: [
{_type: 'span', _key: randomKey(), text: 'Visit ', marks: []},
{_type: 'span', _key: randomKey(), text: 'Sanity', marks: [linkKey]},
{_type: 'span', _key: randomKey(), text: ' for more.', marks: []},
],
markDefs: [
{_type: 'link', _key: linkKey, href: 'https://www.sanity.io'}
]
}
```
### Overlapping Marks
A span can have multiple marks (both decorators and annotations):
```ts
const linkKey = randomKey()
// "bold link" — both strong and linked
{_type: 'span', _key: randomKey(), text: 'bold link', marks: ['strong', linkKey]}
```
### Lists
Lists are regular blocks with `listItem` and `level`:
```ts
// Bullet list
[
{
_type: 'block', _key: randomKey(), style: 'normal',
listItem: 'bullet', level: 1,
children: [{_type: 'span', _key: randomKey(), text: 'First item', marks: []}],
markDefs: []
},
{
_type: 'block', _key: randomKey(), style: 'normal',
listItem: 'bullet', level: 1,
children: [{_type: 'span', _key: randomKey(), text: 'Second item', marks: []}],
markDefs: []
},
{
_type: 'block', _key: randomKey(), style: 'normal',
listItem: 'bullet', level: 2, // nested
children: [{_type: 'span', _key: randomKey(), text: 'Nested item', marks: []}],
markDefs: []
},
]
```
### Custom Block Types
Any object with `_type` and `_key` can be a block:
```ts
// Image block
{
_type: 'image',
_key: randomKey(),
asset: {_type: 'reference', _ref: 'image-abc123-800x600-png'},
alt: 'A description',
}
// Code block
{
_type: 'code',
_key: randomKey(),
language: 'typescript',
code: 'const x = 42',
}
// YouTube embed
{
_type: 'youtube',
_key: randomKey(),
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
}
```
## Helper Function
A utility for building common blocks:
```ts
function createBlock(
text: string,
style: string = 'normal',
options?: {listItem?: string; level?: number}
) {
return {
_type: 'block',
_key: randomKey(),
style,
...(options?.listItem ? {listItem: options.listItem, level: options.level || 1} : {}),
children: [{_type: 'span', _key: randomKey(), text, marks: []}],
markDefs: [],
}
}
// Usage
const blocks = [
createBlock('Introduction', 'h2'),
createBlock('This is a paragraph.'),
createBlock('First point', 'normal', {listItem: 'bullet', level: 1}),
createBlock('Second point', 'normal', {listItem: 'bullet', level: 1}),
]
```
## Validation Checklist
Before writing PT blocks to Sanity, verify:
- [ ] Every block has `_type` and `_key`
- [ ] Every span has `_type: "span"`, `_key`, `text`, and `marks`
- [ ] Every annotation key in `marks[]` has a matching entry in `markDefs[]`
- [ ] `markDefs` entries have `_type` and `_key`
- [ ] `_key` values are unique within the array
- [ ] `style` values match your schema's allowed styles
- [ ] `listItem` values match your schema's allowed list types
- [ ] Custom block `_type` values match registered schema types
## Reference
- [Portable Text Specification](https://github.com/portabletext/portabletext)
- [@sanity/util](https://github.com/sanity-io/sanity/tree/next/packages/%40sanity/util) — provides `randomKey()` for generating `_key` values
@@ -0,0 +1,204 @@
---
title: Convert Markdown to Portable Text
description: Convert Markdown content into Portable Text blocks using @portabletext/markdown
tags: [portable-text, markdown, conversion, migration, import]
---
# Convert Markdown to Portable Text
Use `@portabletext/markdown` for direct Markdown ↔ Portable Text conversion. This is the official library, part of the `portabletext/editor` monorepo.
```bash
npm install @portabletext/markdown
```
## Basic Usage
```ts
import {markdownToPortableText} from '@portabletext/markdown'
const blocks = markdownToPortableText('# Hello **world**')
```
Output:
```json
[{
"_type": "block",
"_key": "f4s8k2",
"style": "h1",
"children": [
{"_type": "span", "_key": "a9c3x1", "text": "Hello ", "marks": []},
{"_type": "span", "_key": "b7d2m5", "text": "world", "marks": ["strong"]}
],
"markDefs": []
}]
```
## Supported Markdown Features
Out of the box:
- Headings (h1h6)
- Paragraphs
- Bold, italic, inline code, strikethrough
- Links
- Blockquotes
- Ordered and unordered lists (including nested)
- Code blocks (fenced with language)
- Horizontal rules
- Images
- Tables (GFM)
- HTML blocks (configurable)
## Custom Schema Mapping
Control how Markdown elements map to your PT schema. Define a schema with `@portabletext/schema`:
```ts
import {markdownToPortableText} from '@portabletext/markdown'
import {defineSchema, compileSchema} from '@portabletext/schema'
const schema = compileSchema(defineSchema({
styles: [{name: 'normal'}, {name: 'heading 1'}, {name: 'heading 2'}],
decorators: [{name: 'strong'}, {name: 'em'}],
annotations: [{name: 'link'}],
lists: [{name: 'bullet'}, {name: 'number'}],
}))
const blocks = markdownToPortableText(markdown, {
schema,
// Map Markdown heading levels to custom style names
block: {
h1: ({context}) => 'heading 1',
h2: ({context}) => 'heading 2',
},
})
```
### Using a Sanity Studio Schema
Use `@portabletext/sanity-bridge` to convert your Sanity block array schema:
```ts
import {markdownToPortableText} from '@portabletext/markdown'
import {sanitySchemaToPortableTextSchema} from '@portabletext/sanity-bridge'
// Convert a Sanity block array schema to a Portable Text schema
const schema = sanitySchemaToPortableTextSchema(sanityBlockArraySchema)
const blocks = markdownToPortableText(markdown, {schema})
```
## Custom Matchers
Matchers are top-level options (not nested under a `matchers` key). Each receives `{context, value}` where `context.schema` lets you validate against the schema:
```ts
const blocks = markdownToPortableText(markdown, {
// Block matchers — map Markdown block elements to PT styles
block: {
h1: ({context}) => {
const style = context.schema.styles.find((s) => s.name === 'heading 1')
return style?.name // Return undefined to skip
},
},
// Mark matchers — map Markdown inline elements to PT marks
marks: {
strong: ({context}) => 'strong',
},
// Type matchers — map Markdown elements to custom PT block types
types: {
table: ({context, value}) => {
const tableType = context.schema.blockObjects.find((obj) => obj.name === 'table')
if (!tableType) return undefined
return {
_type: 'table',
_key: context.keyGenerator(),
rows: value.rows,
headerRows: value.headerRows,
}
},
},
})
```
## Handling Inline HTML
Configure how inline HTML in Markdown is processed:
```ts
const blocks = markdownToPortableText(markdown, {
html: {
inline: 'text', // 'text' preserves as text, 'skip' removes
},
})
```
## Custom Key Generation
Provide your own key generator:
```ts
import {randomKey} from '@sanity/util/content'
const blocks = markdownToPortableText(markdown, {
keyGenerator: () => randomKey(12),
})
```
## Bidirectional: Also Converts PT → Markdown
The same package provides `portableTextToMarkdown()`:
```ts
import {portableTextToMarkdown} from '@portabletext/markdown'
const markdown = portableTextToMarkdown(blocks)
```
See the `portable-text-serialization` skill's `rules/markdown.md` for details on PT → Markdown.
## Migration Example
```ts
import {markdownToPortableText} from '@portabletext/markdown'
import {createClient} from '@sanity/client'
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
const client = createClient({projectId: 'xxx', dataset: 'production', token: '...'})
// Import a directory of Markdown files
const mdFiles = fs.readdirSync('./content').filter(f => f.endsWith('.md'))
for (const file of mdFiles) {
const raw = fs.readFileSync(path.join('./content', file), 'utf-8')
const {data: frontmatter, content} = matter(raw)
const body = markdownToPortableText(content)
await client.createOrReplace({
_id: `post-${path.basename(file, '.md')}`,
_type: 'post',
title: frontmatter.title,
body,
})
}
```
## When to Use htmlToBlocks Instead
Use `@portabletext/block-tools` (`htmlToBlocks`) when:
- Your source is HTML, not Markdown
- You need custom deserializer rules for non-standard HTML elements
- You're migrating from a CMS that exports HTML (WordPress, Contentful, etc.)
- You need to handle complex HTML structures (tables with merged cells, nested divs, etc.)
For Markdown sources, `@portabletext/markdown` is simpler and more direct.
## Reference
- [@portabletext/markdown](https://github.com/portabletext/editor/tree/main/packages/markdown)
- Part of the [portabletext/editor](https://github.com/portabletext/editor) monorepo
- Uses [markdown-it](https://github.com/markdown-it/markdown-it) internally
@@ -0,0 +1,111 @@
---
name: portable-text-serialization
description: Render and serialize Portable Text to React, Svelte, Vue, Astro, HTML, Markdown, and plain text. Use when implementing Portable Text rendering in any frontend framework, building custom serializers for non-standard block types, converting Portable Text to HTML strings server-side, converting Portable Text to Markdown, extracting plain text from Portable Text, or troubleshooting rendering issues with marks, blocks, lists, or custom types.
license: MIT
metadata:
author: sanity
version: "1.0.0"
---
# Portable Text Serialization
Render Portable Text content across frameworks using the `@portabletext/*` library family. Each library follows the same component-mapping pattern: you provide a `components` object that maps PT node types to framework-specific renderers.
## Portable Text Structure (Quick Reference)
PT is an array of blocks. Each block has `_type`, optional `style`, `children` (spans), `markDefs`, `listItem`, and `level`.
```
Root array
├── block (_type: "block")
│ ├── style: "normal" | "h1" | "h2" | "blockquote" | ...
│ ├── children: [span, span, ...]
│ │ └── span: { _type: "span", text: "...", marks: ["strong", "<markDefKey>"] }
│ ├── markDefs: [{ _key, _type: "link", href: "..." }, ...]
│ ├── listItem: "bullet" | "number" (optional)
│ └── level: 1, 2, 3... (optional, for nested lists)
├── custom block (_type: "image" | "code" | any custom type)
└── ...more blocks
```
**Marks** come in two forms:
- **Decorators**: string values in `marks[]` like `"strong"`, `"em"`, `"underline"`, `"code"`
- **Annotations**: keys in `marks[]` referencing entries in `markDefs[]` (e.g., links, internal references)
## Component Mapping Pattern (All Frameworks)
Every `@portabletext/*` library accepts a `components` object with these keys:
| Key | Renders | Props/Data |
|-----|---------|------------|
| `types` | Custom block/inline types (image, code, CTA) | `value` (the block data) |
| `marks` | Decorators + annotations | `children` + `value` (mark data) |
| `block` | Block styles (h1, normal, blockquote) | `children` |
| `list` | List wrappers (ul, ol) | `children` |
| `listItem` | List items | `children` |
| `hardBreak` | Line breaks within a block | — |
## Framework-Specific Rules
Read the rule file matching your framework:
- **React / Next.js**: `rules/react.md``@portabletext/react` or `next-sanity`
- **Svelte / SvelteKit**: `rules/svelte.md``@portabletext/svelte`
- **Vue / Nuxt**: `rules/vue.md``@portabletext/vue`
- **Astro**: `rules/astro.md``astro-portabletext`
- **HTML (server-side)**: `rules/html.md``@portabletext/to-html`
- **Markdown**: `rules/markdown.md``@portabletext/markdown`
- **Plain text extraction**: `rules/plain-text.md``@portabletext/toolkit`
### Additional Community Serializers
These are listed on [portabletext.org](https://www.portabletext.org/integrations/serializers/) but don't have dedicated rule files:
| Target | Package |
|--------|---------|
| React Native | `@portabletext/react-native-portabletext` |
| React PDF | `@portabletext/react-pdf-portabletext` |
| Solid | `solid-portabletext` |
| Qwik | `portabletext-qwik` |
| Shopify Liquid | `portable-text-to-liquid` |
| PHP | `sanity-php` (SanityBlockContent class) |
| Python | `portabletext-html` |
| C# / .NET | `dotnet-portable-text` |
| Dart / Flutter | `flutter_sanity_portable_text` |
## Common Patterns (All Frameworks)
### Custom Types Need Explicit Components
PT renderers only handle standard blocks by default. Custom types (`image`, `code`, `callToAction`, etc.) require explicit component mappings — they won't render otherwise.
### Keep Components Object Stable
In React/Vue, define `components` outside the render function or memoize it. Recreating on every render causes unnecessary re-renders.
### Handle Missing Components Gracefully
All libraries accept `onMissingComponent` to control behavior when encountering unknown types:
- `false` — suppress warnings
- Custom function — log or report
### Querying PT with GROQ
Always expand references inside custom blocks:
```groq
body[]{
...,
_type == "image" => {
...,
asset->
},
markDefs[]{
...,
_type == "internalLink" => {
...,
"slug": @.reference->slug.current
}
}
}
```
@@ -0,0 +1,124 @@
---
title: Serialize Portable Text to Astro
description: Render Portable Text in Astro using astro-portabletext
tags: [portable-text, astro, serialization, rendering]
---
# Serialize Portable Text to Astro
Use `astro-portabletext` to render PT in Astro projects. This is the officially recommended library for Sanity + Astro.
```bash
npm install astro-portabletext
```
## Basic Usage
```astro
---
import {PortableText} from 'astro-portabletext'
const {value} = Astro.props
---
<PortableText value={value} />
```
## Custom Components
Pass custom components to override default rendering:
```astro
---
import {PortableText} from 'astro-portabletext'
import ImageBlock from './ImageBlock.astro'
import CodeBlock from './CodeBlock.astro'
import Link from './Link.astro'
const {value} = Astro.props
const components = {
type: {
image: ImageBlock,
code: CodeBlock,
},
mark: {
link: Link,
},
block: {
h1: 'h1',
h2: 'h2',
blockquote: 'blockquote',
},
}
---
<PortableText {value} {components} />
```
### Custom Type Component
```astro
---
// ImageBlock.astro
const {node} = Astro.props
---
<figure>
<img src={urlFor(node).width(800).url()} alt={node.alt || ''} />
{node.caption && <figcaption>{node.caption}</figcaption>}
</figure>
```
### Custom Mark Component
```astro
---
// Link.astro
const {node} = Astro.props
const href = node?.href || ''
const rel = href.startsWith('/') ? undefined : 'noreferrer noopener'
---
<a {href} {rel}><slot /></a>
```
## Using Slots for Customization
`astro-portabletext` supports Astro's slot system for simpler customization:
```astro
---
import {PortableText} from 'astro-portabletext'
---
<PortableText value={value}>
<fragment slot="block:h1">
<h1 class="text-4xl font-bold"><slot /></h1>
</fragment>
<fragment slot="mark:strong">
<strong class="font-black"><slot /></strong>
</fragment>
</PortableText>
```
## usePortableText Helper
For more control, use the `usePortableText` render function:
```astro
---
import {usePortableText} from 'astro-portabletext'
const {value} = Astro.props
const {render} = usePortableText(value)
---
<div class="prose">
{render()}
</div>
```
## Reference
- [astro-portabletext](https://github.com/theisel/astro-portabletext)
- [Sanity + Astro guide](https://www.sanity.io/guides/sanity-astro)
@@ -0,0 +1,115 @@
---
title: Serialize Portable Text to HTML
description: Convert Portable Text to HTML strings server-side using @portabletext/to-html
tags: [portable-text, html, server-side, serialization, email]
---
# Serialize Portable Text to HTML
Use `@portabletext/to-html` for server-side HTML string generation — useful for RSS feeds, emails, static rendering, or any non-framework context.
```bash
npm install @portabletext/to-html
```
## Basic Usage
```ts
import {toHTML} from '@portabletext/to-html'
const html = toHTML(portableTextBlocks, {components})
```
## ⚠️ Security: Escape HTML
Unlike framework renderers, `toHTML` returns raw strings. **You must sanitize output.**
Use `htm` + `vhtml` for safe templating, or the built-in `escapeHTML` utility:
```ts
import {toHTML, escapeHTML, uriLooksSafe} from '@portabletext/to-html'
import htm from 'htm'
import vhtml from 'vhtml'
const h = htm.bind(vhtml)
```
## Custom Components
Components are functions returning HTML strings:
```ts
const components = {
types: {
image: ({value}) => {
return `<figure>
<img src="${escapeHTML(value.url)}" alt="${escapeHTML(value.alt || '')}" />
${value.caption ? `<figcaption>${escapeHTML(value.caption)}</figcaption>` : ''}
</figure>`
},
code: ({value}) => {
return `<pre data-language="${escapeHTML(value.language)}"><code>${escapeHTML(value.code)}</code></pre>`
},
},
marks: {
link: ({children, value}) => {
const href = value?.href || ''
if (!uriLooksSafe(href)) return children
const rel = href.startsWith('/') ? '' : ' rel="noreferrer noopener"'
return `<a href="${escapeHTML(href)}"${rel}>${children}</a>`
},
strong: ({children}) => `<strong>${children}</strong>`,
em: ({children}) => `<em>${children}</em>`,
highlight: ({children}) => `<mark>${children}</mark>`,
},
block: {
h1: ({children}) => `<h1>${children}</h1>`,
h2: ({children}) => `<h2>${children}</h2>`,
blockquote: ({children}) => `<blockquote>${children}</blockquote>`,
normal: ({children}) => `<p>${children}</p>`,
},
list: {
bullet: ({children}) => `<ul>${children}</ul>`,
number: ({children}) => `<ol>${children}</ol>`,
},
listItem: {
bullet: ({children}) => `<li>${children}</li>`,
},
}
```
## With htm/vhtml (Auto-Escaped)
Using `htm` + `vhtml` auto-escapes attribute values, preventing XSS from user content that could break out of attributes in raw template literals:
```ts
const components = {
types: {
image: ({value}) => h`<img src=${value.url} alt=${value.alt || ''} />`,
},
marks: {
link: ({children, value}) => {
if (!uriLooksSafe(value?.href || '')) return children
return h`<a href=${value.href}>${children}</a>`
},
},
}
```
## Use Cases
| Use Case | Why toHTML |
|----------|-----------|
| RSS/Atom feeds | Need raw HTML string |
| Email templates | No framework runtime |
| Static site generation | Pre-render at build time |
| API responses | Return HTML from endpoints |
| PDF generation | Feed HTML to PDF libraries |
## Reference
- [@portabletext/to-html](https://github.com/portabletext/to-html)
@@ -0,0 +1,123 @@
---
title: Serialize Portable Text to Markdown
description: Convert Portable Text to Markdown strings using @portabletext/markdown
tags: [portable-text, markdown, serialization, conversion]
---
# Serialize Portable Text to Markdown
Use `@portabletext/markdown` to convert PT blocks to Markdown strings. Useful for AI/LLM pipelines, static site generators, README generation, and anywhere Markdown is the target format.
```bash
npm install @portabletext/markdown
```
## Basic Usage
```ts
import {portableTextToMarkdown} from '@portabletext/markdown'
const markdown = portableTextToMarkdown(portableTextBlocks)
```
## Built-in Support
Out of the box, `portableTextToMarkdown` handles:
- Headings (h1h6)
- Paragraphs
- Bold (`**`), italic (`_`), inline code (`` ` ``), strikethrough (`~~`)
- Links (`[text](url)`)
- Blockquotes (`>`)
- Ordered and unordered lists (including nested)
- Code blocks (fenced with language)
- Horizontal rules (`---`)
- Images (`![alt](url)`)
- Tables (GFM)
## Built-in Type Renderers
The library exports default renderers for common block object types. Enable them explicitly:
```ts
import {
portableTextToMarkdown,
DefaultCodeBlockRenderer,
DefaultImageRenderer,
DefaultHorizontalRuleRenderer,
DefaultTableRenderer,
DefaultHtmlRenderer,
} from '@portabletext/markdown'
const markdown = portableTextToMarkdown(blocks, {
types: {
'code': DefaultCodeBlockRenderer, // {code, language?} → fenced code block
'image': DefaultImageRenderer, // {src, alt?, title?} → ![alt](src "title")
'horizontal-rule': DefaultHorizontalRuleRenderer, // → ---
'table': DefaultTableRenderer, // {rows, headerRows?} → GFM table
'html': DefaultHtmlRenderer, // {html} → raw HTML
},
})
```
## Custom Renderers
Handle custom block types and marks with renderer functions:
```ts
const markdown = portableTextToMarkdown(blocks, {
// Custom block types — receives {value, index, isInline}
types: {
callout: ({value}) => `> **${value.title}**\n> ${value.text}`,
image: ({value, isInline}) => {
if (isInline) return ''
return `![${value.alt || ''}](${value.url})`
},
},
// Custom block style renderers — receives {value, children, index}
block: {
h1: ({children}) => `# ${children}`,
blockquote: ({children}) => `> ${children}`,
},
// Custom mark renderers — receives {value, children, text, markType, markKey}
marks: {
highlight: ({children}) => `==${children}==`,
internalLink: ({children, value}) => `[${children}](/docs/${value.slug})`,
},
// Custom list item renderer — receives {value, children, listIndex}
listItem: ({children}) => children,
// Control spacing between blocks — function, not string
blockSpacing: ({current, next}) => {
if (current.listItem && next.listItem) return '\n'
return undefined // use default (\n\n)
},
// Handle unknown types gracefully
unknownType: ({value}) => `<!-- Unknown type: ${value._type} -->`,
unknownMark: ({children}) => children,
})
```
## Use Cases
| Use Case | Why Markdown |
|----------|-------------|
| AI/LLM context | Models work well with Markdown input |
| Static site generators | Hugo, Jekyll, Eleventy consume Markdown |
| README generation | Generate docs from Sanity content |
| Email (with converter) | Markdown → HTML for email templates |
| Export/backup | Human-readable content export |
| Documentation pipelines | Sanity as docs CMS, output as Markdown |
## Bidirectional: Also Converts Markdown → PT
The same package also provides `markdownToPortableText()` for the reverse direction. See the `portable-text-conversion` skill for details.
## Reference
- [@portabletext/markdown](https://github.com/portabletext/editor/tree/main/packages/markdown)
- Part of the [portabletext/editor](https://github.com/portabletext/editor) monorepo
@@ -0,0 +1,66 @@
---
title: Extract Plain Text from Portable Text
description: Convert Portable Text to plain text strings for search, meta descriptions, and summaries
tags: [portable-text, plain-text, search, seo, extraction]
---
# Extract Plain Text from Portable Text
Every `@portabletext/*` library exports a `toPlainText()` utility. Use it for meta descriptions, search indexing, summaries, and anywhere you need raw text without markup.
## Usage
```ts
// From any framework library:
import {toPlainText} from '@portabletext/react'
// or: import {toPlainText} from '@portabletext/svelte'
// or: import {toPlainText} from '@portabletext/vue'
// or: import {toPlainText} from '@portabletext/to-html'
const plainText = toPlainText(portableTextBlocks)
```
## Common Patterns
### Meta Description
```ts
function getMetaDescription(body: PortableTextBlock[]): string {
const text = toPlainText(body)
return text.length > 160 ? text.slice(0, 157) + '...' : text
}
```
### Search Indexing
```ts
// Index document content for search
const searchableText = toPlainText(document.body)
```
### Slug Generation
```ts
import slugify from 'slugify'
const slug = slugify(toPlainText(blocks), {lower: true, strict: true})
```
### Character/Word Count
```ts
const text = toPlainText(blocks)
const wordCount = text.split(/\s+/).filter(Boolean).length
const charCount = text.length
```
## Behavior
- Extracts text from all `span` children in `block` type nodes
- Joins blocks with double newlines (`\n\n`)
- Ignores custom block types (images, code blocks, etc.)
- Strips all marks (bold, links, etc.) — returns raw text only
## Reference
- [`toPlainText` source](https://github.com/portabletext/toolkit)
@@ -0,0 +1,142 @@
---
title: Serialize Portable Text to React
description: Render Portable Text in React and Next.js using @portabletext/react
tags: [portable-text, react, nextjs, serialization, rendering]
---
# Serialize Portable Text to React
Use `@portabletext/react` (or re-exported from `next-sanity`) to render PT in React/Next.js.
```bash
npm install @portabletext/react
```
## Basic Usage
```tsx
import {PortableText} from '@portabletext/react'
// or: import {PortableText} from 'next-sanity'
export function Body({value}: {value: PortableTextBlock[]}) {
return <PortableText value={value} components={components} />
}
```
## Typed Components Object
```tsx
import type {PortableTextComponents} from '@portabletext/react'
const components: PortableTextComponents = {
// Block styles
block: {
h1: ({children}) => <h1 className="text-4xl font-bold">{children}</h1>,
h2: ({children}) => <h2 className="text-3xl font-semibold">{children}</h2>,
blockquote: ({children}) => (
<blockquote className="border-l-4 pl-4 italic">{children}</blockquote>
),
// 'normal' is the default paragraph style
},
// Custom block types
types: {
image: ({value}) => (
<img
src={urlFor(value).width(800).url()}
alt={value.alt || ''}
loading="lazy"
/>
),
code: ({value}) => (
<pre data-language={value.language}>
<code>{value.code}</code>
</pre>
),
},
// Marks (decorators + annotations)
marks: {
// Decorator
highlight: ({children}) => (
<span className="bg-yellow-200">{children}</span>
),
// Annotation
link: ({children, value}) => {
const rel = !value?.href?.startsWith('/') ? 'noreferrer noopener' : undefined
return (
<a href={value?.href} rel={rel}>
{children}
</a>
)
},
internalLink: ({children, value}) => (
<a href={`/${value?.slug}`}>{children}</a>
),
},
// Lists
list: {
bullet: ({children}) => <ul className="list-disc ml-6">{children}</ul>,
number: ({children}) => <ol className="list-decimal ml-6">{children}</ol>,
},
listItem: {
bullet: ({children}) => <li>{children}</li>,
},
}
```
## Props Reference
| Component type | Props received |
|---------------|----------------|
| `block.*` | `{children, value}``value` is the full block |
| `types.*` | `{value, isInline}``value` is the custom block data |
| `marks.*` | `{children, value, markType, markKey}``value` is the markDef data |
| `list.*` | `{children, value}` |
| `listItem.*` | `{children, value}` |
## Performance: Stabilize the Components Object
**Bad** — recreated every render:
```tsx
function Body({value}) {
return <PortableText value={value} components={{
types: {image: ({value}) => <img src={value.url} />}
}} />
}
```
**Good** — defined outside or memoized:
```tsx
const components: PortableTextComponents = {
types: {image: ({value}) => <img src={value.url} />}
}
function Body({value}) {
return <PortableText value={value} components={components} />
}
```
## Plain Text Extraction
```tsx
import {toPlainText} from '@portabletext/react'
const text = toPlainText(blocks) // for meta descriptions, search indexing
```
## Tailwind Typography Shortcut
For simple blogs without custom blocks, wrap in `prose`:
```tsx
<article className="prose lg:prose-xl">
<PortableText value={value} />
</article>
```
## Reference
- [@portabletext/react](https://github.com/portabletext/react-portabletext)
- [Sanity docs: Presenting Portable Text](https://www.sanity.io/docs/presenting-block-text)
@@ -0,0 +1,134 @@
---
title: Serialize Portable Text to Svelte
description: Render Portable Text in Svelte 5 and SvelteKit using @portabletext/svelte
tags: [portable-text, svelte, sveltekit, serialization, rendering]
---
# Serialize Portable Text to Svelte
Use `@portabletext/svelte` (requires Svelte 5+) to render PT in Svelte/SvelteKit.
```bash
npm install @portabletext/svelte
```
## Basic Usage
```svelte
<script>
import {PortableText} from '@portabletext/svelte'
let {value} = $props()
</script>
<PortableText {value} components={components} />
```
## Custom Components
Svelte components receive a `portableText` prop with `value`, `global`, and `indexInParent`. Child content is passed via Svelte snippets.
### Block Styles
```svelte
<!-- Heading.svelte -->
<script>
let {portableText, children} = $props()
const {value} = portableText
</script>
{#if value.style === 'h1'}
<h1 class="text-4xl font-bold">{@render children()}</h1>
{:else if value.style === 'h2'}
<h2 class="text-3xl font-semibold">{@render children()}</h2>
{:else}
<p>{@render children()}</p>
{/if}
```
### Custom Types
```svelte
<!-- ImageBlock.svelte -->
<script>
let {portableText} = $props()
const {value} = portableText
</script>
<figure>
<img src={urlFor(value).width(800).url()} alt={value.alt || ''} />
{#if value.caption}
<figcaption>{value.caption}</figcaption>
{/if}
</figure>
```
### Mark Components (Annotations)
```svelte
<!-- Link.svelte -->
<script>
let {portableText, children} = $props()
const {value} = portableText
const href = value?.href || ''
</script>
<a {href} rel={href.startsWith('/') ? undefined : 'noreferrer noopener'}>
{@render children()}
</a>
```
## Assembling Components
```svelte
<script>
import {PortableText} from '@portabletext/svelte'
import ImageBlock from './ImageBlock.svelte'
import CodeBlock from './CodeBlock.svelte'
import Link from './Link.svelte'
let {value} = $props()
const components = {
types: {
image: ImageBlock,
code: CodeBlock,
},
marks: {
link: Link,
},
block: {
h1: ({children}) => `<h1>${children}</h1>`, // or use a component
},
}
</script>
<PortableText {value} {components} />
```
## Passing Context
Pass external data to all components via `context`:
```svelte
<PortableText
{value}
{components}
context={{dataset: 'production', footnotes}}
/>
```
Access in components via `portableText.global.context`.
## Plain Text Extraction
```js
import {toPlainText} from '@portabletext/svelte'
const text = toPlainText(blocks)
```
## Reference
- [@portabletext/svelte](https://github.com/portabletext/svelte-portabletext)
- [Sanity + SvelteKit guide](https://www.sanity.io/guides/sanity-sveltekit)
@@ -0,0 +1,125 @@
---
title: Serialize Portable Text to Vue
description: Render Portable Text in Vue 3 and Nuxt using @portabletext/vue
tags: [portable-text, vue, nuxt, serialization, rendering]
---
# Serialize Portable Text to Vue
Use `@portabletext/vue` to render PT in Vue 3 / Nuxt applications.
```bash
npm install @portabletext/vue
```
## Basic Usage
```vue
<script setup lang="ts">
import {PortableText} from '@portabletext/vue'
import type {PortableTextBlock} from '@portabletext/types'
const props = defineProps<{value: PortableTextBlock[]}>()
</script>
<template>
<PortableText :value="value" :components="components" />
</template>
```
## Custom Components
Vue components can be defined as render functions, SFCs, or JSX.
### Render Function Style (Concise)
```ts
import {h} from 'vue'
import type {PortableTextVueComponents} from '@portabletext/vue'
const components: PortableTextVueComponents = {
types: {
image: ({value}) => h('img', {src: urlFor(value).width(800).url(), alt: value.alt || ''}),
code: ({value}) => h('pre', {'data-language': value.language}, h('code', value.code)),
},
marks: {
link: ({value}, {slots}) => {
const rel = !value?.href?.startsWith('/') ? 'noreferrer noopener' : undefined
return h('a', {href: value?.href, rel}, slots.default?.())
},
highlight: (_, {slots}) => h('span', {class: 'bg-yellow-200'}, slots.default?.()),
},
block: {
h1: (_, {slots}) => h('h1', {class: 'text-4xl font-bold'}, slots.default?.()),
h2: (_, {slots}) => h('h2', {class: 'text-3xl font-semibold'}, slots.default?.()),
blockquote: (_, {slots}) => h('blockquote', {class: 'border-l-4 pl-4 italic'}, slots.default?.()),
},
list: {
bullet: (_, {slots}) => h('ul', {class: 'list-disc ml-6'}, slots.default?.()),
number: (_, {slots}) => h('ol', {class: 'list-decimal ml-6'}, slots.default?.()),
},
}
```
### SFC Style (For Complex Components)
```vue
<!-- ImageBlock.vue -->
<script setup lang="ts">
import type {PortableTextComponentProps} from '@portabletext/vue'
const props = defineProps<PortableTextComponentProps<{
asset: {_ref: string}
alt?: string
caption?: string
}>>()
</script>
<template>
<figure>
<img :src="urlFor(value).width(800).url()" :alt="value.alt || ''" />
<figcaption v-if="value.caption">{{ value.caption }}</figcaption>
</figure>
</template>
```
Then register:
```ts
import ImageBlock from './ImageBlock.vue'
const components = {
types: {
image: ImageBlock,
},
}
```
## Props Pattern
Custom components receive:
| Prop | Description |
|------|-------------|
| `value` | The block/mark data |
| `index` | Position in parent array |
| `isInline` | Whether this is an inline element |
| `renderNode` | Internal renderer (rarely needed) |
Children are passed via **slots** (`slots.default?.()`), not props.
## Plain Text Extraction
```ts
import {toPlainText} from '@portabletext/vue'
const text = toPlainText(blocks)
```
## Reference
- [@portabletext/vue](https://github.com/portabletext/vue-portabletext)
- [Sanity + Nuxt guide](https://www.sanity.io/guides/sanity-nuxt)
@@ -0,0 +1,74 @@
---
name: sanity-best-practices
description: Sanity development best practices for schema design, GROQ queries, TypeGen, Visual Editing, images, Portable Text, Studio structure, localization, migrations, Sanity Functions, Blueprints, and framework integrations such as Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, Hydrogen, and the App SDK. Use this skill whenever working with Sanity schemas, defineType or defineField, GROQ or defineQuery, content modeling, Presentation or preview setups, Sanity-powered frontend integrations, Sanity Functions, documentEventHandler, defineDocumentFunction, defineMediaLibraryAssetFunction, @sanity/functions, @sanity/blueprints, sanity.blueprint.ts, event-driven content automation, or when reviewing and fixing a Sanity codebase.
---
# Sanity Best Practices
Comprehensive best practices and integration guides for Sanity development, maintained by Sanity. Use the quick reference below to load only the one or two topic files that match the task.
## When to Apply
Reference these guidelines when:
- Setting up a new Sanity project or onboarding
- Integrating Sanity with a frontend framework (Next.js, Nuxt, Astro, Remix, SvelteKit, Hydrogen)
- Writing GROQ queries or optimizing performance
- Designing content schemas
- Implementing Visual Editing and live preview
- Working with images, Portable Text, or page builders
- Configuring Sanity Studio structure
- Setting up TypeGen for type safety
- Implementing localization
- Migrating content from other systems
- Building custom apps with the Sanity App SDK
- Managing infrastructure with Blueprints
- Automating content workflows with Sanity Functions
## Quick Reference
### Integration Guides
- `get-started` - Interactive onboarding for new Sanity projects
- `nextjs` - Next.js App Router, Live Content API, embedded Studio
- `nuxt` - Nuxt integration with @nuxtjs/sanity
- `angular` - Angular integration with @sanity/client, signals, resource API
- `astro` - Astro integration with @sanity/astro
- `remix` - React Router / Remix integration
- `svelte` - SvelteKit integration with @sanity/svelte-loader
- `hydrogen` - Shopify Hydrogen with Sanity
- `project-structure` - Monorepo and embedded Studio patterns
- `app-sdk` - Custom applications with Sanity App SDK
- `blueprints` - Infrastructure as Code with Sanity Blueprints
- `functions` - Automating content workflows with Sanity Functions
### Topic Guides
- `groq` - GROQ query patterns, type safety, performance optimization
- `schema` - Schema design, field definitions, validation, deprecation patterns
- `visual-editing` - Presentation Tool, Stega, overlays, live preview
- `page-builder` - Page Builder arrays, block components, live editing
- `portable-text` - Rich text rendering and custom components
- `image` - Image schema, URL builder, hotspots, LQIP, Next.js Image
- `studio-structure` - Desk structure, singletons, navigation
- `typegen` - TypeGen configuration, workflow, type utilities
- `seo` - Metadata, sitemaps, Open Graph, JSON-LD
- `localization` - i18n patterns, document vs field-level, locale management
- `migration` - Content import overview (see also `migration-html-import`)
- `migration-html-import` - HTML to Portable Text with @portabletext/block-tools
## How to Use
Start with the single framework or topic guide that best matches the request, then read additional references only when the task crosses concerns. Use these reference files for detailed explanations and code examples:
```
references/groq.md
references/schema.md
references/nextjs.md
```
Each reference file contains:
- Comprehensive topic or integration coverage
- Incorrect and correct code examples
- Decision matrices and workflow guidance
- Framework-specific patterns where applicable
@@ -0,0 +1,565 @@
---
title: Angular & Sanity Integration Rules
description: Integration guide for Angular, including @sanity/client setup, data fetching with signals and resource API, Portable Text rendering, and image optimization.
---
# Angular & Sanity Integration Rules
Jump to the section that matches your Angular version or integration task instead of reading this guide straight through.
## Table of Contents
- Setup and configuration
- Client setup (service pattern)
- Data fetching patterns
- Routing
- Portable Text rendering
- Image optimization
- Modern Angular features
- SSR and prerendering
- Visual Editing
- Error handling
## 1. Setup & Configuration
Use the official template `sanity-template-angular-clean` as a starting point. It provides a monorepo structure:
```
project/
├── angular-app/ # Angular 19+ frontend
└── studio/ # Sanity Studio
```
Install dependencies in the Angular app:
```bash
npm install @sanity/client @sanity/image-url @portabletext/to-html
```
Configure environment files for Sanity credentials:
```typescript
// environments/environment.ts
export const environment = {
production: false,
sanity: {
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2025-05-01',
},
}
```
```typescript
// environments/environment.production.ts
export const environment = {
production: true,
sanity: {
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2025-05-01',
},
}
```
> There is no Angular-specific Sanity SDK. Use `@sanity/client` directly, wrapped in an Angular service.
### TypeGen in a Monorepo
Sanity TypeGen generates TypeScript types from your schema and GROQ queries. In the Angular monorepo template, TypeGen runs from the Studio side but scans your Angular app's source files. Ensure `studio/sanity.cli.ts` points at the Angular app:
```typescript
// studio/sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
typegen: {
enabled: true,
path: '../angular-app/src/**/*.ts',
generates: '../angular-app/sanity.types.ts',
},
})
```
The remaining defaults (`overloadClientMethods: true`, `schema: "schema.json"`) work as-is. Include the generated types file in `angular-app/tsconfig.json` (usually covered by `"include": ["src/**/*.ts", "sanity.types.ts"]`). See `typegen.md` for the full TypeGen workflow, git strategy, and configuration options.
## 2. Client Setup (Service Pattern)
Create an injectable service wrapping `@sanity/client` and `@sanity/image-url`:
```typescript
import { Injectable } from '@angular/core'
import { createClient, type ClientReturn, type QueryParams, type SanityClient } from '@sanity/client'
import imageUrlBuilder, { type ImageUrlBuilder } from '@sanity/image-url'
import type { SanityImageSource } from '@sanity/image-url/lib/types/types'
import { environment } from '../environments/environment'
@Injectable({ providedIn: 'root' })
export class SanityService {
private client: SanityClient
private builder: ImageUrlBuilder
constructor() {
this.client = createClient({
projectId: environment.sanity.projectId,
dataset: environment.sanity.dataset,
apiVersion: environment.sanity.apiVersion,
useCdn: true,
})
this.builder = imageUrlBuilder(this.client)
}
// ClientReturn resolves TypeGen's declaration-merged overloads for defineQuery strings
fetch<Query extends string>(query: Query, params?: QueryParams): Promise<ClientReturn<Query>> {
return this.client.fetch(query, params)
}
getImageUrlBuilder(source: SanityImageSource) {
return this.builder.image(source)
}
}
```
For preview/draft content, create a second client instance with a token and `useCdn: false`. Never expose tokens in client-side bundles — use server-side rendering or a proxy endpoint for authenticated requests.
## 3. Data Fetching Patterns
### A. `resource` API (Angular 19+, Recommended)
The `resource` API works natively with promises and integrates with Angular signals:
```typescript
import { Component, input, resource, inject } from '@angular/core'
import { defineQuery } from 'groq'
import { SanityService } from '../sanity.service'
const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
title, body, mainImage, publishedAt
}`)
@Component({
selector: 'app-post',
standalone: true,
template: `
@if (post.value(); as p) {
<h1>{{ p.title }}</h1>
<time>{{ p.publishedAt | date }}</time>
} @else if (post.isLoading()) {
<p>Loading…</p>
} @else if (post.error()) {
<p>Error loading post</p>
}
`,
})
export default class PostComponent {
slug = input.required<string>()
private sanity = inject(SanityService)
post = resource({
params: () => ({ slug: this.slug() }),
loader: ({ params }) => this.sanity.fetch(POST_QUERY, params),
})
}
```
The `resource` automatically re-fetches when `slug` changes and exposes `value()`, `isLoading()`, and `error()` signals.
> **TypeGen:** Wrapping queries in `defineQuery` enables Sanity TypeGen to infer return types automatically — no manual type imports needed. See `typegen.md` for the full workflow.
### B. `rxResource` (Observable-based)
For teams using RxJS patterns or needing operators like `retry` and `debounceTime`:
```typescript
import { Component, input, inject } from '@angular/core'
import { rxResource } from '@angular/core/rxjs-interop'
import { defineQuery } from 'groq'
import { from } from 'rxjs'
import { SanityService } from '../sanity.service'
const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]`)
@Component({ /* ... */ })
export default class PostComponent {
slug = input.required<string>()
private sanity = inject(SanityService)
post = rxResource({
params: () => ({ slug: this.slug() }),
loader: ({ params }) => from(this.sanity.fetch(POST_QUERY, params)),
})
}
```
### C. `toSignal` (Angular 1718)
For apps not yet on Angular 19, convert observables to signals:
```typescript
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { defineQuery } from 'groq'
import { from } from 'rxjs'
import { SanityService } from '../sanity.service'
const POSTS_QUERY = defineQuery(`*[_type == "post"] | order(publishedAt desc)`)
@Component({ /* ... */ })
export class HomeComponent {
private sanity = inject(SanityService)
posts = toSignal(from(this.sanity.fetch(POSTS_QUERY)), { initialValue: [] })
}
```
> **Note:** `toSignal` does not re-fetch on parameter changes. For dynamic queries, use `resource` or `rxResource`.
### Choosing a pattern
| Pattern | Angular Version | Reactivity | Best For |
|---|---|---|---|
| `resource` | 19+ | Signal-based, auto re-fetch | New projects, dynamic queries |
| `rxResource` | 19+ | RxJS + signals | Teams using RxJS operators |
| `toSignal` | 17+ | One-shot conversion | Static queries, legacy apps |
## 4. Routing
Use lazy-loaded routes with `withComponentInputBinding()` so route params bind directly to component inputs:
```typescript
// app.config.ts
import { provideRouter, withComponentInputBinding } from '@angular/router'
import { routes } from './app.routes'
export const appConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
],
}
```
```typescript
// app.routes.ts
import { Routes } from '@angular/router'
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component'),
pathMatch: 'full',
},
{
path: 'post/:slug',
loadComponent: () => import('./post/post.component'),
},
]
```
With `withComponentInputBinding()`, the `:slug` route param is automatically bound to `slug = input.required<string>()` on the component — no need to inject `ActivatedRoute`.
## 5. Portable Text Rendering
### A. `@portabletext/to-html` with Angular Pipe (Recommended)
```typescript
import { Pipe, PipeTransform, inject } from '@angular/core'
import { toHTML, type PortableTextComponents } from '@portabletext/to-html'
import type { PortableTextBlock } from '@portabletext/types'
import { SanityService } from '../sanity.service'
@Pipe({ name: 'portableTextToHTML', standalone: true })
export class PortableTextToHTMLPipe implements PipeTransform {
private sanity = inject(SanityService)
private components: PortableTextComponents = {
types: {
image: ({ value }) => {
const url = this.sanity.getImageUrlBuilder(value).width(800).auto('format').url()
return `<img src="${url}" alt="${value.alt || ''}" loading="lazy" />`
},
},
marks: {
link: ({ children, value }) =>
`<a href="${value.href}" rel="noopener noreferrer">${children}</a>`,
},
}
transform(value: PortableTextBlock[] | undefined): string {
if (!value) return ''
return toHTML(value, { components: this.components })
}
}
```
Usage in templates:
```html
<div [innerHTML]="post.body | portableTextToHTML"></div>
```
### B. `@limitless-angular/sanity` (Community, Component-based)
For full Angular component control over each block type, the community library `@limitless-angular/sanity` provides a component-based Portable Text renderer. This is useful when you need Angular-specific interactivity within rich text blocks.
See `portable-text.md` for Portable Text schema design and serialization rules.
## 6. Image Optimization
Create a pipe wrapping `@sanity/image-url`:
```typescript
import { Pipe, PipeTransform, inject } from '@angular/core'
import type { SanityImageSource } from '@sanity/image-url/lib/types/types'
import { SanityService } from '../sanity.service'
@Pipe({ name: 'sanityImage', standalone: true })
export class SanityImagePipe implements PipeTransform {
private sanity = inject(SanityService)
transform(value: SanityImageSource | undefined, width?: number): string | null {
if (!value) return null
const builder = this.sanity.getImageUrlBuilder(value)
if (width) return builder.width(width).auto('format').url()
return builder.auto('format').url()
}
}
```
Combine with Angular's `NgOptimizedImage` for LCP images:
```html
<!-- Priority image with NgOptimizedImage -->
<img [ngSrc]="post.mainImage | sanityImage: 1200" width="1200" height="630" priority />
<!-- Lazy-loaded image -->
<img [src]="post.mainImage | sanityImage: 600" [alt]="post.mainImage.alt" loading="lazy" />
```
**Bad:** Fetching full-size images without width constraints.
```html
<img [src]="post.mainImage | sanityImage" />
```
**Good:** Specifying width and using `auto('format')` for WebP/AVIF delivery.
```html
<img [src]="post.mainImage | sanityImage: 800" loading="lazy" />
```
### LQIP with `NgOptimizedImage`
Sanity provides a base64 LQIP (Low Quality Image Placeholder) per image asset — but you must query it explicitly:
```groq
mainImage {
// @sanity/image-url needs these to build URLs with hotspot/crop support
asset,
hotspot,
crop,
alt,
// NgOptimizedImage needs these for placeholder and layout
"lqip": asset->metadata.lqip,
"width": asset->metadata.dimensions.width,
"height": asset->metadata.dimensions.height
}
```
Feed the LQIP directly into `NgOptimizedImage`'s `placeholder` attribute:
```html
<img
[ngSrc]="post.mainImage | sanityImage: 1200"
[width]="post.mainImage.width"
[height]="post.mainImage.height"
[placeholder]="post.mainImage.lqip"
[alt]="post.mainImage.alt"
priority
/>
```
Angular applies a CSS blur to the LQIP and crossfades to the full image on load. No extra libraries needed.
> **Note:** LQIP strings are small (~200 bytes) so they're safe to inline in SSR HTML and `TransferState`. See `image.md` for the full image query patterns.
See `image.md` for image field schema patterns and hotspot/crop configuration.
## 7. Modern Angular Features
When building with Sanity, leverage these Angular 19+ features:
- **Standalone components** — Default in Angular 19. No `NgModule` boilerplate needed.
- **Signals and `resource`** — Preferred over RxJS for data fetching. Simpler, less boilerplate.
- **New control flow** — Use `@if`, `@for`, `@switch` with `@empty` for cleaner templates:
```html
@for (post of posts.value(); track post._id) {
<app-post-card [post]="post" />
} @empty {
<p>No posts found.</p>
}
```
- **`@defer` blocks** — Lazy-load below-fold content:
```html
@defer (on viewport) {
<app-comments [postId]="post._id" />
} @placeholder {
<p>Scroll to see comments…</p>
}
```
- **`inject()` function** — Preferred over constructor injection for cleaner code.
- **Zoneless change detection** — Experimental in Angular 19. Works well with signals-based data fetching since signals automatically notify the framework of changes.
## 8. SSR & Prerendering
Angular 17+ includes built-in SSR support (replacing Angular Universal):
```typescript
// app.config.server.ts
import { provideServerRendering } from '@angular/platform-server'
import { provideClientHydration } from '@angular/platform-browser'
export const serverConfig = {
providers: [
provideServerRendering(),
provideClientHydration(),
],
}
```
Key considerations for Sanity + Angular SSR:
| Feature | Details |
|---|---|
| **Hydration** | `provideClientHydration()` preserves server-rendered DOM. The client reuses it instead of re-rendering. |
| **HTTP Transfer Cache** | Only works with Angular's `HttpClient`. Since `@sanity/client` uses its own HTTP transport, use `TransferState` manually (see below). |
| **Prerendering** | Use `getPrerenderParams` in route config to generate static pages at build time. |
### Transfer State for `@sanity/client`
Angular's built-in HTTP Transfer Cache does not cover `@sanity/client` requests. Without manual transfer, the client re-fetches every query during hydration. Add `TransferState` to the service from Section 2:
```typescript
+ async function hashQuery(query: string, params?: QueryParams): Promise<string> {
+ const input = query + JSON.stringify(params ?? {})
+ const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
+ return Array.from(new Uint8Array(buffer), b => b.toString(16).padStart(2, '0')).join('')
+ }
import { Injectable, inject } from '@angular/core'
+ import { isPlatformBrowser, isPlatformServer } from '@angular/common'
+ import { PLATFORM_ID, makeStateKey, TransferState } from '@angular/core'
import { createClient, type ClientReturn, type QueryParams, type SanityClient } from '@sanity/client'
export class SanityService {
private client: SanityClient
+ private transferState = inject(TransferState)
+ private platformId = inject(PLATFORM_ID)
async fetch<Query extends string>(query: Query, params?: QueryParams): Promise<ClientReturn<Query>> {
+ const key = makeStateKey<ClientReturn<Query>>(await hashQuery(query, params))
+
+ if (isPlatformBrowser(this.platformId)) {
+ const cached = this.transferState.get(key, null)
+ if (cached !== null) {
+ this.transferState.remove(key)
+ return cached
+ }
+ }
+
const result = await this.client.fetch(query, params)
+
+ if (isPlatformServer(this.platformId)) {
+ this.transferState.set(key, result)
+ }
+
return result
}
}
```
The `hashQuery` helper keeps `TransferState` keys short (SHA-256 hex) instead of embedding raw GROQ strings in the serialized HTML.
Prerendering dynamic routes:
```typescript
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr'
export const serverRoutes: ServerRoute[] = [
{
path: 'post/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
// Fetch all slugs from Sanity at build time
const client = createClient({ projectId: '...', dataset: '...', apiVersion: '...', useCdn: true })
const slugs = await client.fetch<string[]>(`*[_type == "post"].slug.current`)
return slugs.map((slug) => ({ slug }))
},
},
{ path: '**', renderMode: RenderMode.Server },
]
```
**Bad:** Using `isPlatformBrowser()` in templates to conditionally render content — causes hydration mismatch.
**Good:** Using `@defer` or `afterNextRender()` for browser-only code.
## 9. Visual Editing
> **Important:** Angular does not have official Sanity Visual Editing support. There is no `@sanity/visual-editing` integration, no Stega encoding, and no click-to-edit overlay for Angular applications. This is unlike Next.js, Nuxt, and SvelteKit which have first-party support.
### Preview Mode (Basic)
For draft content preview, create a separate preview client with an API token:
```typescript
@Injectable({ providedIn: 'root' })
export class SanityService {
private client: SanityClient
private previewClient: SanityClient
constructor() {
this.client = createClient({
projectId: environment.sanity.projectId,
dataset: environment.sanity.dataset,
apiVersion: environment.sanity.apiVersion,
useCdn: true,
})
this.previewClient = this.client.withConfig({
useCdn: false,
token: environment.sanity.previewToken, // Server-side only!
perspective: 'drafts',
})
}
fetch<Query extends string>(query: Query, params?: QueryParams, preview = false): Promise<ClientReturn<Query>> {
const client = preview ? this.previewClient : this.client
return client.fetch(query, params)
}
}
```
> **Security:** Never expose the preview token in client-side bundles. Use this pattern only with SSR where the token stays on the server, or proxy preview requests through a backend API.
### Community Visual Editing
The community library `@limitless-angular/sanity` provides experimental Visual Editing support for Angular, including overlay click-to-edit functionality. Check its documentation for current status and limitations.
## 10. Error Handling
Common errors when integrating Angular with Sanity:
| Error | Cause | Solution |
|---|---|---|
| `401 Unauthorized` | Invalid or missing API token | Verify token in [Sanity Manage](https://www.sanity.io/manage). Ensure it has correct permissions. |
| `403 Forbidden` | CORS origin not allowed | Add your Angular dev/production URL to CORS origins in [Sanity Manage](https://www.sanity.io/manage). |
| `422 Invalid query` | GROQ syntax error | Test queries in Vision plugin or Sanity's GROQ playground. See `groq.md`. |
| Hydration mismatch | Conditional rendering based on platform | Use `@defer` or `afterNextRender()` instead of `isPlatformBrowser()` checks. |
| Empty response | Missing dataset or wrong `apiVersion` | Verify environment config. Use a date-based `apiVersion` (e.g., `'2025-05-01'`). |
| Images not loading | Missing `@sanity/image-url` setup | Ensure `getImageUrlBuilder` is called with a valid image reference. See `image.md`. |
For GROQ query patterns and best practices, see `groq.md`. For schema design, see `schema.md`.
@@ -0,0 +1,462 @@
---
title: Sanity App SDK
description: Rules for building custom applications with the Sanity App SDK, including React hooks, document handles, real-time patterns, and Suspense best practices.
---
# Sanity App SDK
Build custom React applications that interact with Sanity content in real-time.
## Tech Stack
- **Framework:** React 19+, TypeScript
- **Packages:** `@sanity/sdk`, `@sanity/sdk-react`
- **Optional UI:** `@sanity/ui`, `styled-components`
- **Runtime:** Node.js 20+
## Commands
```bash
# Basic quickstart
npx sanity@latest init --template app-quickstart --organization <your-org-id> --output-path . --typescript --skip-mcp
# With Sanity UI components
npx sanity@latest init --template app-sanity-ui --organization <your-org-id> --output-path . --typescript --skip-mcp
# Start development server
npm run dev
# Deploy to Sanity
npx sanity@latest deploy
# Install Sanity UI
npm install @sanity/ui styled-components
```
## Project Structure
```
my-app/
├── sanity.cli.ts # CLI config (org ID, entry point)
├── src/
│ ├── App.tsx # Root component with SanityApp provider
│ ├── App.css # Global styles
│ └── components/ # Your components
├── package.json
└── tsconfig.json
```
## Boundaries
- **Always:** Wrap data-fetching components in `<Suspense>`, use `documentId` as React `key`, read/write directly to Content Lake (not local state)
- **Always:** Use `useDocuments` for lists, `useDocumentProjection` for display, `useDocument` + `useEditDocument` for editing
- **Ask first:** Before using `useQuery` with raw GROQ (prefer `useDocuments` + `useDocumentProjection`)
- **Ask first:** Before adding multiple data-fetching hooks in a single component
- **Never:** Use `useState` for form values that should sync with Content Lake
- **Never:** Use array index as React `key` for document lists (breaks real-time updates)
- **Never:** Forget the `fallback` prop on `<SanityApp>` and `<Suspense>` boundaries
---
## Configuration
### CLI Config (`sanity.cli.ts`)
```typescript
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
app: {
organizationId: 'your-org-id',
entry: './src/App.tsx',
},
})
```
### App Root (`src/App.tsx`)
```typescript
import { SanityApp, type SanityConfig } from '@sanity/sdk-react'
export default function App() {
const config: SanityConfig[] = [
{
projectId: 'your-project-id',
dataset: 'production',
},
]
return (
<SanityApp config={config} fallback={<div>Loading...</div>}>
<YourComponents />
</SanityApp>
)
}
```
### With Sanity UI
```typescript
import { SanityApp, type SanityConfig } from '@sanity/sdk-react'
import { ThemeProvider } from '@sanity/ui'
import { buildTheme } from '@sanity/ui/theme'
const theme = buildTheme()
export default function App() {
const config: SanityConfig[] = [
{ projectId: 'your-project-id', dataset: 'production' },
]
return (
<ThemeProvider theme={theme}>
<SanityApp config={config} fallback={<div>Loading...</div>}>
<YourComponents />
</SanityApp>
</ThemeProvider>
)
}
```
### Environment Variables
Prefix with `SANITY_APP_` for automatic bundling:
```bash
SANITY_APP_PROJECT_ID=abc123
SANITY_APP_DATASET=production
```
Access: `process.env.SANITY_APP_PROJECT_ID`
---
## Document Handles
Lightweight references to documents. Fetch handles first, then load content as needed.
```typescript
interface DocumentHandle {
documentId: string
documentType: string
projectId?: string
dataset?: string
}
```
### Creating Handles
```typescript
// Best: From useDocuments hook
const { data: handles } = useDocuments({ documentType: 'article' })
// Good: With helper (preserves literal types for TypeGen)
import { createDocumentHandle } from '@sanity/sdk'
const handle = createDocumentHandle({
documentId: 'my-doc-id',
documentType: 'article',
})
// Good: With as const (preserves literal types)
const handle = {
documentId: 'my-doc-id',
documentType: 'article',
} as const
```
---
## Hook Selection
| Hook | Use Case | Returns |
|------|----------|---------|
| `useDocuments` | List of documents (infinite scroll) | Document handles |
| `usePaginatedDocuments` | Paginated lists with page controls | Document handles |
| `useDocument` | Single document, real-time editing | Full document or field |
| `useDocumentProjection` | Specific fields, display only | Projected data |
| `useQuery` | Complex GROQ queries (use sparingly) | Raw query results |
---
## Code Patterns
### Fetching a Document List
```typescript
// Good: Fetch handles, render items with Suspense
import { Suspense } from 'react'
import { useDocuments } from '@sanity/sdk-react'
function ArticleList() {
const { data, hasMore, loadMore, isPending } = useDocuments({
documentType: 'article',
batchSize: 10,
orderings: [{ field: '_updatedAt', direction: 'desc' }],
})
return (
<>
<ul>
{data.map((handle) => (
<Suspense key={handle.documentId} fallback={<li>Loading...</li>}>
<ArticleItem {...handle} />
</Suspense>
))}
</ul>
{hasMore && (
<button onClick={loadMore} disabled={isPending}>
Load More
</button>
)}
</>
)
}
```
```typescript
// Bad: Over-fetching with raw GROQ, no pagination
function BadArticleList() {
const { data } = useQuery(`*[_type == "article"]`)
return data?.map((doc, i) => <li key={i}>{doc.title}</li>)
}
```
### Projecting Content from a Handle
```typescript
// Good: Project only needed fields
import { useDocumentProjection, type DocumentHandle } from '@sanity/sdk-react'
function ArticleItem(handle: DocumentHandle) {
const { data } = useDocumentProjection({
...handle,
projection: `{
title,
"authorName": author->name,
"imageUrl": image.asset->url
}`,
})
if (!data) return null
return (
<li>
<h2>{data.title}</h2>
<p>By {data.authorName}</p>
</li>
)
}
```
### Real-time Editing
```typescript
// Good: Read and write directly to Content Lake
import { useDocument, useEditDocument, type DocumentHandle } from '@sanity/sdk-react'
function TitleInput(handle: DocumentHandle) {
const { data: title } = useDocument({ ...handle, path: 'title' })
const editTitle = useEditDocument({ ...handle, path: 'title' })
return (
<input
type="text"
value={title ?? ''}
onChange={(e) => editTitle(e.currentTarget.value)}
/>
)
}
```
```typescript
// Bad: Local state with submit button - causes stale data
function BadTitleForm(handle: DocumentHandle) {
const [value, setValue] = useState('')
const editTitle = useEditDocument({ ...handle, path: 'title' })
function handleSubmit(e: FormEvent) {
e.preventDefault()
editTitle(value) // Only writes on submit!
}
return (
<form onSubmit={handleSubmit}>
<input value={value} onChange={(e) => setValue(e.target.value)} />
<button type="submit">Save</button>
</form>
)
}
```
### Document Actions
```typescript
import {
useApplyDocumentActions,
publishDocument,
unpublishDocument,
deleteDocument,
} from '@sanity/sdk-react'
function DocumentActions({ handle }: { handle: DocumentHandle }) {
const apply = useApplyDocumentActions()
return (
<div>
<button onClick={() => apply(publishDocument(handle))}>Publish</button>
<button onClick={() => apply(unpublishDocument(handle))}>Unpublish</button>
<button onClick={() => apply(deleteDocument(handle))}>Delete</button>
</div>
)
}
```
---
## Suspense Patterns
The App SDK uses React Suspense. Every data-fetching component must be wrapped.
### One Hook Per Component
```typescript
// Good: Separate fetchers into separate components
function EventsAndVenues() {
return (
<>
<Suspense fallback="Loading events...">
<EventsList />
</Suspense>
<Suspense fallback="Loading venues...">
<VenuesList />
</Suspense>
</>
)
}
function EventsList() {
const { data } = useDocuments({ documentType: 'event' })
return <List items={data} />
}
function VenuesList() {
const { data } = useDocuments({ documentType: 'venue' })
return <List items={data} />
}
```
```typescript
// Bad: Multiple fetchers in one component
function BadComponent() {
const { data: events } = useDocuments({ documentType: 'event' })
const { data: venues } = useDocuments({ documentType: 'venue' })
// Both trigger Suspense together, causing unnecessary re-renders
}
```
### Prevent Layout Shift
```typescript
// Good: Fallback matches final component dimensions
const BUTTON_TEXT = 'Open in Studio'
export function OpenInStudio({ handle }: { handle: DocumentHandle }) {
return (
<Suspense fallback={<Button text={BUTTON_TEXT} disabled />}>
<OpenInStudioButton handle={handle} />
</Suspense>
)
}
function OpenInStudioButton({ handle }: { handle: DocumentHandle }) {
const { navigateToStudioDocument } = useNavigateToStudioDocument(handle)
return <Button onClick={navigateToStudioDocument} text={BUTTON_TEXT} />
}
```
---
## Event Handling
```typescript
import { useDocumentEvent, DocumentEvent } from '@sanity/sdk-react'
function DocumentWatcher(handle: DocumentHandle) {
useDocumentEvent({
...handle,
onEvent: (event) => {
switch (event.type) {
case 'edited':
console.log('Edited:', event.documentId)
break
case 'published':
console.log('Published:', event.documentId)
break
case 'deleted':
console.log('Deleted:', event.documentId)
break
}
},
})
return null
}
```
---
## Multi-Project Apps
```typescript
const config: SanityConfig[] = [
{ projectId: 'project-1', dataset: 'production' },
{ projectId: 'project-2', dataset: 'staging' },
]
// Handles include project/dataset info
const handle: DocumentHandle = {
documentId: 'doc-123',
documentType: 'article',
projectId: 'project-1',
dataset: 'production',
}
```
---
## Lazy Loading with Refs
```typescript
function LazyContent(handle: DocumentHandle) {
const ref = useRef(null)
const { data } = useDocumentProjection({
...handle,
ref, // Only loads when element enters viewport
projection: '{ title, body }',
})
return <div ref={ref}>{data?.title}</div>
}
```
---
## What's NOT Included
The App SDK provides hooks and data stores. You bring:
- UI components (use Sanity UI or your own)
- Router
- Form validation
- Schema validation
---
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Safari dev issues | Use Chrome or Firefox during development |
| Port 3333 in use | `npm run dev -- --port 3334` |
| Auth errors | `npx sanity@latest logout && npx sanity@latest login` |
@@ -0,0 +1,107 @@
---
title: Astro & Sanity Integration Rules
description: Integration guide for Astro, including @sanity/astro, visual editing, and data fetching.
---
# Astro & Sanity Integration Rules
## 1. Setup & Configuration
### Configuration (`astro.config.mjs`)
Use the official `@sanity/astro` integration.
```javascript
import { defineConfig } from "astro/config";
import sanity from "@sanity/astro";
export default defineConfig({
integrations: [
sanity({
projectId: "YOUR_PROJECT_ID",
dataset: "production",
useCdn: false, // False for static builds
studioBasePath: "/admin", // If embedding Studio
}),
],
});
```
### Client Type Safety
Enable types in `tsconfig.json`.
```json
{
"compilerOptions": {
"types": ["@sanity/astro/module"]
}
}
```
## 2. Data Fetching
### Basic Fetching
Use `sanityClient` from `sanity:client` in the frontmatter of your `.astro` files.
```astro
---
import { sanityClient } from "sanity:client";
import { defineQuery } from "groq";
const POSTS_QUERY = defineQuery(`*[_type == "post"]{title, slug}`);
const posts = await sanityClient.fetch(POSTS_QUERY);
---
<ul>
{posts.map(post => <li>{post.title}</li>)}
</ul>
```
### Helper Functions
It's best practice to abstract queries into a utility file (e.g., `src/utils/sanity.ts`).
```typescript
import { sanityClient } from "sanity:client";
import { defineQuery } from "groq";
const POSTS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)]`);
export async function getPosts() {
return await sanityClient.fetch(POSTS_QUERY);
}
```
## 3. Portable Text
Use `astro-portabletext` for rendering rich text.
```astro
---
import { PortableText } from "astro-portabletext";
const { body } = Astro.props;
---
<div class="prose">
<PortableText value={body} />
</div>
```
## 4. Image Handling
Use `@sanity/image-url` to generate optimized image URLs.
```typescript
import imageUrlBuilder from "@sanity/image-url";
import { sanityClient } from "sanity:client";
const builder = imageUrlBuilder(sanityClient);
export function urlFor(source) {
return builder.image(source);
}
```
## 5. Visual Editing (Live Preview)
Astro handles visual editing slightly differently depending on if you are using Hybrid or Static mode.
### Setup
Ensure `stega` is enabled in your client configuration if you want clickable overlays.
For real-time updates in the presentation tool, you typically need a React component wrapper (since Astro components don't re-render on the client) or use the View Transitions API with a loader.
*Note: The `@sanity/astro` integration is evolving. Check the latest docs for "Visual Editing" support.*
@@ -0,0 +1,88 @@
---
title: Sanity Blueprints
description: Rules for Sanity Blueprints, the Infrastructure as Code solution for managing Sanity resources declaratively.
---
# Sanity Blueprints
Sanity's Infrastructure as Code (IaC) solution. Define resources declaratively in `sanity.blueprint.ts`, track in version control, deploy with a single command.
## Mental Model
```
Blueprint (code) → Stack (deployed state) → Resources (real infrastructure)
```
| Concept | What it is |
|---------|------------|
| **Blueprint** | A declarative configuration file (`sanity.blueprint.ts`) that describes your desired infrastructure |
| **Stack** | The deployed, real-world collection of resources managed by Blueprints |
| **Resources** | Individual Sanity components: CORS origins, webhooks, datasets, functions, roles, robots |
| **Operation** | A deployment execution that applies Blueprint changes to resources in a Stack |
### How it works
1. Initialize and edit a Blueprint file describing desired resources
2. Run `sanity blueprints deploy` to apply changes to resources in a Stack
3. Blueprints creates/updates a Stack with your resources
4. The Stack persists — future deploys update it based on Blueprint changes
**Key insight:** The Blueprint is your *intent*. The Stack is *reality*. Blueprints reconciles the two.
## Available Resources
Blueprints can manage these Sanity components:
- Document Functions
- Media Library Asset Functions
More resources are coming soon.
## CLI Commands
```bash
sanity blueprints init <name> # Initialize a new blueprint project
sanity blueprints info # Show current stack status and resources
sanity blueprints plan # Preview changes before deploying
sanity blueprints deploy # Deploy the blueprint (creates/updates stack)
sanity blueprints config # Configure the blueprint (edit project and stack)
sanity blueprints logs # View deployment logs
sanity blueprints doctor # Check for potential issues
sanity blueprints stacks # List all stacks for the project
sanity blueprints destroy # Destroy all resources in the stack
```
## Basic Workflow
### 1. Initialize
```bash
sanity blueprints init my-infra
cd my-infra
sanity blueprints info
```
This creates a `sanity.blueprint.ts` file and links it to a Sanity project.
### 2. Define resources
Edit `sanity.blueprint.ts` to add resources using typed helper functions from `@sanity/blueprints`.
### 3. Preview and deploy
```bash
sanity blueprints plan # See what will change
sanity blueprints deploy # Apply changes
```
### 4. Iterate
Modify your Blueprint and redeploy. Blueprints handles creating, updating, or removing resources to match your definition.
## Key Behaviors
- **Additive by default** — New resources in the Blueprint are created
- **Updates in place** — Changed resources are updated when possible
- **Removal = destruction** — Resources removed from the Blueprint are destroyed from the Stack
- **References** — Resources can reference each other (e.g., a webhook can reference a dataset)
- **Rollback on failure** — If a deployment fails partway through, Blueprints attempts to rollback
@@ -0,0 +1,634 @@
---
title: Sanity Functions
description: Rules for Sanity Functions — serverless event handlers that react to content changes in Sanity's Content Lake. Covers blueprint configuration, handler patterns, testing, deployment, and recursion control.
---
# Sanity Functions
Serverless event handlers hosted on Sanity's infrastructure, configured via **Blueprints** and triggered by document lifecycle events.
> **Experimental feature**: APIs may change. Always use `npx sanity@latest`.
## When to use
- Set computed/derived fields (timestamps, slugs, summaries)
- Enrich or validate content on publish
- Trigger external services (CDN purge, deploy hooks, notifications)
- Automate workflows (translation, tagging, cross-posting)
- Sync content to external systems
- Invoke Agent Actions in response to content events
## When NOT to use
- Logic needs >900s execution or >200MB bundle — use an external worker
- High-throughput bulk operations that exceed rate limits (200/fn/30s, 4000/project/30s)
- A simple POST to an external URL on publish with no document data shaping — use a webhook
- Client-side or UI-driven logic (validation, conditional fields) — belongs in Studio schema config
## Requirements
| Dependency | Version |
|:---|:---|
| Node.js | v24.x (matches deployed runtime) |
| Sanity CLI | v4.12.0+ |
| `@sanity/blueprints` | Latest |
| `@sanity/functions` | Latest |
| `@sanity/client` | v7.12.0+ (includes recursion protection) |
## Project Structure
Organize functions alongside your Sanity project, one level above the Studio directory:
```
my-project/
├── studio/
├── next-app/
├── functions/
│ ├── my-function/
│ │ ├── index.ts # Handler code (entry point)
│ │ └── package.json # (optional) function-level dependencies
│ └── another-function/
│ └── index.ts
├── sanity.blueprint.ts # Blueprint configuration
├── package.json # Project-level dependencies
└── node_modules/
```
The function directory name must match the `name` in the blueprint config. Each function exports a `handler` from its `index.ts` (or `index.js`).
---
## Step-by-step: Creating a Function
### 1. Initialize a Blueprint
```bash
npx sanity@latest blueprints init . \
--type ts \
--stack-name production \
--project-id <your-project-id>
```
This creates `sanity.blueprint.ts` and `.sanity/blueprint.config.json` (add the latter to `.gitignore`).
### 2. Scaffold a Function
```bash
npx sanity@latest blueprints add function \
--name my-function \
--fn-type document-publish \
--installer npm
```
`--fn-type` options: `document-create`, `document-update`, `document-publish` (deprecated), `document-delete`.
### 3. Configure the Blueprint
```typescript
// sanity.blueprint.ts
import { defineBlueprint, defineDocumentFunction } from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineDocumentFunction({
name: 'my-function',
event: {
on: ['create', 'update'],
filter: '_type == "post"',
},
}),
],
})
```
### 4. Write the Handler
```typescript
// functions/my-function/index.ts
import { documentEventHandler } from '@sanity/functions'
import { createClient } from '@sanity/client'
interface PostData {
_id: string
_type: string
title: string
}
export const handler = documentEventHandler<PostData>(async ({ context, event }) => {
const { data } = event
const client = createClient({
...context.clientOptions,
apiVersion: '2025-05-08',
})
try {
await client.patch(data._id, {
setIfMissing: { firstPublished: new Date().toISOString() },
})
console.log(`Set firstPublished on ${data._id}`)
} catch (error) {
console.error('Failed to patch document:', error)
}
})
```
### 5. Test Locally
```bash
# Visual dev playground
npx sanity@latest functions dev
# CLI testing
npx sanity@latest functions test my-function \
--dataset production \
--with-user-token
# With a specific document
npx sanity@latest functions test my-function \
--document-id abc123 \
--dataset production \
--with-user-token
```
### 6. Deploy
```bash
npx sanity@latest blueprints deploy
```
### 7. View Logs
```bash
npx sanity@latest functions logs my-function
npx sanity@latest functions logs my-function --watch
```
---
## Handler Reference
Every handler receives `{ context, event }`:
### `context`
| Property | Type | Description |
|:---|:---|:---|
| `clientOptions.apiHost` | `string` | API host URL |
| `clientOptions.projectId` | `string` | Sanity project ID |
| `clientOptions.dataset` | `string` | Dataset name |
| `clientOptions.token` | `string` | Robot token (deployed only) |
| `local` | `boolean \| undefined` | `true` during local testing |
| `eventResourceType` | `string` | `'dataset'` or `'media-library'` |
| `eventResourceId` | `string` | e.g., `'projectId.datasetName'` |
### `event`
```typescript
{
data: {
_id: string
_type: string
// ... rest of document (shaped by projection if set)
}
}
```
When testing locally, `context.clientOptions` only has `projectId` and `apiHost`. Use `--dataset` and `--with-user-token` flags to supply the rest.
---
## Blueprint Configuration
### `defineDocumentFunction` Options
| Option | Type | Default | Description |
|:---|:---|:---|:---|
| `name` | `string` | required | Must match the directory name under `functions/` |
| `displayName` | `string` | — | Human-readable display name |
| `src` | `string` | `functions/<name>` | Path to function source directory |
| `memory` | `number` | `1` | Memory in GB (max 10) |
| `timeout` | `number` | `10` | Timeout in seconds (max 900) |
| `runtime` | `string` | `'nodejs22.x'` | `'node'`, `'nodejs22.x'`, or `'nodejs24.x'` |
| `project` | `string` | — | Project ID. Required if blueprint is org-scoped. |
| `robotToken` | `string` | — | Custom robot token name for the function |
| `event` | `object` | required | Event configuration (see below) |
| `env` | `Record<string, string>` | — | Environment variables via `process.env` |
### `event` Options
| Option | Type | Default | Description |
|:---|:---|:---|:---|
| `on` | `string[]` | required | `'create'`, `'update'`, `'delete'`. Legacy `'publish'` is deprecated. |
| `filter` | `string` | — | GROQ filter body (no `*[...]` wrapper) |
| `projection` | `string` | — | GROQ projection to shape `event.data`. Wrap in `{}`. |
| `includeDrafts` | `boolean` | `false` | Trigger on draft changes |
| `includeAllVersions` | `boolean` | `false` | Trigger on all document versions |
| `resource` | `object` | — | Scope to dataset: `{ type: 'dataset', id: 'projectId.datasetName' }` |
### `defineMediaLibraryAssetFunction`
For Media Library asset events. Requires `@sanity/blueprints` v0.4.0+ and `@sanity/functions` v1.1.0+.
```typescript
import { defineBlueprint, defineMediaLibraryAssetFunction } from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineMediaLibraryAssetFunction({
name: 'asset-handler',
event: {
on: ['delete'],
filter: 'documents::incomingGlobalDocumentReferenceCount() > 0',
projection: '{_id, versions, title}',
resource: {
type: 'media-library',
id: 'mlYourLibraryId',
},
},
}),
],
})
```
---
## Event Types
| Event | Description |
|:---|:---|
| `create` | New document created |
| `update` | Existing document modified (for published docs, fires when a draft/version is published) |
| `delete` | Document deleted |
| `publish` | **Deprecated.** Equivalent to `['create', 'update']`. Migrate to explicit events. |
Often best to use `['create', 'update']` together for published document triggers.
---
## GROQ Filter Tips
- Only the filter body — `_type == 'post'`, not `*[_type == 'post']`
- `delta::changedAny(fieldName)` — trigger only when specific fields change
- `sanity::dataset() == 'production'` — scope to a dataset without `resource` config
- `_id in path('drafts.**')` with `includeDrafts: true` — draft-only triggers
- Combine conditions to prevent recursion: `_type == 'post' && !defined(processedAt)`
---
## Projections
- Shape the data passed to `event.data`
- Limited to the invoking document's scope (plus `→` for references)
- Nested filters in projections (like `*[references(^._id)]`) will fail silently — query inside the function instead
- Wrap in `{}`: `projection: '{title, _id, slug}'`
---
## Environment Variables
Three ways to set them:
1. Blueprint config: `env: { MY_VAR: 'value' }`
2. CLI: `npx sanity functions env add my-function MY_VAR my-value`
3. Local testing: `MY_VAR=value npx sanity functions test my-function`
Access in handler code via `process.env.MY_VAR`.
---
## Critical Rules
### Preventing Recursion
If your function mutates the same document type it listens to, you **will** create an infinite loop.
**✅ Correct — use GROQ filters to exclude processed documents:**
```typescript
defineDocumentFunction({
name: 'first-published',
event: {
on: ['create', 'update'],
filter: "_type == 'post' && !defined(firstPublished)",
},
})
```
**✅ Correct — use `@sanity/client` v7.12.0+ for automatic lineage headers:**
```typescript
import { createClient } from '@sanity/client'
// Client automatically sets X-Sanity-Lineage header
// Recursive chains are limited to 16 invocations
const client = createClient({
...context.clientOptions,
apiVersion: '2025-05-08',
})
```
**❌ Incorrect — no recursion guard:**
```typescript
defineDocumentFunction({
name: 'update-post',
event: {
on: ['create', 'update'],
filter: "_type == 'post'", // Will re-trigger on its own writes!
},
})
```
### Local Testing Safety
Use `context.local` to prevent accidental mutations during testing:
```typescript
// Skip mutations entirely in test
if (!context.local) {
await client.createOrReplace(someDoc)
}
// Or use dryRun
await client.patch(event.data._id, {
set: { processed: true },
}).commit({ dryRun: context.local })
// Or use noWrite for Agent Actions
await client.agent.action.generate({
schemaId: 'your-schema-id',
documentId: event.data._id,
instruction: 'Summarize this document',
target: { path: ['summary'] },
noWrite: context.local,
})
```
### Limits
- Max bundle size: 200MB (including dependencies). Prefer slim, platform-agnostic packages.
- Rate limits: 200 invocations/fn/30s, 4000/project/30s
- Max timeout: 900s. Larger functions = slower cold starts.
### Cost
Cost = invocations × (memory GB × duration seconds). Default is 1GB memory. A function averaging 1GB and 40ms duration can run ~500k invocations within 20K GB-seconds. [Monitor usage at the organization level](https://www.sanity.io/manage).
---
## Common Patterns
### Deploy hook / CDN invalidation
**Blueprint:**
```typescript
defineDocumentFunction({
name: 'deploy-hook',
event: {
on: ['create', 'update'],
filter: '_type == "page"',
},
})
```
**Handler:**
```typescript
export const handler = documentEventHandler(async ({ context, event }) => {
const URL = process.env.DEPLOY_HOOK_URL
if (!URL) throw new Error('DEPLOY_HOOK_URL is not set')
await fetch(URL)
console.log('Deploy hook triggered')
})
```
Set the env var: `npx sanity functions env add deploy-hook DEPLOY_HOOK_URL https://...`
### Set a timestamp on first publish
Uses the same pattern as the step-by-step example above. The key insight: the `!defined(firstPublished)` GROQ filter prevents re-triggering after the field is set. The `setIfMissing` patch is a redundant safety net.
```typescript
defineDocumentFunction({
name: 'first-published',
event: {
on: ['create', 'update'],
filter: '_type == "post" && !defined(firstPublished)',
},
})
```
### Auto-translate with Agent Actions
**Blueprint:**
```typescript
defineDocumentFunction({
name: 'translate',
event: {
on: ['create', 'update'],
filter: "_type == 'post' && language == 'en-US'",
projection: '{_id}',
},
})
```
**Handler:**
```typescript
export const handler = documentEventHandler(async ({ context, event }) => {
const client = createClient({ ...context.clientOptions, apiVersion: 'vX' })
await client.agent.action.translate({
schemaId: 'your-schema-id',
async: true,
documentId: event.data._id,
languageFieldPath: 'language',
targetDocument: {
operation: 'createOrReplace',
_id: `${event.data._id}-el-GR`,
},
fromLanguage: { id: 'en-US', title: 'English' },
toLanguage: { id: 'el-GR', title: 'Greek' },
})
})
```
The GROQ filter ensures only English documents trigger the function. The translated document gets a different `language` value, preventing recursive triggers.
### Auto-tag with Agent Actions
**Blueprint:**
```typescript
defineDocumentFunction({
name: 'auto-tag',
event: {
on: ['create', 'update'],
filter: "_type == 'post'",
projection: '{_id, title, body}',
},
})
```
**Handler:**
```typescript
export const handler = documentEventHandler(async ({ context, event }) => {
const client = createClient({ ...context.clientOptions, apiVersion: 'vX' })
await client.agent.action.generate({
schemaId: 'your-schema-id',
documentId: event.data._id,
instruction: 'Analyze the content and generate 3 relevant tags. Reuse existing tags when possible.',
target: { path: ['tags'] },
async: true,
})
})
```
### Slack notification on publish
```typescript
export const handler = documentEventHandler(async ({ context, event }) => {
const WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL
if (!WEBHOOK_URL) throw new Error('SLACK_WEBHOOK_URL not set')
await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `📝 New content published: *${event.data.title || event.data._id}* (${event.data._type})`,
}),
})
})
```
### Scope to a specific dataset
**Option A — `resource` config:**
```typescript
defineDocumentFunction({
name: 'production-only',
event: {
on: ['update'],
filter: "_type == 'post'",
resource: { type: 'dataset', id: 'myProjectId.production' },
},
})
```
**Option B — GROQ filter:**
```typescript
defineDocumentFunction({
name: 'production-only',
event: {
on: ['update'],
filter: "_type == 'post' && sanity::dataset() == 'production'",
},
})
```
### React to Media Library asset changes
Requires `@sanity/blueprints` v0.4.0+ and `@sanity/functions` v1.1.0+.
**Blueprint:**
```typescript
import { defineBlueprint, defineMediaLibraryAssetFunction } from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineMediaLibraryAssetFunction({
name: 'asset-deleted',
event: {
on: ['delete'],
filter: 'documents::incomingGlobalDocumentReferenceCount() > 0',
projection: '{_id, versions, title}',
resource: { type: 'media-library', id: 'mlYourLibraryId' },
},
}),
],
})
```
**Handler:**
```typescript
export const handler = documentEventHandler(async ({ context, event }) => {
const { eventResourceId } = context // Media Library ID
const client = createClient({
...context.clientOptions,
apiVersion: '2025-05-08',
})
const response = await client.request({
uri: `/media-libraries/${eventResourceId}/query`,
method: 'POST',
body: { query: `*[_type == 'sanity.imageAsset']` },
})
console.log('Assets:', response)
})
```
### Recursion control with custom HTTP clients
If not using `@sanity/client`, implement lineage tracking manually:
```typescript
export const handler = documentEventHandler(async ({ context, event }) => {
const lineage = process.env.X_SANITY_LINEAGE
await fetch(`https://${context.clientOptions.projectId}.api.sanity.io/v2025-05-08/data/mutate/production`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${context.clientOptions.token}`,
...(lineage ? { 'X-Sanity-Lineage': lineage } : {}),
},
body: JSON.stringify({
mutations: [{ patch: { id: event.data._id, set: { processed: true } } }],
}),
})
})
```
### Multiple functions in one blueprint
```typescript
export default defineBlueprint({
resources: [
defineDocumentFunction({
name: 'first-published',
event: {
on: ['create', 'update'],
filter: "_type == 'post' && !defined(firstPublished)",
},
}),
defineDocumentFunction({
name: 'notify-slack',
event: {
on: ['create', 'update'],
filter: "_type == 'post'",
projection: '{title, _id}',
},
}),
defineDocumentFunction({
name: 'sync-algolia',
timeout: 30,
event: {
on: ['create', 'update', 'delete'],
filter: "_type == 'product'",
},
}),
],
})
```
---
## CI/CD Deployment
Use the [Blueprints GitHub Action](https://github.com/sanity-io/blueprints-actions)
```yaml
- uses: sanity-io/blueprints-actions/deploy@deploy-v3
with:
sanity-token: ${{ secrets.SANITY_DEPLOY_TOKEN }}
```
Only personal auth tokens are supported for deployment (not robot tokens).
@@ -0,0 +1,315 @@
---
title: Sanity Getting Started Guide
description: Use these rules when users ask to 'Get started with Sanity' or need help setting up a new Sanity project.
---
# Sanity Getting Started Guide
## Overview
Getting started with Sanity follows three phases:
1. **Studio & Schema** — Set up Sanity Studio and define your content model
2. **Content** — Import existing content or generate placeholder content via MCP
3. **Frontend** — Integrate with your application (framework-specific)
## Communication Style
**Keep responses succinct:**
- Tell the user what you did: "Created post schema with title, body, and slug"
- Ask direct questions: "What kind of content are you building?"
- Avoid verbose explanations of what you're about to do
- Don't explain every step unless the user asks
**Examples:**
- **Good**: "Schema deployed. Ready to add some content?"
- **Bad**: "I'm going to deploy your schema to the Content Lake so that the MCP server can recognize your new document types. This will allow..."
---
## Get Started with Sanity (Interactive Guide)
**TRIGGER PHRASE:** When the user says "Get started with Sanity" or similar, follow these steps.
**Before starting:** Let the user know they can pause and resume anytime by saying "Continue Sanity setup".
**RESUME TRIGGER:** If the user says "Continue Sanity setup", check what's already configured:
- Does `sanity.config.ts` exist? → Studio is set up
- Are there files in `schemaTypes/`? → Schema exists
- Is there a frontend framework in `package.json`? → May need integration
Resume from where they left off.
---
## Phase 1: Studio & Schema
### Step 1: Check for Existing Studio
**Look for `sanity.config.ts` or `sanity.cli.ts`:**
**If NO Studio found:**
- Ask: "Want to create a new Sanity Studio?"
- If yes, run:
```bash
npm create sanity@latest -- --template clean --typescript
```
**If Studio exists:**
- Read the config to get `projectId` and `dataset`
- Proceed to Step 2
### Step 2: Check for Existing Schema
**Look in `schemaTypes/`, `schemas/`, or `src/sanity/schemaTypes/`:**
**If NO schema found:**
- Ask: "What kind of content are you building? (e.g., Blog, E-commerce, Portfolio)"
- Create appropriate schema types based on their answer
- See `schema.md` for patterns
**If schema exists:**
- Show them what you found
- Ask: "Want to add more content types or modify existing ones?"
**If they want a quick example:**
Create a basic blog schema:
```typescript
// schemaTypes/post.ts
import { defineType, defineField } from 'sanity'
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
defineField({ name: 'body', type: 'array', of: [{ type: 'block' }] }),
],
})
```
### Step 3: Deploy Schema
**Required before Phase 2:**
```bash
npx sanity schema deploy
```
This uploads your schema to the Content Lake so MCP tools can work with it.
---
## Phase 2: Content
### Step 1: Check for Existing Content
**Use MCP `query_documents` to check:**
```
*[_type == "post"][0...5]
```
**If content exists:**
- Show them a summary
- Ask: "Want to add more content or move to frontend integration?"
**If NO content:**
- Ask: "Do you want to:
1. Import existing content (from another CMS, markdown, etc.)
2. Generate sample content with AI
3. Skip this and add content manually in the Studio"
### Step 2a: Import Existing Content
If migrating from another CMS or files:
- See `migration.md`
- Use MCP `migrate_content` tool for guidance
### Step 2b: Generate Sample Content (MCP)
Use the Sanity MCP Server:
```
Tool: create_document
Type: post
Content: Create a sample blog post about getting started with Sanity
```
**If MCP fails:** Remind them to run `npx sanity schema deploy` first.
### MCP Setup (If Not Configured)
**Quick start via Sanity CLI:**
```bash
npx sanity@latest mcp configure
```
**Cursor:** [One-click install →](cursor://anysphere.cursor-deeplink/mcp/install?name=Sanity&config=eyJ1cmwiOiJodHRwczovL21jcC5zYW5pdHkuaW8iLCJ0eXBlIjoiaHR0cCJ9Cg==)
Or add to `.cursor/mcp.json`:
```json
{
"mcpServers": {
"Sanity": {
"type": "http",
"url": "https://mcp.sanity.io"
}
}
}
```
**Claude Code:**
```bash
claude mcp add Sanity -t http https://mcp.sanity.io --scope user
```
**VS Code:** Command Palette → `MCP: Open User Configuration` → add:
```json
{
"servers": {
"Sanity": {
"type": "http",
"url": "https://mcp.sanity.io"
}
}
}
```
---
## Phase 3: Frontend Integration
### Step 1: Detect Framework
**Check `package.json` dependencies:**
| Dependency | Framework | Rule File |
|------------|-----------|-----------|
| `next` | Next.js | `nextjs.md` |
| `@remix-run/react` or `react-router` | React Router / Remix | `remix.md` |
| `svelte` or `@sveltejs/kit` | SvelteKit | `svelte.md` |
| `nuxt` | Nuxt | `nuxt.md` |
| `astro` | Astro | `astro.md` |
**If NO framework found:**
- Ask: "Which framework are you using, or would you like to create a new app?"
- Guide them to create one or specify their choice
### Step 2: Next.js Integration (Inline)
If Next.js is detected, follow these essential steps:
**Install dependencies:**
```bash
npm install @sanity/client @sanity/image-url @portabletext/react
```
**Create the client (`src/sanity/client.ts`):**
```typescript
import { createClient } from "@sanity/client";
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: "2026-02-01", // Use current date for new projects
useCdn: false, // Use API directly for server-side rendering; set true for client-side reads
});
```
**Fetch content in a Server Component:**
```typescript
// app/posts/page.tsx
import { client } from "@/sanity/client";
import { defineQuery } from "groq";
const POSTS_QUERY = defineQuery(`*[_type == "post"]{ _id, title, slug }`);
export default async function PostsPage() {
const posts = await client.fetch(POSTS_QUERY);
return (
<ul>
{posts.map((post) => (
<li key={post._id}>
<a href={`/posts/${post.slug.current}`}>{post.title}</a>
</li>
))}
</ul>
);
}
```
**Add environment variables (`.env.local`):**
```
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
```
For advanced patterns (TypeGen, Visual Editing, `defineLive`), see `nextjs.md`.
### Step 3: Other Frameworks
For non-Next.js frameworks, read the corresponding rule file and follow its integration guide:
- **React Router / Remix:** `remix.md`
- **SvelteKit:** `svelte.md`
- **Nuxt:** `nuxt.md`
- **Astro:** `astro.md`
Each rule file contains framework-specific patterns for data fetching, Portable Text rendering, and Visual Editing.
---
## What's Next
Once setup is complete, let the user know:
"You're all set! Here are some things I can help with:
- **Visual Editing** — Click-to-edit in the Presentation tool (`visual-editing.md`)
- **TypeGen** — Type-safe queries with generated types (`typegen.md`)
- **Studio Structure** — Customize the Studio sidebar (`studio-structure.md`)
- **SEO** — Metadata, sitemaps, and Open Graph (`seo.md`)
- **i18n** — Multi-language content (`localization.md`)
Just ask about any of these!"
---
## Environment Variables
### Framework-Specific Prefixes
| Framework | Client-Side Prefix | Example |
|-----------|-------------------|---------|
| Next.js | `NEXT_PUBLIC_` | `NEXT_PUBLIC_SANITY_PROJECT_ID` |
| React Router / Remix | None (use loader) | `SANITY_PROJECT_ID` |
| SvelteKit | `PUBLIC_` | `PUBLIC_SANITY_PROJECT_ID` |
| Nuxt | `NUXT_PUBLIC_` | `NUXT_PUBLIC_SANITY_PROJECT_ID` |
| Astro | `PUBLIC_` | `PUBLIC_SANITY_PROJECT_ID` |
---
## Common Commands
```bash
npx sanity@latest mcp configure # Configure MCP for your editor
npx sanity dev # Start Studio locally
npx sanity schema deploy # Deploy schema (required for MCP!)
npx sanity deploy # Deploy Studio to Sanity hosting
npx sanity manage # Open project settings
npm run typegen # Generate TypeScript types
```
---
## Important Notes
- **Be succinct** — Guide step-by-step without over-explaining
- **Check context first** — Read existing files before suggesting changes
- **Don't give up** — If something fails, give the user a way to complete manually
- **Deploy schema early** — MCP tools won't work without it
- **One phase at a time** — Complete each phase before moving to the next
@@ -0,0 +1,350 @@
---
title: GROQ Query Maintenance & Best Practices
description: Guidelines for GROQ queries, type safety, performance optimization, and syntax highlighting.
---
# GROQ Query Maintenance & Best Practices
Use this contents list to jump to the query concern you need to solve.
## Table of Contents
- Query definition and imports
- Query fragments
- Expansion patterns
- Maintenance workflow
- Common patterns
- Performance rules
- API version best practices
## 1. Query Definition & Imports
### The `defineQuery` Function
**ALWAYS** wrap GROQ queries in `defineQuery` for TypeGen support. The import location depends on your framework:
```typescript
// Framework-agnostic (Angular, Remix, SvelteKit, Astro, vanilla)
import { defineQuery } from "groq";
// Next.js (re-exported for convenience)
import { defineQuery } from "next-sanity";
```
### Syntax Highlighting
For VS Code syntax highlighting, either:
1. Use the `groq` tagged template (recommended): `groq\`...\``
2. Or prefix with `/* groq */` comment when using `defineQuery`
```typescript
import { defineQuery } from "groq";
// ✅ Option A: groq tag (provides highlighting automatically)
import groq from "groq";
const QUERY = defineQuery(groq`*[_type == "post"]`);
// ✅ Option B: Comment prefix (for plain template literals)
const QUERY = defineQuery(/* groq */ `*[_type == "post"]`);
// ✅ Also valid: Just defineQuery (TypeGen works, but no editor highlighting)
const QUERY = defineQuery(`*[_type == "post"]`);
```
## 2. Query Fragments
Use string interpolation to reuse query logic and keep queries maintainable.
```typescript
// src/sanity/fragments/image.ts
export const imageFragment = /* groq */ `
asset->{
_id,
url,
metadata { lqip, dimensions }
},
alt
`;
// src/sanity/queries/post.ts
import { defineQuery } from "groq";
import { imageFragment } from "../fragments/image";
export const POST_QUERY = defineQuery(/* groq */ `
*[_type == "post"][0] {
title,
mainImage {
${imageFragment}
}
}
`);
```
## 3. Expansion Patterns (Page Builder)
When building a Page Builder query, expand all potential component types.
**Best Practice:** Use a `pageFields` fragment or similar strategy to keep the main query clean.
```typescript
const pageBuilderExpansion = /* groq */ `
pageBuilder[] {
...,
_type == "hero" => {
...,
cta[] { link, label }
},
_type == "gallery" => {
images[] { ${imageFragment} }
}
}
`;
```
## 4. Maintenance Workflow
When you add a new field or component to the Schema:
1. **Update the Query:** Add the new field/expansion to the relevant GROQ query immediately.
2. **Run TypeGen:** If you have `typegen.enabled: true` in `sanity.cli.ts`, types regenerate automatically during `sanity dev`/`sanity build`. Otherwise, run `npm run typegen` manually.
3. **Verify:** Ensure the new field is available in the generated types.
## 5. Common Patterns
### Ordering
```groq
// Single field
*[_type == "post"] | order(publishedAt desc)
// Multiple fields (tiebreaker)
*[_type == "post"] | order(featured desc, publishedAt desc)
// ⚠️ Order BEFORE slice, not after!
*[_type == "post"] | order(publishedAt desc)[0...10] // ✅ Correct
*[_type == "post"][0...10] | order(publishedAt desc) // ❌ Wrong order
```
### Slice Notation
```groq
*[_type == "post"][0] // Single document (object, not array)
*[_type == "post"][0...5] // First 5 (exclusive) ← Most common
*[_type == "post"][$start...$end] // Pagination with params
```
### Default Values with `coalesce()`
```groq
*[_type == "page"]{
"title": coalesce(seoTitle, title, "Untitled"),
"image": coalesce(ogImage, mainImage, defaultImage)
}
```
### Conditionals with `select()`
```groq
*[_type == "product"]{
title,
"badge": select(
stock == 0 => "Out of Stock",
stock < 5 => "Low Stock",
"In Stock"
)
}
```
### Aggregation with `count()`
```groq
// Total count
count(*[_type == "post" && defined(slug.current)])
// Count per document
*[_type == "category"]{
title,
"postCount": count(*[_type == "post" && references(^._id)])
}
```
### Reverse References
```groq
*[_type == "author"]{
name,
"posts": *[_type == "post" && references(^._id)]{ title, slug }
}
```
### Array Filtering
```groq
*[_type == "movie"]{
title,
"mainCast": castMembers[role == "lead"]->{name}
}
// Check if value exists in array
*[_type == "post" && "tech" in categories[]->slug.current]
```
### Special Variables
```groq
// ^ = parent document (in nested queries)
*[_type == "author"]{
name,
"posts": *[_type == "post" && author._ref == ^._id]
}
// @ = current item (in array operations)
*[_type == "post"]{
"tagCount": count(tags[@ != null])
}
```
## 6. Performance Rules
### Optimizable vs Non-Optimizable Filters
GROQ uses indexes for **optimizable** filters. Non-optimizable filters scan ALL documents.
| Pattern | Optimizable | Example |
|---------|-------------|---------|
| `_type == "x"` | ✅ Yes | `*[_type == "post"]` |
| `_id == "x"` | ✅ Yes | `*[_id == "abc123"]` |
| `slug.current == $slug` | ✅ Yes | `*[slug.current == "hello"]` |
| `defined(field)` | ✅ Yes | `*[defined(publishedAt)]` |
| `references($id)` | ✅ Yes | `*[references("author-123")]` |
| `field->attr == x` | ❌ No | Resolves reference for every doc |
| `fieldA < fieldB` | ❌ No | Compares two attributes |
**Fix non-optimizable filters by stacking:**
```groq
// Stack optimizable filters FIRST to reduce search space
*[_type == "product" && defined(salePrice) && salePrice < displayPrice]
```
### Avoid Joins in Filters
Reference resolution (`->`) in filters is expensive. Use `_ref` instead:
```groq
// ❌ Slow: Resolves reference for every document
*[_type == "post" && author->name == "Bob Woodward"]
// ✅ Fast: Direct _ref comparison
*[_type == "post" && author._ref == "author-bob-woodward-id"]
```
**When you need dynamic lookups** (don't know the ID upfront):
```groq
// Two-step approach:
// 1. Get the reference ID first
*[_type == "author" && name == "Bob Woodward"][0]._id
// 2. Use that ID in your main query
*[_type == "post" && author._ref == $authorId]
// Or use a subquery (still better than -> in filter):
*[_type == "post" && author._ref in *[_type == "author" && name == "Bob Woodward"]._id]
```
### Merge Repeated Reference Resolutions
Each `->` is a subquery. Don't repeat it:
```groq
// ❌ Slow: Two separate subqueries
*[_type == "category"]{
"parentTitle": parent->title,
"parentSlug": parent->slug.current
}
// ✅ Fast: Single subquery, merged
*[_type == "category"]{
...(parent->{ "parentTitle": title, "parentSlug": slug.current })
}
```
### Cursor-Based Pagination (Not Deep Slicing)
Deep slices are slow because all skipped docs must be sorted first.
```groq
// ❌ Slow: Must sort and skip 10,000 docs
*[_type == "article"] | order(_id)[10000...10020]
// ✅ Fast: Cursor-based, only fetches 20
*[_type == "article" && _id > $lastId] | order(_id)[0...20]
```
**For custom sort orders**, include the sort field in the cursor:
```groq
// Compound cursor: publishedAt + _id for deterministic pagination
*[_type == "article" && (
publishedAt < $lastDate ||
(publishedAt == $lastDate && _id > $lastId)
)] | order(publishedAt desc, _id)[0...20]
```
### Always Project Fields
Always use projections to return only the fields your application needs. Fetching entire documents wastes bandwidth and processing time.
```groq
// ❌ Returns ALL fields including unused ones, metadata, revisions
*[_type == "post"]
// ✅ Only fetch what the component needs
*[_type == "post"]{
_id,
title,
"slug": slug.current,
publishedAt,
excerpt
}
```
Apply projections at every level, including nested references:
```groq
*[_type == "post"]{
title,
author->{ name, "avatar": image.asset->url },
categories[]->{ title, "slug": slug.current }
}
```
Use conditional projections for different contexts:
```groq
*[_type == "post"]{
title,
slug,
// Only include body for single post view
$includeBody == true => { body }
}
```
### Don't Filter/Sort on Projected Values
Computed attributes can't use indexes:
```groq
// ❌ Not optimizable (computed attribute)
*[_type == "person"]{
"fullName": firstName + " " + lastName
} | order(fullName)
// ✅ Optimizable (original attribute)
*[_type == "person"] | order(firstName, lastName)
```
### Quick Checklist
| Rule | Why |
|------|-----|
| Always project `{ fields }` | Reduces data returned |
| Use `defined()` checks | Filters use indexes |
| Use `$params` not interpolation | Prevents query manipulation + enables caching |
| Order BEFORE slice | `order()[0...N]` not `[0...N] order()` |
| Use `_ref` not `->field` in filters | Avoids expensive joins |
| Merge repeated `->` calls | Single subquery vs many |
| Cursor pagination for deep pages | Avoids sorting entire dataset |
## 7. API Version Best Practices
Always use dated versions (`YYYY-MM-DD`) for consistent behavior:
```typescript
const client = createClient({
apiVersion: '2026-02-01', // Use current date for new projects
})
```
- **New projects:** Use current date (e.g., `2026-02-01`)
- **Existing projects:** Keep current version unless you need new features
- Dated versions lock behavior; `v1` or `vX` may change unexpectedly
@@ -0,0 +1,261 @@
---
title: "Sanity + Shopify + Hydrogen Rules"
description: Integration guide for Sanity with Shopify using the Hydrogen framework (React Router 7).
---
# Sanity + Shopify + Hydrogen Rules
**Package:** [`hydrogen-sanity`](https://github.com/sanity-io/hydrogen-sanity) — requires `@shopify/hydrogen >= 2025.5.0`
## 1. Architecture Overview
| Component | Purpose |
|-----------|---------|
| **Shopify** | Product catalog, inventory, checkout (source of truth for commerce) |
| **Sanity Connect** | Syncs Shopify data to Sanity in real-time |
| **Sanity Studio** | Editorial content, rich descriptions, media (enhances Shopify data) |
| **Hydrogen** | React Router 7 front-end optimized for Shopify |
**Project Structure:**
```
./
├── /studio # Sanity Studio
└── /web # Hydrogen front-end
```
## 2. Environment Variables
```bash
# web/.env
PUBLIC_STOREFRONT_API_TOKEN="your-public-storefront-token"
PRIVATE_STOREFRONT_API_TOKEN="your-private-storefront-token"
PUBLIC_STORE_DOMAIN="your-store.myshopify.com"
SESSION_SECRET="your-random-session-secret"
# Sanity
SANITY_PROJECT_ID="your-project-id"
SANITY_DATASET="production"
SANITY_API_VERSION="2026-02-01"
SANITY_PREVIEW_TOKEN="your-sanity-viewer-token" # Viewer token for previews
```
## 3. Sanity Client Setup
### Vite Config
```typescript
// web/vite.config.ts
import {hydrogen} from '@shopify/hydrogen/vite'
import {sanity} from 'hydrogen-sanity/vite'
export default defineConfig({
plugins: [hydrogen(), sanity()],
})
```
### Context Setup
```typescript
// web/app/lib/context.ts
import {createSanityContext, type SanityContext} from 'hydrogen-sanity'
import {PreviewSession} from 'hydrogen-sanity/preview/session'
import {isPreviewEnabled} from 'hydrogen-sanity/preview'
const sanity = await createSanityContext({
request,
cache,
waitUntil,
client: {
projectId: env.SANITY_PROJECT_ID,
dataset: env.SANITY_DATASET,
apiVersion: env.SANITY_API_VERSION || '2026-02-01',
useCdn: true,
stega: {
enabled: isPreviewEnabled(env.SANITY_PROJECT_ID, previewSession),
studioUrl: 'http://localhost:3333',
}
},
preview: {
token: env.SANITY_PREVIEW_TOKEN,
session: previewSession,
}
})
```
### Provider Setup (entry.server.tsx)
```typescript
const {SanityProvider} = context.sanity
const body = await renderToReadableStream(
<NonceProvider>
<SanityProvider>
<ServerRouter context={reactRouterContext} url={request.url} nonce={nonce} />
</SanityProvider>
</NonceProvider>,
)
```
### Root Layout (root.tsx)
```typescript
import {Sanity} from 'hydrogen-sanity'
export function Layout({children}) {
const nonce = useNonce()
return (
<html>
<body>
{children}
<Sanity nonce={nonce} /> {/* Required for client-side */}
<Scripts nonce={nonce} />
</body>
</html>
)
}
```
## 4. Data Fetching
Fetch from **both** Shopify (GraphQL) and Sanity (GROQ). Use `defineQuery` for TypeGen support.
### Recommended: `query` + `Query` component
```typescript
import {defineQuery} from 'groq'
import {Query} from 'hydrogen-sanity'
const PRODUCT_QUERY = defineQuery(`*[_type == "product" && store.slug.current == $handle][0]{ body }`)
// Loader
export async function loader({params, context: {sanity}}: LoaderFunctionArgs) {
const initial = await context.sanity.query(PRODUCT_QUERY, params)
return {initial}
}
// Component - auto-enables live preview when active
export default function ProductPage({loaderData}) {
return (
<Query query={PRODUCT_QUERY} options={{initial: loaderData.initial}}>
{(data) => <div>{data?.body}</div>}
</Query>
)
}
```
### Alternative methods
| Method | Use Case |
|--------|----------|
| `sanity.query()` + `Query` | Recommended - auto preview mode |
| `sanity.loadQuery()` | Manual loader integration |
| `sanity.fetch()` | No preview needed, lightweight |
| `sanity.client` | Mutations in actions |
### Images
```typescript
import {useImageUrl} from 'hydrogen-sanity'
function Hero({image}) {
const imageUrl = useImageUrl(image)
return <img src={imageUrl.width(1200).height(600).url()} />
}
```
**Key Insight:** Shopify fields synced via Sanity Connect are `readOnly`. Use Sanity for editorial enhancements only.
## 5. Visual Editing Setup
### Root Layout
```typescript
// web/app/root.tsx
import {usePreviewMode} from 'hydrogen-sanity/preview'
import {VisualEditing} from 'hydrogen-sanity/visual-editing'
export function Layout({children}: {children?: React.ReactNode}) {
const previewMode = usePreviewMode()
return (
<html>
<body>
{children}
{previewMode ? <VisualEditing action="/api/preview" /> : null}
</body>
</html>
)
}
```
### Preview Route
```typescript
// web/app/routes/api.preview.ts
export {action, loader} from 'hydrogen-sanity/preview/route'
```
### Content Security Policy
```typescript
// web/entry.server.tsx
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
frameAncestors: isPreviewEnabled ? [studioHostname] : [],
connectSrc: [
`https://${projectId}.api.sanity.io`,
`wss://${projectId}.api.sanity.io`,
],
})
```
## 6. Studio: Presentation Tool
```typescript
// studio/sanity.config.ts
import {presentationTool} from 'sanity/presentation'
export default defineConfig({
plugins: [
presentationTool({
resolve: {
locations: {
product: defineLocations({
select: { title: 'store.title', slug: 'store.slug.current' },
resolve: (doc) => ({
locations: [
{ title: doc?.title || 'Untitled', href: `/products/${doc?.slug}` },
{ title: 'Products', href: `/collections/all` },
],
}),
}),
},
},
previewUrl: {
origin: 'http://localhost:3000',
previewMode: { enable: '/api/preview' },
},
}),
],
})
```
## 7. Commands
```bash
# Install dependencies
pnpm add hydrogen-sanity @sanity/client @portabletext/react
# Development (run in separate terminals)
cd studio && pnpm dev # Studio at localhost:3333
cd web && pnpm dev # Hydrogen at localhost:3000
# Sanity Manage (CORS, tokens): https://www.sanity.io/manage
pnpm dlx sanity manage
```
## 8. Boundaries
- Always:
- Query Shopify for commerce data (price, inventory, variants)
- Query Sanity for editorial content (rich text, custom fields)
- Use `hydrogen-sanity` package for Visual Editing
- Add Hydrogen URL to CORS origins in [Sanity Manage](https://www.sanity.io/manage)
- Ask First:
- Before modifying Sanity Connect sync settings
- Before changing CSP configuration
- Never:
- Edit Shopify-synced fields in Sanity (they're `readOnly`)
- Expose `SANITY_API_TOKEN` to client-side code
- Query Sanity for commerce data that should come from Shopify
@@ -0,0 +1,124 @@
---
title: "Sanity Image Rules"
description: "Best practices for handling images in Sanity: Schema, URL generation, and Next.js Image integration."
---
# Sanity Image Rules
## 1. Schema Definition
**Always** enable `hotspot: true`. This allows editors to control cropping and the focal point.
```typescript
defineField({
name: 'mainImage',
title: 'Main Image',
type: 'image',
options: {
hotspot: true // CRITICAL
},
fields: [
defineField({
name: 'alt',
type: 'string',
title: 'Alternative Text',
validation: rule => rule.required().warning('Alt text is important for SEO')
})
]
})
```
## 2. URL Builder (`urlFor`)
Use the Sanity Image URL Builder to generate optimized URLs (resize, crop, format).
**Setup (`sanity/lib/image.ts`):**
```typescript
import createImageUrlBuilder from '@sanity/image-url'
import { dataset, projectId } from '../env'
const builder = createImageUrlBuilder({ projectId, dataset })
export const urlFor = (source: any) => {
return builder.image(source)
}
```
**Usage:** The URL builder automatically uses hotspot/crop data when available:
```typescript
const imageUrl = urlFor(mainImage)
.width(800)
.height(600)
.fit('crop') // Respects hotspot when cropping
.url()
```
## 3. Next.js Image Component Pattern
Create a reusable `SanityImage` component that handles the `urlFor` logic and `next/image` props.
```typescript
import Image from 'next/image'
import { urlFor } from '@/sanity/lib/image'
interface SanityImageProps {
value: any // SanityImageSource
width?: number
height?: number
className?: string
priority?: boolean
}
export function SanityImage({ value, width = 800, height, className, priority }: SanityImageProps) {
if (!value?.asset) return null
return (
<Image
className={className}
src={urlFor(value)
.width(width)
.height(height || Math.round(width / 1.5)) // Default aspect ratio if no height
.url()}
alt={value.alt || ''}
width={width}
height={height || Math.round(width / 1.5)}
priority={priority}
// Optional: Use LQIP (Low Quality Image Placeholder)
placeholder={value.asset.metadata?.lqip ? 'blur' : 'empty'}
blurDataURL={value.asset.metadata?.lqip}
/>
)
}
```
## 4. Querying Images
**Critical:** LQIP (Low Quality Image Placeholder) is **not automatic**. You must explicitly query it via `asset->{ metadata { lqip } }`.
### Minimal Query (No LQIP)
```groq
mainImage {
asset->{ _id, url },
alt
}
```
### Full Query (With LQIP & Dimensions)
```groq
mainImage {
asset->{
_id,
url,
metadata {
lqip, // Base64 blur placeholder
dimensions { width, height } // For aspect ratio
}
},
alt,
hotspot, // Include if using hotspot cropping
crop // Include if using cropping
}
```
**Why this matters:** Without querying `metadata.lqip`, the `blurDataURL` in your component will be `undefined` and the blur effect won't work.
## 5. Performance Tips
- **Auto Format:** Sanity CDN automatically serves WebP/AVIF if the browser supports it (no need to specify `.format('webp')` manually in most cases, but `next/image` handles this too).
- **Sizing:** Always request the exact size you need using `.width()` and `.height()` in `urlFor`. Don't download a 4000px image for a thumbnail.
@@ -0,0 +1,432 @@
---
title: Sanity Localization Rules
description: Localization patterns for Sanity using official plugins and best practices.
---
# Sanity Localization Rules
Use the contents list to jump directly to the localization pattern you need.
## Table of Contents
- Guiding principles
- Terminology
- Locale content type
- Choosing document-level vs field-level localization
- Document-level localization
- Localized singletons
- Field-level localization
- AI-powered translation
- UI enhancement
- Frontend URL best practices
## 1. Guiding Principles
### Priority: Easy Authoring Experience
The structured nature of Sanity schemas and GROQ make it easy to parse localized content for your frontend. **Never** let frontend architecture dictate your localization approach — prioritize the editor experience.
### Avoid Content Duplication
Don't create nearly identical copies with slight differences (e.g., US vs British English). Use Portable Text marks and custom blocks to swap out words or sections as needed.
## 2. Terminology
| Term | Definition |
|------|------------|
| **Internationalization (i18n)** | Designing your frontend to support multiple languages |
| **Localization** | Adapting content for a specific language/region |
| **Language Tag** | Code like `en`, `en-US`, `zh-Hant-TW` (per IETF RFC 5646) |
| **Locale** | A language tag with region info (e.g., `en-US`) |
## 3. Create a Locale Content Type
**Best Practice:** Store locales in Sanity, not just in code. This allows sharing between Studio and frontend.
```typescript
// schemaTypes/locale.ts
import { TranslateIcon } from '@sanity/icons'
import { defineField, defineType } from 'sanity'
export const localeType = defineType({
name: 'locale',
icon: TranslateIcon,
type: 'document',
fields: [
defineField({ name: 'name', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'tag', type: 'string', description: 'IANA tag (en, en-US)', validation: (r) => r.required() }),
defineField({ name: 'fallback', type: 'reference', to: [{ type: 'locale' }] }),
defineField({ name: 'default', type: 'boolean' }),
],
preview: { select: { title: 'name', subtitle: 'tag' } },
})
```
**Tip:** Restrict locale editing to admins via Structure by filtering `locale` from non-admin users.
## 4. Choose Your Localization Method
| Content Type | Examples | Recommended Method |
|--------------|----------|-------------------|
| **Structured** (things) | Products, People, Locations, Categories | Field-level |
| **Presentation** (UI) | Pages, Posts, Components | Document-level |
### Decision Questions
1. **Are fields shared across languages?** → Field-level
2. **Should changes be "global" for all locales?** (e.g., reordering components) → Field-level
3. **Is content mostly the same except regional differences?** → Field-level with PT marks
4. **Need to publish language versions independently?** → Document-level
## 5. Document-Level Localization
Use the **@sanity/document-internationalization** plugin.
```bash
npm install @sanity/document-internationalization
```
### Configuration
```typescript
// sanity.config.ts
import { documentInternationalization } from '@sanity/document-internationalization'
export default defineConfig({
plugins: [
documentInternationalization({
// Fetch from Content Lake
supportedLanguages: (client) =>
client.fetch(`*[_type == "locale"]{ "id": tag, "title": name }`),
// Document types to localize
schemaTypes: ['post', 'page'],
}),
],
})
```
### Add Language Field to Schema
```typescript
// In each schema type listed in schemaTypes
defineField({
name: 'language',
type: 'string',
readOnly: true,
hidden: true,
})
```
### Initial Value Templates
Pre-set language when creating documents outside the translation UI:
```typescript
// sanity.config.ts
import { template } from 'sanity'
export default defineConfig({
// ...
document: {
newDocumentOptions: (prev, { creationContext }) => {
// Filter to only show base language in "New document" menu
// The plugin handles creating translations from there
return prev.filter((item) =>
!['post', 'page'].includes(item.templateId) ||
item.parameters?.language === 'en'
)
},
},
// Initial value templates for each language
templates: (prev) => [
...prev,
template.initial({
id: 'post-en',
title: 'Post (English)',
schemaType: 'post',
parameters: [{name: 'language', type: 'string'}],
value: ({language}) => ({language}),
}),
],
})
```
### Querying Translated Documents
```groq
// Get document in specific language
*[_type == "post" && language == $locale && slug.current == $slug][0]
// Get all translations via metadata document
*[_type == "translation.metadata" && references($docId)][0] {
translations[] {
_key,
value-> { title, slug, language }
}
}
```
## 6. Localized Singletons (Homepage per Locale)
For singletons like homepages that need a separate document per locale, combine document-level localization with the singleton pattern.
### Schema Definition
```typescript
// schemaTypes/homePage.ts
import { HomeIcon } from '@sanity/icons'
import { defineType, defineField } from 'sanity'
export const homePageType = defineType({
name: 'homePage',
title: 'Home Page',
type: 'document',
icon: HomeIcon,
fields: [
defineField({
name: 'language',
type: 'string',
readOnly: true,
hidden: true,
}),
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'pageBuilder', type: 'pageBuilder' }),
// ... other fields
],
preview: {
select: { language: 'language' },
prepare({ language }) {
return {
title: 'Home Page',
subtitle: language?.toUpperCase() || 'No language',
}
},
},
})
```
### Initial Value Templates
Create templates that pre-set the language for each locale:
```typescript
// sanity.config.ts
import { defineConfig, Template } from 'sanity'
// Define your supported locales
const LOCALES = [
{ id: 'en', title: 'English' },
{ id: 'fr', title: 'French' },
{ id: 'de', title: 'German' },
]
export default defineConfig({
// ...
templates: (prev) => {
// Create a template for each locale
const homePageTemplates: Template[] = LOCALES.map((locale) => ({
id: `homePage-${locale.id}`,
title: `Home Page (${locale.title})`,
schemaType: 'homePage',
parameters: [{ name: 'language', type: 'string' }],
value: { language: locale.id },
}))
return [...prev, ...homePageTemplates]
},
})
```
### Structure: Localized Singleton Helper
Create a helper to show one singleton per locale in the Structure:
```typescript
// src/structure/index.ts
import { StructureBuilder, StructureResolver } from 'sanity/structure'
import { HomeIcon } from '@sanity/icons'
const LOCALES = ['en', 'fr', 'de']
function createLocalizedSingleton(
S: StructureBuilder,
typeName: string,
title: string,
icon?: React.ComponentType
) {
return S.listItem()
.title(title)
.icon(icon)
.child(
S.list()
.title(title)
.items(
LOCALES.map((locale) =>
S.listItem()
.title(`${title} (${locale.toUpperCase()})`)
.icon(icon)
.child(
S.document()
.schemaType(typeName)
.documentId(`${typeName}-${locale}`) // Fixed ID per locale
.title(`${title} (${locale.toUpperCase()})`)
)
)
)
)
}
export const structure: StructureResolver = (S) =>
S.list()
.title('Content')
.items([
// Localized singletons
createLocalizedSingleton(S, 'homePage', 'Home Page', HomeIcon),
S.divider(),
// Filter localized singletons from default list
...S.documentTypeListItems().filter(
(item) => !['homePage'].includes(item.getId() as string)
),
])
```
### Querying Localized Singletons
```groq
// Get homepage for specific locale
*[_type == "homePage" && language == $locale][0]{
title,
pageBuilder[]{...}
}
// Or by fixed document ID
*[_id == "homePage-" + $locale][0]{...}
```
### Key Points
- **Fixed IDs:** Use `${typeName}-${locale}` pattern for predictable document IDs
- **Initial Value Templates:** Essential for the "New document" menu to work correctly
- **Structure:** Group all locale versions under one list item for cleaner navigation
- **See also:** `studio-structure.md` for more singleton patterns
## 7. Field-Level Localization
Use **sanity-plugin-internationalized-array** (NOT localized objects — they hit attribute limits).
```bash
npm install sanity-plugin-internationalized-array
```
### Configuration
```typescript
// sanity.config.ts
import { internationalizedArray } from 'sanity-plugin-internationalized-array'
export default defineConfig({
plugins: [
internationalizedArray({
languages: (client) =>
client.fetch(`*[_type == "locale"]{ "id": tag, "title": name }`),
fieldTypes: ['string', 'text', 'simpleBlockContent'],
}),
],
})
```
### Usage in Schema
```typescript
// The plugin creates types like `internationalizedArrayString`
defineField({
name: 'jobTitle',
type: 'internationalizedArrayString', // Localized string field
})
```
### Portable Text Localization
Create a reusable block content type, then add it to `fieldTypes`:
```typescript
// schemaTypes/simpleBlockContent.ts
export default defineType({
name: 'simpleBlockContent',
type: 'array',
of: [
{
type: 'block',
styles: [{ title: 'Normal', value: 'normal' }],
lists: [],
},
],
})
// sanity.config.ts
fieldTypes: ['string', 'simpleBlockContent']
// In your schema
defineField({
name: 'bio',
type: 'internationalizedArraySimpleBlockContent',
})
```
### Querying Internationalized Arrays
```groq
// Get specific locale value
*[_type == "author"][0] {
"jobTitle": jobTitle[_key == $locale][0].value
}
// With fallback
*[_type == "author"][0] {
"jobTitle": coalesce(
jobTitle[_key == $locale][0].value,
jobTitle[_key == "en"][0].value
)
}
```
## 8. AI-Powered Translation
Use **@sanity/assist** for automated translations.
```bash
npm install @sanity/assist
```
```typescript
// sanity.config.ts
import { assist } from '@sanity/assist'
export default defineConfig({
plugins: [
assist({
translate: {
// For document-level localization
document: {
languageField: 'language',
},
// For field-level localization
field: {
languages: (client) =>
client.fetch(`*[_type == "locale"]{ "id": tag, "title": name }`),
documentTypes: ['author', 'category'],
},
},
}),
],
})
```
## 9. UI Enhancement
Use **@sanity/language-filter** to let editors show/hide locales:
```bash
npm install @sanity/language-filter
```
## 10. Frontend URL Best Practices
**Always include locale in the URL** for SEO:
- `yoursite.com/en/my-page``yoursite.com/fr/my-page`
- `yoursite.com/my-page` → redirects to default locale
**Avoid:** Having the default locale at root without prefix — causes SEO edge cases.
Use Next.js middleware (or framework equivalent) to redirect paths missing a locale prefix to the default locale.
@@ -0,0 +1,138 @@
---
title: Import HTML to Portable Text
description: Use @portabletext/block-tools with JSDOM to convert HTML content
---
## Import HTML to Portable Text
Use `@portabletext/block-tools` with `JSDOM` to convert HTML from legacy CMSs to Portable Text.
### Setup
```bash
npm install @portabletext/block-tools jsdom
```
### Basic Conversion
```typescript
import { htmlToBlocks } from '@portabletext/block-tools'
import { JSDOM } from 'jsdom'
// Get block content type from your schema
const blockContentType = schema.get('blockContent')
const blocks = htmlToBlocks(htmlString, blockContentType, {
parseHtml: html => new JSDOM(html).window.document,
})
```
### Custom Deserializers
Handle specific HTML patterns:
```javascript
const blocks = htmlToBlocks(htmlString, blockContentType, {
parseHtml: html => new JSDOM(html).window.document,
rules: [
{
deserialize(el, next, block) {
// Custom link handling
if (el.tagName.toLowerCase() === 'a') {
return {
_type: 'link',
href: el.getAttribute('href'),
blank: el.getAttribute('target') === '_blank'
}
}
// Custom image handling
if (el.tagName.toLowerCase() === 'img') {
return {
_type: 'image',
// Upload image separately, store reference
_sanityAsset: `image@${el.getAttribute('src')}`
}
}
return undefined // Fall through to default handling
}
}
]
})
```
### Pre-Processing HTML
Clean HTML before conversion:
```javascript
function cleanHtml(html) {
const dom = new JSDOM(html)
const doc = dom.window.document
// Remove layout elements
doc.querySelectorAll('header, footer, nav, .sidebar').forEach(el => el.remove())
// Extract metadata before processing body
const title = doc.querySelector('title')?.textContent
const description = doc.querySelector('meta[name="description"]')?.content
return {
body: doc.body.innerHTML,
metadata: { title, description }
}
}
```
### Image Upload
Don't just link external images—upload them:
```javascript
async function uploadImage(client, imageUrl) {
const response = await fetch(imageUrl)
const buffer = await response.arrayBuffer()
const asset = await client.assets.upload('image', Buffer.from(buffer), {
filename: imageUrl.split('/').pop()
})
return {
_type: 'image',
asset: { _type: 'reference', _ref: asset._id }
}
}
```
### Using in a Migration
Wrap this in `defineMigration` for reproducible imports:
```typescript
// migrations/import-wordpress-posts/index.ts
import {defineMigration, createOrReplace} from 'sanity/migrate'
import {htmlToBlocks} from '@portabletext/block-tools'
export default defineMigration({
title: 'Import WordPress posts',
async *migrate(documents, context) {
const posts = await fetchWordPressPosts() // Your import source
for (const post of posts) {
const blocks = htmlToBlocks(post.content, blockContentType, {
parseHtml: html => new JSDOM(html).window.document,
})
yield createOrReplace({
_id: `post-${post.slug}`,
_type: 'post',
title: post.title,
body: blocks,
})
}
}
})
```
Run with: `sanity migration run import-wordpress-posts --no-dry-run`
Reference: [Schema and Content Migrations](https://www.sanity.io/docs/content-lake/schema-and-content-migrations)
@@ -0,0 +1,44 @@
---
title: Sanity Content Migration Rules
description: Best practices for migrating content (HTML, Markdown) into Sanity Portable Text.
---
# Sanity Content Migration Rules
## 1. HTML Import (Legacy CMS)
Use `@portabletext/block-tools` with `JSDOM` to convert HTML to Portable Text. This covers setup, custom deserializers, pre-processing, image uploads, and wrapping in `defineMigration`.
**See `migration-html-import.md` for the full guide with working examples.**
## 2. Markdown Import (Static Sites)
Use `@portabletext/markdown` for direct, schema-aware Markdown ↔ Portable Text conversion.
**Recommended: Direct Conversion with `@portabletext/markdown`**
```typescript
import {markdownToPortableText} from '@portabletext/markdown'
const blocks = markdownToPortableText(markdownString)
```
This handles headings, lists, bold, italic, code, links, images, and tables. Use `@portabletext/sanity-bridge` to pass your Sanity schema so only valid types are produced.
**Alternative: Markdown → HTML → Portable Text**
For complex Markdown with non-standard extensions, convert to HTML first, then use `htmlToBlocks` (see above).
1. **Parse:** `marked` or `remark` to convert MD to HTML.
2. **Convert:** Use `htmlToBlocks` from `@portabletext/block-tools`.
> **Note:** `@sanity/block-content-to-markdown` and `@sanity/block-tools` are deprecated. Use `@portabletext/markdown` and `@portabletext/block-tools` instead.
## 3. Image Handling (Universal)
Don't just link to external images. Download them and upload to Sanity Asset Pipeline.
1. **Extract:** Find `<img>` tags or Markdown image syntax.
2. **Download:** Fetch the image buffer.
3. **Upload:** `client.assets.upload('image', buffer)`
4. **Replace:** Return a Sanity Image block with the new asset reference.
## 4. Schema Validation
Ensure your destination schema allows the structures you are importing.
- **Tables:** Need a `table` type (HTML `<table>` or GFM tables).
- **Code:** Need a `code` type (HTML `<pre><code>` or MD code fences).
@@ -0,0 +1,544 @@
---
title: Next.js & Sanity Integration Rules
description: Integration guide for Next.js App Router, Live Content API, and Sanity Studio (Embedded or Standalone).
---
# Next.js & Sanity Integration Rules
Jump to the section that matches the task instead of reading this guide end-to-end.
## Table of Contents
- Architecture patterns
- Data fetching (Live Content API)
- Caching and revalidation
- Visual Editing and clean data
- Embedded Studio setup
- Draft Mode setup
- Error handling
- Presentation queries
- Pagination pattern
## 1. Architecture Patterns
### Option A: Embedded Studio (Recommended)
**Best for:** Most Next.js projects. Unified deployment, simpler setup.
The Studio lives inside your Next.js app at `/app/studio/[[...tool]]/page.tsx`.
- **Config:** `sanity.config.ts` lives in the project root.
- See `project-structure.md` rule for detailed structure.
### Option B: Monorepo (Alternative)
**Best for:** Separation of concerns, multiple frontends, or strict dependency isolation.
The Studio and Next.js app live in separate folders:
```
apps/
├── studio/ # Sanity Studio (standalone)
└── web/ # Next.js frontend
```
- **Config:** Add your Next.js app URL to **CORS Origins** in [Sanity Manage](https://www.sanity.io/manage).
- See `project-structure.md` rule for detailed structure.
## 2. Data Fetching (Live Content API)
We use `defineLive` (next-sanity v11+) to enable real-time content updates and Visual Editing automatically.
### Setup (`src/sanity/lib/live.ts`)
```typescript
import { defineLive } from 'next-sanity'
import { client } from './client'
export const { sanityFetch, SanityLive } = defineLive({
client: client.withConfig({
apiVersion: '2026-02-01'
}),
serverToken: process.env.SANITY_API_READ_TOKEN,
browserToken: process.env.SANITY_API_READ_TOKEN,
})
```
### Rendering (`src/app/layout.tsx`)
You **must** render `<SanityLive />` in the root layout to enable real-time updates.
```typescript
import { SanityLive } from '@/sanity/lib/live'
import { VisualEditing } from 'next-sanity/visual-editing'
import { draftMode } from 'next/headers'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<SanityLive />
{(await draftMode()).isEnabled && <VisualEditing />}
</body>
</html>
)
}
```
## 3. Caching & Revalidation
### Prefer Live Content API (Default)
**Use `defineLive` by default.** It handles fetching, caching, and invalidation automatically. Only implement manual caching when you need fine-grained control.
### When to Use Manual Caching
| Scenario | Approach |
|----------|----------|
| Real-time updates, Visual Editing | `defineLive` (default) |
| Static marketing pages, rarely updated | Time-based revalidation |
| Blog posts, products with frequent edits | Tag-based revalidation |
| Critical accuracy (stock levels, prices) | Path-based + short revalidation |
### Debugging: Enable Fetch Logging
See every fetch with cache HIT/MISS status:
```typescript
// next.config.ts
const nextConfig: NextConfig = {
logging: {
fetches: {
fullUrl: true,
},
},
};
```
Console output shows cache status:
```text
GET /posts 200 in 39ms
│ GET https://...apicdn.sanity.io/... 200 in 5ms (cache hit)
```
### Sanity CDN vs API
| Setting | Speed | Freshness | Use When |
|---------|-------|-----------|----------|
| `useCdn: true` | Fast | May have brief delay | Default for all runtime fetches |
| `useCdn: false` | Slower | Guaranteed fresh | `generateStaticParams`, webhooks |
Override per-request:
```typescript
// For static generation, use API directly
export async function generateStaticParams() {
const slugs = await client
.withConfig({ useCdn: false })
.fetch(SLUGS_QUERY);
return slugs;
}
```
### Manual `sanityFetch` Helper (Advanced)
For manual caching control, create a wrapper:
```typescript
// src/sanity/lib/client.ts
export async function sanityFetch<const QueryString extends string>({
query,
params = {},
revalidate = 60,
tags = [],
}: {
query: QueryString;
params?: QueryParams;
revalidate?: number | false;
tags?: string[];
}) {
return client.fetch(query, params, {
next: {
revalidate: tags.length ? false : revalidate,
tags,
},
});
}
```
### Time-Based Revalidation
Simple and predictable. Good for content that changes infrequently.
```typescript
const posts = await sanityFetch({
query: POSTS_QUERY,
revalidate: 3600, // Revalidate every hour
});
```
**The "Typo Problem":** With time-based only, content authors may wait up to an hour to see changes. Use webhooks for instant updates.
### Path-Based Revalidation
Surgically revalidate specific routes when documents change.
**1. Create API Route:**
```typescript
// src/app/api/revalidate/path/route.ts
import { revalidatePath } from 'next/cache';
import { type NextRequest, NextResponse } from 'next/server';
import { parseBody } from 'next-sanity/webhook';
type WebhookPayload = { path?: string };
export async function POST(req: NextRequest) {
try {
const { isValidSignature, body } = await parseBody<WebhookPayload>(
req,
process.env.SANITY_REVALIDATE_SECRET,
true // Add delay to allow CDN to update
);
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 });
}
if (!body?.path) {
return new Response('Missing path', { status: 400 });
}
revalidatePath(body.path);
return NextResponse.json({ revalidated: body.path });
} catch (err) {
return new Response((err as Error).message, { status: 500 });
}
}
```
**2. Create GROQ-Powered Webhook:**
- URL: `https://yoursite.com/api/revalidate/path`
- Filter: `_type in ["post"]`
- Projection: `{ "path": "/posts/" + slug.current }`
- Add `SANITY_REVALIDATE_SECRET` to webhook and `.env.local`
### Tag-Based Revalidation
"Update once, revalidate everywhere" — best for referenced content.
**1. Tag Your Queries:**
```typescript
// Posts index - revalidate when ANY post, author, or category changes
const posts = await sanityFetch({
query: POSTS_QUERY,
tags: ['post', 'author', 'category'],
});
// Individual post - more granular, includes slug-specific tag
const post = await sanityFetch({
query: POST_QUERY,
params,
tags: [`post:${params.slug}`, 'author', 'category'],
});
```
**2. Create API Route:**
```typescript
// src/app/api/revalidate/tag/route.ts
import { revalidateTag } from 'next/cache';
import { type NextRequest, NextResponse } from 'next/server';
import { parseBody } from 'next-sanity/webhook';
type WebhookPayload = { tags: string[] };
export async function POST(req: NextRequest) {
try {
const { isValidSignature, body } = await parseBody<WebhookPayload>(
req,
process.env.SANITY_REVALIDATE_SECRET,
true
);
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 });
}
if (!Array.isArray(body?.tags) || !body.tags.length) {
return new Response('Missing tags', { status: 400 });
}
body.tags.forEach((tag) => revalidateTag(tag));
return NextResponse.json({ revalidated: body.tags });
} catch (err) {
return new Response((err as Error).message, { status: 500 });
}
}
```
**3. Create GROQ-Powered Webhook:**
- URL: `https://yoursite.com/api/revalidate/tag`
- Filter: `_type in ["post", "author", "category"]`
- Projection: `{ "tags": [_type, _type + ":" + slug.current] }`
### Stale Data After Webhook?
Webhooks fire *before* Sanity CDN updates. If you see stale data:
1. **Add delay** — Pass `true` as third arg to `parseBody`
2. **Or bypass CDN** — Set `useCdn: false` in client config (use sparingly)
## 4. Visual Editing (Stega) & Clean Data
Visual Editing injects invisible characters into strings to enable click-to-edit.
### A. The Golden Rule of Stega
If a string field controls logic (alignment, colors, IDs), you **must** clean it before comparing.
```typescript
import { stegaClean } from "@sanity/client/stega";
export function Layout({ align }: { align: string }) {
// ❌ Bad: Will fail in Edit Mode due to invisible chars
// if (align === 'center') ...
// ✅ Good: Clean the value first
const cleanAlign = stegaClean(align);
return <div className={cleanAlign === 'center' ? 'mx-auto' : ''} />
}
```
### B. Metadata & SEO (Critical)
**Never** let Stega characters leak into `<head>` tags. Always set `stega: false` for metadata fetching.
```typescript
export async function generateMetadata({ params }) {
const { data } = await sanityFetch({
query: SEO_QUERY,
params: await params,
stega: false // 👈 Critical for SEO
})
return { title: data?.title }
}
```
### C. Static Params
When generating static params, fetch only published content and disable stega.
```typescript
export async function generateStaticParams() {
const { data } = await sanityFetch({
query: SLUGS_QUERY,
perspective: 'published', // 👈 No drafts
stega: false
})
return data
}
```
## 5. Setup: Embedded Studio
Mount the Studio on a Next.js route.
**`src/app/studio/[[...tool]]/page.tsx`:**
```typescript
import { NextStudio } from 'next-sanity/studio'
import config from '../../../../sanity.config'
export const dynamic = 'force-static'
export { metadata, viewport } from 'next-sanity/studio'
export default function StudioPage() {
return <NextStudio config={config} />
}
```
## 6. Setup: Draft Mode
Enable Presentation Tool and Visual Editing by setting up a draft mode route.
**`src/app/api/draft-mode/enable/route.ts`:**
```typescript
import { client } from '@/sanity/lib/client'
import { defineEnableDraftMode } from 'next-sanity/draft-mode'
import { token } from '@/sanity/lib/token' // Helper to get token
export const { GET } = defineEnableDraftMode({
client: client.withConfig({ token }),
})
```
## 7. Error Handling
Use `notFound()` for missing documents. Common errors:
| Error | Cause | Solution |
|-------|-------|----------|
| 401 Unauthorized | Invalid/missing token | Check `SANITY_API_READ_TOKEN` |
| 403 Forbidden | CORS not configured | Add URL to CORS origins |
| Query syntax error | Invalid GROQ | Test in Vision plugin first |
| Empty result | Wrong filter/params | Log params, check `_type` spelling |
```typescript
import { notFound } from 'next/navigation'
export default async function PostPage({ params }: Props) {
const { data } = await sanityFetch({ query: POST_QUERY, params: await params })
if (!data) notFound()
return <Post data={data} />
}
```
## 8. Presentation Queries (`usePresentationQuery`)
For faster live editing in the Presentation Tool, use `usePresentationQuery` to fetch only the specific block being edited, rather than re-rendering the entire page.
### Why Use This
- **Without:** Editing a hero title re-fetches the whole page, re-renders all blocks
- **With:** Only the hero block re-fetches and re-renders
This is especially valuable for pages with many Page Builder blocks or complex Portable Text.
### Basic Pattern
```typescript
'use client'
import { usePresentationQuery } from 'next-sanity/hooks'
import { HERO_PRESENTATION_QUERY } from '@/sanity/lib/queries'
type HeroProps = {
_key: string
documentId: string
title: string
subtitle?: string
// ... other initial props from page query
}
export function Hero({ _key, documentId, title, subtitle, ...rest }: HeroProps) {
// Fetch block-specific data for faster updates in Presentation Tool
const { data } = usePresentationQuery({
query: HERO_PRESENTATION_QUERY,
params: { documentId, blockKey: _key },
})
// Use presentation data if available, fallback to initial server props
const blockData = data?.heroBlock || { title, subtitle, ...rest }
return (
<section>
<h1>{blockData.title}</h1>
{blockData.subtitle && <p>{blockData.subtitle}</p>}
</section>
)
}
```
### The Presentation Query
Create a query that targets the specific block by `_key`:
```typescript
// queries.ts
export const HERO_PRESENTATION_QUERY = defineQuery(`
*[_id == $documentId][0]{
_id,
_type,
"heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
title,
subtitle,
image,
theme,
// Include all fields the component needs
}
}
`)
```
### Passing Document Context
Your PageBuilder component needs to pass `documentId` to each block:
```typescript
export function PageBuilder({ content, documentId }: { content: Block[]; documentId: string }) {
return (
<main>
{content.map((block) => {
switch (block._type) {
case "hero":
return <Hero key={block._key} documentId={documentId} {...block} />
// ... other blocks
}
})}
</main>
)
}
```
### For Portable Text Blocks
The same pattern works for custom blocks inside Portable Text:
```typescript
export const PTE_IMAGE_PRESENTATION_QUERY = defineQuery(`
*[_id == $documentId][0]{
"pteImageBlock": body[_key == $blockKey && _type == "pteImage"][0]{
image,
caption,
alt
}
}
`)
```
**See also:** `visual-editing.md` for the conceptual overview and `page-builder.md` for full Page Builder patterns.
## 9. Pagination Pattern
For listing pages with many entries, use offset-based pagination with a count query.
### Queries
```typescript
// Paginated listing
export const ARTICLES_QUERY = defineQuery(`
*[_type == "article" && defined(slug.current)]
| order(date desc) [$start...$end] {
_id, title, "slug": slug.current, date
}
`);
// Total count for pagination UI
export const ARTICLES_COUNT_QUERY = defineQuery(`
count(*[_type == "article" && defined(slug.current)])
`);
```
### Listing Page
```typescript
const ENTRIES_PER_PAGE = 10;
export default async function BlogPage({
searchParams
}: {
searchParams: Promise<{ page?: string }>
}) {
const { page: pageParam } = await searchParams;
const page = parseInt(pageParam || "1");
const start = (page - 1) * ENTRIES_PER_PAGE;
const end = start + ENTRIES_PER_PAGE;
const [{ data: articles }, { data: total }] = await Promise.all([
sanityFetch({ query: ARTICLES_QUERY, params: { start, end } }),
sanityFetch({ query: ARTICLES_COUNT_QUERY })
]);
const totalPages = Math.ceil(total / ENTRIES_PER_PAGE);
return (
<main>
{articles.map(article => (
<ArticleCard key={article._id} article={article} />
))}
<Pagination current={page} total={totalPages} />
</main>
);
}
```
@@ -0,0 +1,84 @@
---
title: Nuxt & Sanity Integration Rules
description: Integration guide for Nuxt, including @nuxtjs/sanity, visual editing, and data fetching.
---
# Nuxt & Sanity Integration Rules
## 1. Setup & Configuration
### Configuration (`nuxt.config.ts`)
Use the official `@nuxtjs/sanity` module.
**Important:** Ensure the `minimal` client is NOT enabled if you want full features.
```typescript
export default defineNuxtConfig({
modules: ["@nuxtjs/sanity"],
sanity: {
projectId: process.env.NUXT_SANITY_PROJECT_ID,
dataset: process.env.NUXT_SANITY_DATASET,
apiVersion: "2026-02-01",
// Live Visual Editing Configuration
visualEditing: {
studioUrl: process.env.NUXT_SANITY_STUDIO_URL,
token: process.env.NUXT_SANITY_API_READ_TOKEN, // Required for fetching drafts
stega: true, // Enable stega for visual editing
mode: 'live-visual-editing', // Default: enables live updates
},
},
});
```
## 2. Data Fetching
### `useSanityQuery`
Use the composable provided by the module for reactive fetching. It automatically handles preview state when configured.
```vue
<script setup lang="ts">
const query = groq`*[_type == "post"]{title, slug}`;
const { data: posts } = await useSanityQuery(query);
</script>
<template>
<ul>
<li v-for="post in posts" :key="post._id">{{ post.title }}</li>
</ul>
</template>
```
## 3. Visual Editing (Live Preview)
### Automatic Setup
When `visualEditing` is configured in `nuxt.config.ts`, the module handles:
1. Injecting the Visual Editing overlays.
2. Refreshing data when content changes in the Studio.
3. Enabling Stega encoding.
### Handling Stega in Logic
Just like Next.js, if you use stega-encoded strings in logic (e.g. `v-if="post.layout === 'full'"`), you must clean them.
```typescript
import { stegaClean } from "@sanity/client/stega";
const layout = computed(() => stegaClean(props.layout));
```
## 4. Components
### Portable Text
Use the `<PortableText>` component (if installed via `@portabletext/vue` or provided by the module).
```vue
<PortableText :value="post.body" :components="customComponents" />
```
### Images
Use `@sanity/image-url` helper or a dedicated image component.
```typescript
import imageUrlBuilder from '@sanity/image-url'
const builder = imageUrlBuilder(useSanity().client)
// ... url generation logic
```
@@ -0,0 +1,307 @@
---
title: "Sanity Page Builder Patterns"
description: Patterns for Sanity Page Builder arrays, block components, and live editing.
---
# Sanity Page Builder Patterns
This guide covers **Page Builder** patterns—arrays of block objects that allow content teams to compose flexible page layouts. For Portable Text (rich text within documents), see `portable-text.md`.
## 1. What is a Page Builder?
A page builder is an **array of objects** (`pageBuilder[]`) that allows content teams to compose pages from reusable blocks without developer intervention.
**When to use:**
- Flexible layouts needed (marketing pages, landing pages)
- Content can be reordered
- Different components on different pages
**When NOT to use:**
- Rigid, formulaic content (blog posts, product pages)
- Highly structured data that doesn't change layout
- Rich text within a document body—use Portable Text instead
## 2. Schema Organization
### Directory Structure
```
schemaTypes/
├── blocks/ # Page builder blocks (objects)
│ ├── heroType.ts
│ ├── featuresType.ts
│ └── faqsType.ts
├── pageBuilderType.ts # The array definition
└── pageType.ts # Document using the page builder
```
### Objects vs References
| Use **Objects** | Use **References** |
|-----------------|-------------------|
| Content is unique to this page | Content reused across many pages |
| Simpler queries | Needs central management |
| Default choice | FAQs, CTAs, testimonials |
**Rule:** Use references sparingly. Most blocks should be objects.
### Page Builder Array
```typescript
// pageBuilderType.ts
import { defineType, defineArrayMember } from "sanity";
export const pageBuilderType = defineType({
name: "pageBuilder",
type: "array",
of: [
defineArrayMember({ type: "hero" }),
defineArrayMember({ type: "splitImage" }),
defineArrayMember({ type: "features" }),
defineArrayMember({ type: "faqs" }),
],
options: {
insertMenu: {
views: [
// Optional: Show visual thumbnails in the insert menu grid
{ name: "grid", previewImageUrl: (type) => `/block-previews/${type}.png` },
],
},
},
});
```
### Block Preview Pattern
Every block should have consistent previews:
```typescript
import { defineType } from "sanity";
import { BlockContentIcon } from "@sanity/icons";
export const splitImageType = defineType({
name: "splitImage",
type: "object",
icon: BlockContentIcon,
fields: [/* ... */],
preview: {
select: { title: "title", media: "image" },
prepare({ title, media }) {
return {
title: title || "Untitled",
subtitle: "Split Image", // Block type name
media: media ?? BlockContentIcon, // Fallback to icon
};
},
},
});
```
## 3. Querying Page Builders
Expand references only for blocks that need them:
```groq
*[_type == "page" && slug.current == $slug][0]{
...,
content[]{
...,
_type == "faqs" => {
...,
faqs[]-> // Expand only FAQ references
}
}
}
```
## 4. Rendering Page Builders
### TypeScript Typing
Use `Extract` to type individual blocks from the query result:
```typescript
import { PAGE_QUERYResult } from "@/sanity/types";
type HeroProps = Extract<
NonNullable<NonNullable<PAGE_QUERYResult>["content"]>[number],
{ _type: "hero" }
>;
export function Hero({ title, image }: HeroProps) {
// Fully typed!
}
```
### Switch-Based Rendering
```typescript
export function PageBuilder({ content }: { content: Block[] }) {
if (!Array.isArray(content)) return null;
return (
<main>
{content.map((block) => {
switch (block._type) {
case "hero":
return <Hero key={block._key} {...block} />;
case "features":
return <Features key={block._key} {...block} />;
case "splitImage":
return <SplitImage key={block._key} {...block} />;
default:
return <div key={block._key}>Unknown: {block._type}</div>;
}
})}
</main>
);
}
```
**Always use `_key` for React keys:**
```typescript
// Breaks Visual Editing and causes hydration issues
{items.map((item, i) => <Component key={i} {...item} />)}
// Always use Sanity's _key
{items.map((item) => <Component key={item._key} {...item} />)}
```
### Cleaning Values for Logic
Use `stegaClean` when block fields control rendering logic:
```typescript
import { stegaClean } from "next-sanity";
function SplitImage({ orientation, title, image }) {
return (
<section data-orientation={stegaClean(orientation) || "imageLeft"}>
{/* ... */}
</section>
);
}
```
## 5. Presentation Queries for Live Editing (Next.js)
For faster live updates in the Presentation Tool, use **presentation queries** that fetch only the specific block being edited, rather than re-fetching the entire page.
> **Note:** This pattern uses `usePresentationQuery` from `next-sanity/hooks`. For other frameworks, check your loader package for equivalent functionality.
### The Pattern
1. **Create a block-specific presentation query:**
```typescript
// queries.ts
export const HERO_PRESENTATION_QUERY = defineQuery(`
*[_id == $documentId][0]{
_id,
_type,
"heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
title,
subtitle,
image,
// ... all fields the component needs
}
}
`)
```
2. **Use `usePresentationQuery` in your component:**
```typescript
'use client'
import { usePresentationQuery } from 'next-sanity/hooks'
import { HERO_PRESENTATION_QUERY } from '@/sanity/lib/queries'
type HeroProps = {
_key: string
documentId: string
// ... initial props from page query
}
export function Hero({ _key, documentId, ...initialProps }: HeroProps) {
// Fetch block-specific data for faster updates
const { data } = usePresentationQuery({
query: HERO_PRESENTATION_QUERY,
params: { documentId, blockKey: _key },
})
// Use presentation data if available, fallback to initial props
const blockData = data?.heroBlock || initialProps
return (
<section>
<h1>{blockData.title}</h1>
{/* ... */}
</section>
)
}
```
### Why This Is Faster
- **Without:** Editing a field triggers a full page re-render with all blocks
- **With:** Only the specific block re-renders with its targeted query
This pattern is especially valuable for pages with many blocks or complex nested data.
**Note:** See `nextjs.md` for more details on `usePresentationQuery` and `visual-editing.md` for the conceptual overview.
## 6. Page Builder Pitfalls
| Pitfall | Solution |
|---------|----------|
| Too many block variations | Split into separate blocks if >2 variants |
| Paradox of choice | Limit blocks per document type |
| Overusing references | Default to objects; references only for truly shared content |
| Unused blocks accumulate | Prune regularly; see deprecation patterns |
| Inconsistent previews | Always set title, subtitle (block name), and media/icon |
## 7. Component Alignment Pattern
Map Sanity "alignment" fields (usually string/select) to CSS classes using utility functions.
**Schema:**
```typescript
defineField({
name: 'align',
type: 'string',
options: { list: ['left', 'center', 'right'], layout: 'radio' }
})
```
**Implementation (Utility):**
```typescript
import { stegaClean } from "@sanity/client/stega";
export function getTextAlign(align?: string) {
// CLEAN the value before switching!
switch (stegaClean(align)) {
case 'left': return 'text-left';
case 'right': return 'text-right';
default: return 'text-center';
}
}
```
## 8. Semantic Heading Levels
**Rule:** Do NOT store heading levels (h1, h2) in Sanity schema options. Determine them dynamically in the frontend to ensure accessibility.
**Bad Schema:**
```typescript
// Don't do this
{ name: 'level', type: 'string', options: { list: ['h1', 'h2'] } }
```
**Good Component:**
Pass a `semanticLevel` prop based on the component's context/nesting.
```typescript
type Props = {
block: HeroBlock;
level?: 'h1' | 'h2' | 'h3'; // Default to h2 if undefined
}
export default function Section({ block, level = 'h2' }: Props) {
const Tag = level;
return <Tag>{block.title}</Tag>;
}
```
*Note: For Image patterns, see `image.md`. For Portable Text patterns, see `portable-text.md`.*
@@ -0,0 +1,365 @@
---
title: "Sanity Portable Text Rules"
description: Portable Text (Rich Text) rendering and custom component creation for React/Next.js.
---
# Sanity Portable Text Rules
Portable Text is Sanity's rich text format, used for content like article bodies (`body[]`). This guide covers rendering and creating custom PTE components.
**Note:** For page-level layout blocks (`pageBuilder[]`), see `page-builder.md`.
## 1. The Component
Use the `PortableText` component from `next-sanity` (or `@portabletext/react`).
```typescript
import { PortableText } from "next-sanity";
// or import { PortableText } from "@portabletext/react";
export function Content({ value }: { value: any }) {
return <PortableText value={value} components={components} />;
}
```
## 2. Custom Components (`components` prop)
**Always** define a typed components object to handle custom blocks, marks, and list styles.
```typescript
import { PortableTextComponents } from "next-sanity";
const components: PortableTextComponents = {
// 1. Block styles (paragraphs, headings)
block: {
h1: ({ children }) => <h1 className="text-4xl font-bold">{children}</h1>,
h2: ({ children }) => <h2 className="text-3xl font-bold">{children}</h2>,
blockquote: ({ children }) => <blockquote className="border-l-4 pl-4">{children}</blockquote>,
},
// 2. Custom types (non-text blocks like images, videos)
types: {
image: ({ value }) => <SanityImage value={value} />,
callToAction: ({ value }) => <Button href={value.url}>{value.text}</Button>,
},
// 3. Marks (inline decorators and annotations)
marks: {
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
link: ({ children, value }) => {
const rel = !value.href.startsWith("/") ? "noreferrer noopener" : undefined;
return <a href={value.href} rel={rel} className="underline text-blue-600">{children}</a>;
},
},
// 4. Lists
list: {
bullet: ({ children }) => <ul className="list-disc ml-4">{children}</ul>,
number: ({ children }) => <ol className="list-decimal ml-4">{children}</ol>,
},
};
```
## 3. Component Categories
Portable Text has three types of custom components, each with different patterns:
| Type | Examples | Pattern |
|------|----------|---------|
| **Block styles** | h1, h2, blockquote, normal | Text blocks with `children` prop |
| **Custom types** | image, video, callToAction | Non-text blocks with `value` prop |
| **Marks** | link, strong, productRef | Inline annotations wrapping text |
## 4. Creating Block Style Components
Block styles are text blocks like headings and paragraphs. For simple styling, inline components work fine:
```typescript
block: {
h2: ({ children }) => <h2 className="mt-8 mb-4 text-3xl font-bold">{children}</h2>,
normal: ({ children }) => <p className="mb-4 leading-relaxed">{children}</p>,
}
```
### With Visual Editing Support
For live editing in the Presentation Tool, block style components may need **both** a client and server version:
```typescript
// Heading2.tsx (Server - simple SSR for production)
export function Heading2({ children }: { children: React.ReactNode }) {
return <h2 className="mt-8 mb-4 text-3xl font-bold">{children}</h2>;
}
// Heading2Client.tsx (Client - for visual editing context)
'use client'
export function Heading2Client({ children, value }: { children: React.ReactNode; value: any }) {
// Can access block data via `value` for advanced patterns
return <h2 className="mt-8 mb-4 text-3xl font-bold">{children}</h2>;
}
```
Use `useIsPresentationTool` to conditionally render the client version:
```typescript
import { useIsPresentationTool } from 'next-sanity/hooks'
function Heading2Wrapper(props) {
const isPresentationTool = useIsPresentationTool()
if (isPresentationTool) {
return <Heading2Client {...props} />
}
return <Heading2 {...props} />
}
```
## 5. Creating Custom Type Components
Custom types are non-text blocks like images, videos, or CTAs embedded in rich text.
### Schema Definition
```typescript
// schemaTypes/blocks/pteImageBlock.ts
import { defineType, defineField } from 'sanity'
export const pteImageBlock = defineType({
name: 'pteImage',
title: 'Image',
type: 'object',
fields: [
defineField({ name: 'image', type: 'image', options: { hotspot: true } }),
defineField({ name: 'caption', type: 'string' }),
defineField({ name: 'alt', type: 'string', validation: (r) => r.required() }),
],
preview: {
select: { title: 'caption', media: 'image' },
},
})
```
### Register in Body Schema
```typescript
defineField({
name: 'body',
type: 'array',
of: [
{ type: 'block' }, // Standard text
{ type: 'pteImage' }, // Custom image block
{ type: 'pteVideo' }, // Custom video block
],
})
```
### Frontend Component
```typescript
// PteImageComponent.tsx
'use client'
type PteImageProps = {
value: {
_key: string
image: any
caption?: string
alt: string
}
}
export function PteImageComponent({ value }: PteImageProps) {
if (!value.image) return null
return (
<figure className="my-8">
<SanityImage value={value.image} alt={value.alt} />
{value.caption && (
<figcaption className="text-sm text-gray-600 mt-2">{value.caption}</figcaption>
)}
</figure>
)
}
// Register in components
const components: PortableTextComponents = {
types: {
pteImage: PteImageComponent,
},
}
```
## 6. Creating Mark Components
Marks are inline annotations that wrap text—links, highlights, or custom references.
### Schema Definition (Annotation)
```typescript
// In your block configuration
defineField({
name: 'body',
type: 'array',
of: [
{
type: 'block',
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Highlight', value: 'highlight' },
],
annotations: [
{
name: 'link',
type: 'object',
title: 'Link',
fields: [
{ name: 'href', type: 'url', title: 'URL' },
{ name: 'openInNewTab', type: 'boolean', title: 'Open in new tab' },
],
},
{
name: 'productRef',
type: 'object',
title: 'Product Reference',
fields: [
{ name: 'product', type: 'reference', to: [{ type: 'product' }] },
],
},
],
},
},
],
})
```
### Frontend Component
```typescript
// LinkMark.tsx
type LinkMarkProps = {
children: React.ReactNode
value: {
href: string
openInNewTab?: boolean
}
}
export function LinkMark({ children, value }: LinkMarkProps) {
const { href, openInNewTab } = value
const target = openInNewTab ? '_blank' : undefined
const rel = openInNewTab ? 'noopener noreferrer' : undefined
return (
<a href={href} target={target} rel={rel} className="text-blue-600 underline">
{children}
</a>
)
}
// Register in components
const components: PortableTextComponents = {
marks: {
link: LinkMark,
highlight: ({ children }) => <mark className="bg-yellow-200">{children}</mark>,
},
}
```
## 7. Presentation Queries for PTE Blocks
For faster live editing of custom PTE blocks, use presentation queries that fetch only the specific block:
```typescript
// queries.ts
export const PTE_IMAGE_PRESENTATION_QUERY = defineQuery(`
*[_id == $documentId][0]{
_id,
_type,
"pteImageBlock": body[_key == $blockKey && _type == "pteImage"][0]{
_key,
image,
caption,
alt
}
}
`)
```
Then in your component:
```typescript
'use client'
import { usePresentationQuery } from 'next-sanity/hooks'
export function PteImageComponent({ value, documentId }: { value: any; documentId?: string }) {
const { data } = usePresentationQuery({
query: PTE_IMAGE_PRESENTATION_QUERY,
params: { documentId, blockKey: value._key },
})
const blockData = data?.pteImageBlock || value
// ... render with blockData
}
```
**Note:** You'll need to pass `documentId` through to your PTE components. See `visual-editing.md` for context patterns.
## 8. GROQ Fragment for PTE
When querying documents with Portable Text, expand custom blocks:
```groq
*[_type == "article" && slug.current == $slug][0]{
...,
body[]{
...,
_type == "pteImage" => {
...,
"imageUrl": image.asset->url
},
_type == "pteVideo" => {
...,
video->{ title, url }
}
}
}
```
## 9. Stega and Visual Editing
When Visual Editing is enabled, text content contains invisible stega characters for click-to-edit functionality.
**For text rendering:** Let stega characters pass through—they enable overlays:
```typescript
// Good - stega preserved for click-to-edit
<h2>{children}</h2>
```
**For logic/comparisons:** Clean the values first:
```typescript
import { stegaClean } from '@sanity/client/stega'
// Clean before using in logic
const cleanedStyle = stegaClean(block.style)
if (cleanedStyle === 'h2') { ... }
```
## 10. Type Safety
When using TypeGen, the Portable Text value usually has a complex generated type. You can often use `any` or `PortableTextBlock[]` for the *prop*, but cast specific blocks if needed.
```typescript
import { PortableTextBlock } from "next-sanity";
type Props = {
value: PortableTextBlock[];
};
```
## 11. Best Practices
- **Tailwind Typography:** For simple blogs, wrap `<PortableText />` in a `<div className="prose">` (from `@tailwindcss/typography`) instead of manually styling every block.
- **Handling Nulls:** Always check if `value` exists and is an array before rendering.
- **Keys:** The `PortableText` component handles React keys automatically using the `_key` from Sanity. Do not add keys manually.
- **Separate from Page Builder:** PTE blocks live in `body[]` (rich text fields), not `pageBuilder[]` (page layout). Keep these patterns separate.
@@ -0,0 +1,116 @@
---
title: Sanity Project Structure
description: Project structure patterns for Sanity projects including monorepo and embedded Studio setups.
---
# Sanity Project Structure
## Standalone Studio
Best for content-only projects, API-first architectures, or when frontend is managed separately.
```
your-project/
├── schemaTypes/
│ ├── index.ts
│ ├── documents/
│ ├── objects/
│ └── blocks/
├── sanity.config.ts
├── sanity.cli.ts
└── package.json
```
**Use cases:**
- Content modeling with MCP/AI tools (no frontend needed)
- Headless CMS with external consumers
- Prototyping and content design
## Embedded Studio (Recommended for Next.js)
Best for most Next.js projects. Unified deployment, simpler setup.
```
your-project/
├── src/
│ ├── app/ # Next.js App Router
│ │ └── studio/[[...tool]]/ # Embedded Studio route
│ └── sanity/
│ ├── lib/
│ │ ├── client.ts
│ │ ├── live.ts # defineLive setup
│ │ └── queries.ts
│ └── schemaTypes/
│ ├── index.ts
│ ├── documents/
│ ├── objects/
│ └── blocks/
├── sanity.config.ts
├── sanity.cli.ts # CLI + TypeGen configuration
└── sanity.types.ts # Generated types (from TypeGen)
```
## Monorepo
Best when you need separation of concerns, multiple frontends, or strict dependency isolation.
```
your-project/
├── apps/
│ ├── studio/ # Sanity Studio (standalone)
│ │ ├── src/
│ │ │ └── schemaTypes/
│ │ │ ├── index.ts
│ │ │ ├── documents/
│ │ │ ├── objects/
│ │ │ └── blocks/
│ │ ├── sanity.config.ts
│ │ ├── sanity.cli.ts
│ │ └── package.json
│ └── web/ # Next.js (or other framework)
│ ├── src/
│ │ ├── app/
│ │ └── sanity/
│ │ ├── client.ts
│ │ ├── live.ts
│ │ └── queries.ts
│ └── package.json
├── pnpm-workspace.yaml
└── package.json
```
**Setup:**
1. Add web app URL to CORS origins in [Sanity Manage](https://www.sanity.io/manage)
2. Configure `typegen` in `sanity.cli.ts` to read schema from `apps/studio` and output types to `apps/web`
## File Naming Conventions
- **kebab-case** for all files: `user-profile.ts`, `hero-block.ts`
- `.ts` for schemas/utilities, `.tsx` for React components
- Each schema exports a named const matching filename
## Schema Directory Structure
```
schemaTypes/
├── index.ts # Exports all types
├── documents/ # Standalone content types
│ ├── post.ts
│ └── author.ts
├── objects/ # Embeddable/reusable types
│ ├── seo.ts
│ └── link.ts
├── blocks/ # Portable Text blocks
│ ├── hero.ts
│ └── callout.ts
└── shared/ # Shared field definitions
└── seoFields.ts
```
## Key Files
| File | Purpose |
|------|---------|
| `sanity.config.ts` | Studio configuration (plugins, schema, structure) |
| `sanity.cli.ts` | CLI configuration (project ID, dataset, TypeGen config) |
| `structure.ts` | Custom desk structure |
@@ -0,0 +1,134 @@
---
title: React Router (Remix) & Sanity Integration Rules
description: Integration guide for React Router (formerly Remix) with Sanity, including Loaders and Visual Editing.
---
# React Router (Remix) & Sanity Integration Rules
## Version Note
This guide covers both:
- **Remix v2** (`@remix-run/*` packages)
- **React Router v7** (the successor to Remix, `react-router` package)
The Sanity integration pattern is the same for both. Import paths differ slightly:
| Remix v2 | React Router v7 |
|----------|-----------------|
| `@remix-run/node` | `react-router` |
| `@remix-run/react` | `react-router` |
| `remix.config.js` | `react-router.config.ts` |
The examples below use Remix v2 imports. Adjust if using React Router v7.
## 1. Setup & Client Pattern
To support both server-side fetching and client-side live previews, use the **Split Loader Pattern**.
### A. Shared Loader (`app/sanity/loader.ts`)
Defines the store config (SSR enabled, client deferred).
```typescript
import { createQueryStore } from '@sanity/react-loader'
export const {
loadQuery,
setServerClient,
useQuery,
useLiveMode,
} = createQueryStore({ client: false, ssr: true })
```
### B. Server Loader (`app/sanity/loader.server.ts`)
Initializes the server client.
```typescript
import { createClient } from '@sanity/client'
import { loadQuery, setServerClient } from './loader'
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
useCdn: true,
apiVersion: '2026-02-01',
stega: {
enabled: true,
studioUrl: 'https://my-studio-url.com',
},
})
setServerClient(client)
export { loadQuery }
```
## 2. Data Fetching (Loaders)
Use `loadQuery` from your **server** file in route loaders.
```typescript
import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { loadQuery } from "~/sanity/loader.server";
import { POSTS_QUERY } from "~/sanity/queries";
export async function loader({ params }: LoaderFunctionArgs) {
const initial = await loadQuery(POSTS_QUERY, params);
return { initial, query: POSTS_QUERY, params };
}
export default function Index() {
const { initial, query, params } = useLoaderData<typeof loader>();
// ... pass to component
}
```
## 3. Real-time Preview & Visual Editing
### A. Use `useQuery` in Components
Import `useQuery` from your **shared** loader file.
```typescript
import { useQuery } from "~/sanity/loader";
export default function Page() {
const { initial, query, params } = useLoaderData<typeof loader>();
const { data, encodeDataAttribute } = useQuery(query, params, {
initial
});
return (
<h1 data-sanity={encodeDataAttribute('title')}>
{data?.title}
</h1>
);
}
```
### B. Enable Live Mode (`VisualEditing.tsx`)
Create a component to handle the connection.
```typescript
import { enableVisualEditing } from '@sanity/visual-editing'
import { useLiveMode } from '~/sanity/loader'
import { client } from '~/sanity/client' // Your browser-safe client
import { useEffect } from 'react'
export default function VisualEditing() {
useEffect(() => enableVisualEditing(), [])
useLiveMode({ client })
return null
}
```
Render this component in `root.tsx` only when valid (e.g., check env vars or user session).
## 4. Stega Cleaning
When using data for logic (routing, classNames), use `stegaClean`.
```typescript
import { stegaClean } from "@sanity/client/stega"
// ...
if (stegaClean(slug) === 'home') { ... }
```
@@ -0,0 +1,378 @@
---
title: Sanity Schema Best Practices
description: Rules for defining Sanity Content Models (Schemas), including field definitions, strict typing, and validation patterns.
---
# Sanity Schema Best Practices
Use this contents list to jump to the schema design decision you are making.
## Table of Contents
- Core philosophy: data over presentation
- Strict definition syntax
- Shared fields pattern
- Field patterns
- References vs nested objects
- Safe schema updates
- Validation patterns
## 1. Core Philosophy: Data > Presentation
Model **what things are**, not **what they look like**.
-**Bad:** `bigHeroText`, `redButton`, `threeColumnRow`, `color`, `fontSize`
-**Good:** `heroStatement`, `callToAction`, `featuresSection`, `status`, `role`
**The test:** "If we redesigned the site, would this field name still make sense?"
- `threeColumnLayout` → ❌ Fails (what if we go to 2 columns?)
- `features` → ✅ Passes (features are features regardless of layout)
## 2. Strict Definition Syntax
Always use the helper functions from `sanity` for type safety and autocompletion.
- **ALWAYS** use `defineType` for the root export.
- **ALWAYS** use `defineField` for fields.
- **ALWAYS** use `defineArrayMember` for items inside arrays.
```typescript
import { defineType, defineField, defineArrayMember } from 'sanity'
import { TagIcon } from '@sanity/icons'
export const article = defineType({
name: 'article',
title: 'Article',
type: 'document',
icon: TagIcon,
fields: [
defineField({
name: 'title',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'tags',
type: 'array',
of: [
// ALWAYS use defineArrayMember for array items
defineArrayMember({ type: 'reference', to: [{ type: 'tag' }] })
]
})
]
})
```
## 3. Shared Fields Pattern
Export arrays of fields to reuse common patterns (e.g., SEO, standard page headers).
```typescript
// src/schemaTypes/shared/seoFields.ts
export const seoFields = [
defineField({ name: 'seoTitle', type: 'string', title: 'SEO Title' }),
defineField({ name: 'seoDesc', type: 'text', title: 'SEO Description' })
]
// Usage
defineType({
name: 'page',
fields: [
defineField({ name: 'title', type: 'string' }),
...seoFields // Spread shared fields
]
})
```
## 4. Field Patterns
### A. Array Keys (`_key`)
Every item in a Sanity array automatically gets a `_key` property. This is **critical** for:
- React reconciliation (use as `key` prop)
- Visual Editing overlays (click-to-edit)
- Portable Text rendering
**Schema:** Sanity auto-generates `_key` for array items. You don't define it.
**Frontend:** Always use `_key` as React's `key`:
```typescript
// ✅ Correct
{items.map((item) => <Component key={item._key} {...item} />)}
// ❌ Wrong - index keys break Visual Editing
{items.map((item, i) => <Component key={i} {...item} />)}
```
**Querying:** Always include `_key` in array projections:
```groq
*[_type == "page"][0]{
pageBuilder[]{
_key, // Always include _key in queries
_type,
...
}
}
```
### B. Icons
Always assign an icon from `@sanity/icons` to documents and objects. This improves the Studio UX significantly. Browse all icons at [icons.sanity.build](https://icons.sanity.build/all).
| Content Type | Icon |
|--------------|------|
| Article, Post | `DocumentTextIcon` |
| Author, Person | `UserIcon` |
| Category, Tag | `TagIcon` |
| Settings | `CogIcon` |
| Page | `DocumentIcon` |
| Image block | `ImageIcon` |
| Video block | `PlayIcon` |
| FAQ | `HelpCircleIcon` |
| Link | `LinkIcon` |
### C. Boolean vs. List
Avoid boolean fields for binary states that might expand later.
- **Prefer:** `options.list` with "radio" layout.
```typescript
defineField({
name: 'status',
type: 'string',
options: {
list: [
{ title: 'Draft', value: 'draft' },
{ title: 'Published', value: 'published' }
],
layout: 'radio'
}
})
```
### D. The "Toggle" Pattern (Conditional Fields)
Use a radio/boolean field to toggle visibility of other fields (often grouped in fieldsets).
```typescript
defineField({
name: 'linkType',
type: 'string',
options: { list: ['internal', 'external'], layout: 'radio' }
}),
defineField({
name: 'internalLink',
type: 'reference',
hidden: ({ parent }) => parent?.linkType !== 'internal'
}),
defineField({
name: 'externalUrl',
type: 'url',
hidden: ({ parent }) => parent?.linkType !== 'external'
})
```
## 5. References vs Nested Objects
A **critical modeling decision**: when to use `reference` vs embedding an `object`.
### Use References When:
- Content is **reusable** across documents (authors, categories, products)
- Content needs its **own editing interface** in Studio
- You need to query/filter by the related content independently
- Multiple documents should share the **same instance** (update once, reflect everywhere)
```typescript
// ✅ Author is reusable and independently editable
defineField({
name: 'author',
type: 'reference',
to: [{ type: 'author' }]
})
```
### Use Nested Objects When:
- Content is **specific to this document** (not shared)
- Content doesn't make sense on its own (address, SEO metadata)
- You want **simpler editing** (all fields in one place)
- You need the data to be **copied** not linked
```typescript
// ✅ SEO is document-specific, not shared
defineField({
name: 'seo',
type: 'object',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'description', type: 'text' })
]
})
```
### Quick Decision Matrix
| Scenario | Use |
|----------|-----|
| Blog post author | `reference` (reusable) |
| Product category | `reference` (shared taxonomy) |
| Page SEO fields | `object` (page-specific) |
| Hero section content | `object` (page-specific) |
| Team member on About page | `reference` (might be used elsewhere) |
| Call-to-action button | `object` (usually page-specific) |
### Querying Differences
```groq
// Reference requires expansion
*[_type == "post"]{ author->{ name, bio } }
// Object is already inline
*[_type == "post"]{ seo { title, description } }
```
## 6. Safe Schema Updates (The Deprecation Pattern)
**NEVER** delete a field that contains production data. It will cause data loss or Studio crashes. Instead, follow the **ReadOnly -> Hidden -> Deprecated** lifecycle.
### The Pattern
1. **`deprecated`**: Adds a visual warning and reason.
2. **`readOnly: true`**: Prevents new edits but keeps data visible.
3. **`hidden`**: Hides it from *new* documents (where value is undefined).
4. **`initialValue: undefined`**: Ensures new documents don't get this field.
```typescript
defineField({
name: 'oldTitle', // The field you want to remove
title: 'Article Title (Deprecated)',
type: 'string',
deprecated: {
reason: 'Use the new "seoTitle" field instead. This will be removed in v2.'
},
readOnly: true,
hidden: ({ value }) => value === undefined,
initialValue: undefined
})
```
### Migration Workflow
**Phase 1: Deprecate** — Apply the deprecation pattern above. Deploy.
**Phase 2: Migrate** — Update frontend to use new fields (with `coalesce()` fallbacks). Create a migration:
```typescript
// migrations/rename-oldTitle-to-newTitle/index.ts
import {defineMigration, at, setIfMissing, unset} from 'sanity/migrate'
export default defineMigration({
title: 'Rename oldTitle to newTitle',
documentTypes: ['article'],
filter: 'defined(oldTitle) && !defined(newTitle)',
migrate: {
document(doc) {
if (!doc.oldTitle || doc.newTitle) return
return [
at('newTitle', setIfMissing(doc.oldTitle)),
at('oldTitle', unset())
]
}
}
})
```
```bash
# Dry run first (default)
sanity migration run rename-oldTitle-to-newTitle
# Execute when ready
sanity migration run rename-oldTitle-to-newTitle --no-dry-run
```
**Phase 3: Remove** — Once `oldTitle` is undefined for all documents, delete the field definition.
## 7. Validation Patterns
Beyond `rule.required()`, Sanity offers powerful validation options.
### Common Patterns
```typescript
// Email validation
defineField({
name: 'email',
type: 'string',
validation: (rule) => rule.email().required()
})
// URL validation (with custom message)
defineField({
name: 'website',
type: 'url',
validation: (rule) => rule.uri({
scheme: ['http', 'https']
}).error('Must be a valid URL starting with http:// or https://')
})
// Length constraints
defineField({
name: 'excerpt',
type: 'text',
validation: (rule) => rule.max(200).warning('Keep it under 200 characters for best SEO')
})
// Regex pattern
defineField({
name: 'slug',
type: 'slug',
validation: (rule) => rule.required().custom((slug) => {
if (!slug?.current) return 'Required'
if (!/^[a-z0-9-]+$/.test(slug.current)) {
return 'Slug must be lowercase with hyphens only'
}
return true
})
})
```
### Cross-Field Validation
```typescript
defineField({
name: 'endDate',
type: 'datetime',
validation: (rule) => rule.custom((endDate, context) => {
const startDate = context.document?.startDate
if (startDate && endDate && new Date(endDate) < new Date(startDate)) {
return 'End date must be after start date'
}
return true
})
})
```
### Array Validation
```typescript
defineField({
name: 'tags',
type: 'array',
of: [{ type: 'string' }],
validation: (rule) => rule
.min(1).error('Add at least one tag')
.max(10).warning('Too many tags may hurt SEO')
.unique()
})
```
### Async Validation (Uniqueness Check)
```typescript
defineField({
name: 'slug',
type: 'slug',
validation: (rule) => rule.required().custom(async (slug, context) => {
if (!slug?.current) return true
const client = context.getClient({ apiVersion: '2026-02-01' })
const id = context.document?._id?.replace(/^drafts\./, '')
const existing = await client.fetch(
`count(*[_type == "post" && slug.current == $slug && _id != $id])`,
{ slug: slug.current, id }
)
return existing === 0 || 'Slug already exists'
})
})
```
@@ -0,0 +1,331 @@
---
title: Sanity SEO Best Practices
description: SEO best practices for Sanity with Next.js, including metadata, Open Graph, sitemaps, redirects, and JSON-LD structured data.
---
# Sanity SEO Best Practices
## 1. Core Philosophy
SEO doesn't require complex configurations. A few core principles, applied consistently:
- **Smart defaults with optional overrides** — Don't require SEO fields; use existing content as fallback
- **Use GROQ for fallback logic** — Move conditional logic into queries, not components
- **Leverage Next.js APIs** — Use `generateMetadata`, `sitemap.ts`, not manual `<meta>` tags
- **Structured content = structured data** — Your content model is already SEO-ready
## 2. SEO Schema Type (Reusable)
Create a reusable SEO object type for consistent metadata across document types.
```typescript
// schemaTypes/seoType.ts
import { defineField, defineType } from "sanity";
export const seoType = defineType({
name: "seo",
title: "SEO",
type: "object",
fields: [
defineField({
name: "title",
description: "Overrides the page title if provided",
type: "string",
}),
defineField({
name: "description",
type: "text",
rows: 3,
}),
defineField({
name: "image",
description: "Image for social sharing (1200x630 recommended)",
type: "image",
options: { hotspot: true },
}),
defineField({
name: "noIndex",
description: "Hide this page from search engines",
type: "boolean",
initialValue: false,
}),
],
});
```
**Usage in document types:**
```typescript
defineField({
name: "seo",
type: "seo",
})
```
## 3. GROQ Queries with Fallbacks
Use `coalesce()` to provide fallback values. This keeps frontend logic clean.
```groq
*[_type == "page" && slug.current == $slug][0]{
...,
"seo": {
// Use SEO field if provided, otherwise fall back to main title
"title": coalesce(seo.title, title, ""),
"description": coalesce(seo.description, ""),
"image": seo.image,
"noIndex": seo.noIndex == true
}
}
```
**Key principle:** `seo.title` will never be `null` — it contains either the SEO override, the page title, or empty string.
## 4. Next.js Metadata (The Right Way)
Use `generateMetadata` — never render `<title>` or `<meta>` tags directly in components.
```typescript
// app/(frontend)/[slug]/page.tsx
import type { Metadata } from "next";
import { urlFor } from "@/sanity/lib/image";
type RouteProps = {
params: Promise<{ slug: string }>;
};
// Extract fetch to reuse in both functions
const getPage = async (params: RouteProps["params"]) =>
sanityFetch({
query: PAGE_QUERY,
params: await params,
stega: false, // Critical for SEO!
});
export async function generateMetadata({ params }: RouteProps): Promise<Metadata> {
const { data: page } = await getPage(params);
if (!page) return {};
const metadata: Metadata = {
title: page.seo.title,
description: page.seo.description,
};
// Open Graph image
if (page.seo.image) {
metadata.openGraph = {
images: {
url: urlFor(page.seo.image).width(1200).height(630).url(),
width: 1200,
height: 630,
},
};
}
// noIndex robots directive
if (page.seo.noIndex) {
metadata.robots = "noindex";
}
return metadata;
}
export default async function Page({ params }: RouteProps) {
const { data: page } = await getPage(params);
// ... render page
}
```
**Critical:** Always set `stega: false` when fetching for metadata. Stega characters in `<title>` destroy SEO.
## 5. Dynamic Sitemap
Use Next.js `sitemap.ts` convention to auto-generate from Sanity content.
### GROQ Query
```groq
*[_type in ["page", "post"] && defined(slug.current) && seo.noIndex != true] {
"href": select(
_type == "page" => "/" + slug.current,
_type == "post" => "/posts/" + slug.current,
slug.current
),
_updatedAt
}
```
### Route Implementation
```typescript
// app/sitemap.ts
import { MetadataRoute } from "next";
import { client } from "@/sanity/lib/client";
import { SITEMAP_QUERY } from "@/sanity/lib/queries";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
try {
const paths = await client.fetch(SITEMAP_QUERY);
if (!paths) return [];
return paths.map((path) => ({
url: new URL(path.href!, baseUrl).toString(),
lastModified: new Date(path._updatedAt),
changeFrequency: "weekly",
priority: 1,
}));
} catch (error) {
console.error("Sitemap generation failed:", error);
return [];
}
}
```
**Note:** Sitemap limit is 50,000 URLs per file. For larger sites, use sitemap index.
## 6. Redirects (Managed in Sanity)
Create a redirect document type for content team management.
### Schema
```typescript
// schemaTypes/redirectType.ts
import { defineField, defineType, SanityDocumentLike } from "sanity";
import { LinkIcon } from "@sanity/icons";
function isValidPath(value: string | undefined) {
if (!value) return "Required";
if (!value.startsWith("/")) return "Must start with /";
if (/[^a-zA-Z0-9\-_/:]/.test(value)) return "Invalid characters";
return true;
}
export const redirectType = defineType({
name: "redirect",
title: "Redirect",
type: "document",
icon: LinkIcon,
validation: (Rule) =>
Rule.custom((doc: SanityDocumentLike | undefined) => {
if (doc?.source === doc?.destination) {
return "Source and destination cannot be the same";
}
return true;
}),
fields: [
defineField({
name: "source",
type: "string",
validation: (Rule) => Rule.required().custom(isValidPath),
}),
defineField({
name: "destination",
type: "string",
validation: (Rule) => Rule.required(),
}),
defineField({
name: "permanent",
description: "301 (permanent) or 302 (temporary)",
type: "boolean",
initialValue: true,
}),
defineField({
name: "isEnabled",
type: "boolean",
initialValue: true,
}),
],
});
```
### Next.js Config
```typescript
// next.config.ts
import { fetchRedirects } from "@/sanity/lib/fetchRedirects";
const nextConfig: NextConfig = {
async redirects() {
return await fetchRedirects();
},
};
```
**Limits:** Vercel allows max 1,024 redirects in `next.config`. For more, use middleware.
## 7. Dynamic Open Graph Images
Generate OG images on-the-fly using Next.js Edge Runtime at `/api/og`.
```typescript
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export async function GET(request: Request) {
const id = new URL(request.url).searchParams.get("id");
if (!id) return new Response("Missing id", { status: 400 });
const data = await client.fetch(`*[_id == $id][0]{ title }`, { id });
return new ImageResponse(
<div tw="flex w-full h-full bg-blue-500 text-white p-10">
<h1 tw="text-6xl font-bold">{data?.title || "Untitled"}</h1>
</div>,
{ width: 1200, height: 630 }
);
}
```
Use as fallback in metadata: `url: page.seo.image ? urlFor(page.seo.image).url() : \`/api/og?id=\${page._id}\``
## 8. JSON-LD Structured Data
Use `schema-dts` for type-safe structured data.
```bash
npm install schema-dts
```
### FAQ Example
```typescript
import { FAQPage, WithContext } from "schema-dts";
const generateFaqData = (faqs: FAQ[]): WithContext<FAQPage> => ({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.title,
acceptedAnswer: {
"@type": "Answer",
text: faq.text, // Use pt::text() in GROQ to get plain text
},
})),
});
// In component
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(generateFaqData(faqs)) }}
/>
```
### GROQ for Plain Text
```groq
faqs[]->{
_id,
title,
body,
"text": pt::text(body) // Convert Portable Text to plain string
}
```
## 9. Testing Tools
- **Open Graph:** [opengraph.ing](https://opengraph.ing/)
- **Facebook:** [Sharing Debugger](https://developers.facebook.com/tools/debug/)
- **Twitter:** [Card Validator](https://cards-dev.twitter.com/validator)
- **LinkedIn:** [Post Inspector](https://www.linkedin.com/post-inspector/)
- **Sitemap:** [XML Sitemaps Validator](https://www.xml-sitemaps.com/validate-xml-sitemap.html)
@@ -0,0 +1,136 @@
---
title: "Sanity Studio Structure Rules"
description: Rules for customizing the Sanity Studio Structure (S.structure).
---
# Sanity Studio Structure Rules
## 1. Setup
Custom structure is defined in `sanity.config.ts` using the `structureTool`.
```typescript
import { structureTool } from 'sanity/structure'
import { structure } from './src/structure'
export default defineConfig({
// ...
plugins: [
structureTool({ structure })
]
})
```
## 2. Structure Definition
**Location:** `src/structure/index.ts`
Use a function that receives `S` (StructureBuilder).
```typescript
import type { StructureResolver } from 'sanity/structure'
export const structure: StructureResolver = (S) =>
S.list()
.title('Content')
.items([
// ... items
])
```
## 3. Organization Principles
1. **Singletons First:** Place critical site-wide settings (Global Settings, Homepage) at the top.
2. **Dividers:** Use `S.divider()` to visually separate logical groups.
3. **Filtered Lists:** Always exclude Singleton documents from generic `documentTypeList` items to avoid duplication.
## 4. Singleton Pattern (Critical)
**Singletons are enforced via Structure, NOT schema options.** There is no `singleton: true` schema option.
### How Singletons Work
1. Use `S.document().documentId('fixed-id')` to lock the document to a specific ID.
2. Filter the type from generic lists to prevent duplicate entries.
### Singleton Helper Function
```typescript
// Helper to create singleton list items
function createSingleton(S: StructureBuilder, typeName: string, title: string, icon?: ComponentType) {
return S.listItem()
.title(title)
.icon(icon)
.child(
S.document()
.schemaType(typeName)
.documentId(typeName) // Fixed ID = singleton
.title(title)
)
}
// Usage
createSingleton(S, 'settings', 'Site Settings', CogIcon)
```
### Querying Singletons
```groq
// By fixed ID (most efficient)
*[_id == "settings"][0]
// By type (works but slower)
*[_type == "settings"][0]
```
**For localized singletons** (e.g., homepage per language), see `localization.md` Section 6.
## 5. Implementation Pattern
```typescript
// Define singleton types to exclude from generic lists
const SINGLETONS = ['settings', 'homePage']
export const structure: StructureResolver = (S) =>
S.list()
.title('Website Content')
.items([
// 1. Singletons
S.listItem()
.title('Site Settings')
.icon(CogIcon)
.child(S.document().schemaType('settings').documentId('settings')),
S.divider(),
// 2. Content Verticals
S.listItem()
.title('Blog')
.child(
S.list()
.title('Blog Content')
.items([
S.documentTypeListItem('post').title('Posts'),
S.documentTypeListItem('author').title('Authors'),
])
),
S.divider(),
// 3. Remaining Documents (Filtered)
...S.documentTypeListItems().filter(
(listItem) => !SINGLETONS.includes(listItem.getId() as string)
)
])
```
## 6. Views (Split Pane)
Add "Web Preview" or other views to documents.
```typescript
export const defaultDocumentNode: DefaultDocumentNodeResolver = (S, { schemaType }) => {
switch (schemaType) {
case `post`:
return S.document().views([
S.view.form(), // Default form
S.view.component(PreviewComponent).title('Preview') // Custom view
])
default:
return S.document().views([S.view.form()])
}
}
```
@@ -0,0 +1,155 @@
---
title: "SvelteKit & Sanity Integration Rules"
description: Integration guide for SvelteKit with Sanity, including @sanity/svelte-loader, Visual Editing, and Preview Mode.
---
# SvelteKit & Sanity Integration Rules
## 1. Setup & Configuration
### Installation
```bash
npm install @sanity/svelte-loader @sanity/client @sanity/visual-editing
```
### Client Configuration (`src/lib/sanity.ts`)
Define the client with `stega` enabled for the studio URL.
```typescript
import { createClient } from '@sanity/client'
import { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET, PUBLIC_SANITY_API_VERSION, PUBLIC_SANITY_STUDIO_URL } from '$env/static/public'
export const client = createClient({
projectId: PUBLIC_SANITY_PROJECT_ID,
dataset: PUBLIC_SANITY_DATASET,
apiVersion: PUBLIC_SANITY_API_VERSION,
useCdn: true,
stega: {
studioUrl: PUBLIC_SANITY_STUDIO_URL,
},
})
```
### Server Client (`src/lib/server/sanity.ts`)
Use the read token for fetching preview content.
```typescript
import { SANITY_API_READ_TOKEN } from '$env/static/private'
import { client } from '$lib/sanity'
export const serverClient = client.withConfig({
token: SANITY_API_READ_TOKEN,
stega: true, // Optional: enable stega on server too if needed
})
```
## 2. Hooks & Request Handler (Critical)
You **must** configure `createRequestHandler` in `src/hooks.server.ts` to handle preview sessions and inject `loadQuery` into locals.
```typescript
// src/hooks.server.ts
import { createRequestHandler, setServerClient } from '@sanity/svelte-loader'
import { serverClient } from '$lib/server/sanity'
setServerClient(serverClient)
export const handle = createRequestHandler()
```
**Update `app.d.ts` types:**
```typescript
import type { LoaderLocals } from '@sanity/svelte-loader'
declare global {
namespace App {
interface Locals extends LoaderLocals {}
}
}
```
## 3. Preview State Propagation
Pass the preview state from the server to the client via the root layout.
**Server Layout (`src/routes/+layout.server.ts`):**
```typescript
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = ({ locals: { preview } }) => {
return { preview }
}
```
**Client Layout (`src/routes/+layout.ts`):**
```typescript
import { setPreviewing } from '@sanity/svelte-loader'
import type { LayoutLoad } from './$types'
export const load: LayoutLoad = ({ data: { preview } }) => {
setPreviewing(preview)
}
```
## 4. Data Fetching (Loaders)
Use `locals.loadQuery` in your page server loaders.
```typescript
// src/routes/[slug]/+page.server.ts
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals: { loadQuery }, params }) => {
const initial = await loadQuery(QUERY, params)
return { initial }
}
```
## 5. Real-time Preview & Visual Editing
### Component Usage (`useQuery`)
Use `useQuery` in your Svelte component to handle real-time updates.
```svelte
<!-- src/routes/[slug]/+page.svelte -->
<script lang="ts">
import { useQuery } from '@sanity/svelte-loader'
import type { PageData } from './$types'
export let data: PageData
const { initial } = data
// Hydrate with initial data
const query = useQuery(initial)
// Reactive data access
$: ({ data: post, loading, encodeDataAttribute } = $query)
</script>
{#if !loading && post}
<!-- Use encodeDataAttribute for overlays -->
<h1 data-sanity={encodeDataAttribute('title')}>
{post.title}
</h1>
{/if}
```
### Enable Visual Editing (`+layout.svelte`)
Enable Visual Editing and Live Mode in your root layout.
```svelte
<script lang="ts">
import { useLiveMode } from '@sanity/svelte-loader'
import { enableVisualEditing } from '@sanity/visual-editing'
import { PUBLIC_SANITY_STUDIO_URL } from '$env/static/public'
import { onMount } from 'svelte'
onMount(() => enableVisualEditing())
onMount(() => useLiveMode({
studioUrl: PUBLIC_SANITY_STUDIO_URL
}))
</script>
<slot />
```
@@ -0,0 +1,215 @@
---
title: Sanity TypeGen Rules
description: Workflow for generating TypeScript types from Sanity Schema and GROQ queries.
---
# Sanity TypeGen Rules
## 1. The Workflow
Sanity TypeGen generates TypeScript types from your schema and GROQ queries. Types can be generated automatically or manually.
### Automatic (Recommended)
Enable in `sanity.cli.ts` — types regenerate during `sanity dev` and `sanity build`:
```typescript
// sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
typegen: {
enabled: true,
},
})
```
### Manual
Run the extract + generate cycle whenever schema or queries change:
1. **Extract:** Converts your Schema (TS/JS) into a static JSON representation.
2. **Generate:** Scans your codebase for GROQ queries and generates TypeScript types.
```bash
npx sanity schema extract && npx sanity typegen generate
```
### Watch Mode (for separate frontends)
If your frontend is in a separate repo from the Studio, use watch mode:
```bash
npx sanity typegen generate --watch
```
## 2. The "Update Types" Pattern
For manual workflows, implement a single script:
**package.json:**
```json
"scripts": {
"typegen": "sanity schema extract && sanity typegen generate"
}
```
### Git Strategy for Generated Files
**Option A: Commit generated types (Recommended for most teams)**
- Types available immediately after `git pull`
- CI/CD doesn't need to run typegen
- Can cause merge conflicts
**Option B: Generate in CI (Recommended for larger teams)**
Add to `.gitignore`:
```gitignore
# Sanity TypeGen (generated)
sanity.types.ts
schema.json
```
Then ensure CI runs typegen before build:
```yaml
# Example GitHub Actions
- run: npm run typegen
- run: npm run build
```
## 3. Configuration (`sanity.cli.ts`)
> **Note:** `sanity-typegen.json` is deprecated. Move your configuration to `sanity.cli.ts`.
```typescript
// sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
typegen: {
enabled: true, // Auto-generate during sanity dev/build
path: "./src/**/*.{ts,tsx,js,jsx,astro,svelte,vue}", // Glob to find queries
schema: "schema.json", // Schema file from extract
generates: "./sanity.types.ts", // Output file
overloadClientMethods: true, // Auto-type client.fetch() calls
},
})
```
### Project Structure Examples
**Single Repo / Embedded Studio (most common):**
Use defaults — no extra config needed.
**Monorepo** (Studio in `apps/studio`, Frontend in `apps/web`):
```typescript
export default defineCliConfig({
typegen: {
path: "../web/src/**/*.{ts,tsx,js,jsx}",
schema: "schema.json",
generates: "../web/sanity.types.ts",
},
})
```
**Separate Repos:**
Use `--watch` mode in your frontend: `sanity typegen generate --watch`
## 4. Usage in Code
### Automatic Type Inference (Recommended)
With `overloadClientMethods: true` (default), `client.fetch()` automatically returns typed results when you use `defineQuery`:
```typescript
import { defineQuery } from "groq";
import { createClient } from "@sanity/client";
const client = createClient({...});
const POSTS_QUERY = defineQuery(`*[_type == "post"]{ title, slug }`);
// Return type is automatically inferred — no manual type import needed!
const posts = await client.fetch(POSTS_QUERY);
```
### Manual Type Import (Alternative)
You can also import generated types directly:
```typescript
import { defineQuery } from "groq";
// Next.js re-exports defineQuery for convenience:
// import { defineQuery } from "next-sanity";
const AUTHOR_QUERY = defineQuery(`*[_type == "author" && slug.current == $slug][0]{ name, bio }`);
import type { AUTHOR_QUERYResult } from "@/sanity.types";
export default function Author({ data }: { data: AUTHOR_QUERYResult }) {
return <h1>{data.name}</h1>
}
```
### Required Fields
Use `--enforce-required-fields` during extraction to translate `validation: rule => rule.required()` into non-optional types:
```bash
npx sanity schema extract --enforce-required-fields
npx sanity typegen generate
```
> **Warning:** If you use draft previews, fields may still be `undefined` even with required validation, since drafts can be in an invalid state.
### Type Utilities
TypeGen provides utilities for working with complex types:
```typescript
import type { Get, FilterByType } from 'sanity'
import type { Page, PageBuilder } from './sanity.types'
// Extract deeply nested type (up to 20 levels)
type HeroSection = Get<Page, 'sections', number, 'hero'>
// Filter specific types from unions using _type discriminator
type HeroBlock = FilterByType<PageBuilder, 'hero'>
```
### Unique Query Names
All queries must have unique variable names. Duplicate names across files will cause TypeGen to silently overwrite types. Use descriptive, scoped names:
```typescript
// Unique names
const POSTS_INDEX_QUERY = defineQuery(`*[_type == "post"]{ title }`)
const POST_DETAIL_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]`)
// Duplicate names will conflict
const QUERY = defineQuery(`*[_type == "post"]`) // file-a.ts
const QUERY = defineQuery(`*[_type == "author"]`) // file-b.ts — overwrites!
```
### Supported Query Formats
Queries must be assigned to a variable using `groq` or `defineQuery`:
```typescript
// Works — groq template tag
const query = groq`*[_type == "post"]`
// Works — defineQuery
const query = defineQuery(`*[_type == "post"]`)
// Won't work — inline query
await client.fetch(groq`*[_type == "post"]`)
```
### Supported File Types
TypeGen parses queries from: `.ts`, `.tsx`, `.js`, `.jsx`, `.astro`, `.svelte`, `.vue`
### tsconfig Requirements
Ensure `sanity.types.ts` is included in your `tsconfig.json`'s `include` array. If your config restricts includes (e.g., `["src/**/*"]`) and the types file is at the project root, TypeScript won't pick up the generated types:
```json
{
"include": ["src/**/*", "sanity.types.ts"]
}
```
### Skipping Individual Queries
Add `@sanity-typegen-ignore` in a comment before a query to skip type generation:
```typescript
// @sanity-typegen-ignore
const debugQuery = groq`*[_type == "debug"]`
```
@@ -0,0 +1,263 @@
---
title: "Sanity Visual Editing Rules"
description: Comprehensive guide for Sanity Visual Editing, including Presentation Tool, Stega (Content Source Maps), and Overlays.
---
# Sanity Visual Editing Rules
## 1. Concepts
### Presentation Tool
The Studio plugin (`sanity/presentation`) that renders your front-end application inside an iframe in the Studio. It enables the "Edit" overlay and bidirectional navigation.
### Content Source Maps (Stega)
Invisible characters embedded in strings that tell the Presentation Tool which field in which document the content comes from.
- **Mechanism:** Sanity encodes document ID, field path, and dataset info into string values.
- **Result:** Click-to-edit functionality in the preview.
### Loaders
Framework-agnostic or specific libraries that handle:
1. Fetching data (production vs. preview).
2. Subscribing to real-time updates (Live Content API).
3. Encoding Stega strings (if not handled by the Content Lake automatically).
## 2. The Golden Rule of Stega (Clean Data)
When Visual Editing is enabled, string fields will contain invisible characters. You **MUST** clean them before using the value for logic.
| Scenario | Clean? | Why |
|----------|--------|-----|
| Comparing strings (`if (x === 'y')`) | ✅ Yes | Stega breaks equality |
| Using as object keys | ✅ Yes | Keys won't match |
| Using as HTML IDs | ✅ Yes | Invalid characters |
| Passing to third-party libraries | ✅ Yes | May validate input |
| Rendering text (`<h1>{title}</h1>`) | ❌ No | Breaks click-to-edit |
| Passing to `<PortableText />` | ❌ No | Handles internally |
| Passing to image helpers | ❌ No | Handles internally |
```typescript
import { stegaClean } from "@sanity/client/stega";
export function Layout({ align }: { align: string }) {
// Good: Clean before comparison
const cleanAlign = stegaClean(align);
return <div className={cleanAlign === 'center' ? 'mx-auto' : ''} />
}
```
## 3. Token Handling (Security)
Store your read token in a dedicated file that throws if missing:
```typescript
// src/sanity/lib/token.ts
export const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
throw new Error('Missing SANITY_API_READ_TOKEN')
}
```
**Never** expose tokens in client bundles. Pass to `defineLive` for server/browser use only when Draft Mode is enabled.
## 4. Setup: Presentation Tool
**File:** `sanity.config.ts`
```typescript
import { defineConfig } from 'sanity'
import { presentationTool } from 'sanity/presentation'
import { resolve } from '@/sanity/presentation/resolve'
export default defineConfig({
// ...
plugins: [
presentationTool({
resolve, // Document locations (see below)
previewUrl: {
previewMode: {
enable: '/api/draft-mode/enable',
},
},
}),
],
})
```
### Document Locations
Show where documents appear in the front-end — enables quick navigation between Structure and Presentation tools.
```typescript
// src/sanity/presentation/resolve.ts
import { defineLocations, PresentationPluginOptions } from 'sanity/presentation'
export const resolve: PresentationPluginOptions['resolve'] = {
locations: {
post: defineLocations({
select: { title: 'title', slug: 'slug.current' },
resolve: (doc) => ({
locations: [
{ title: doc?.title || 'Untitled', href: `/posts/${doc?.slug}` },
{ title: 'Posts index', href: `/posts` },
],
}),
}),
// Add more document types as needed
},
}
```
## 5. Visual Editing Overlays
Render `<VisualEditing />` in Draft Mode for click-to-edit overlays.
**Next.js (App Router):**
```typescript
// layout.tsx
import { VisualEditing } from 'next-sanity/visual-editing'
import { draftMode } from 'next/headers'
import { DisableDraftMode } from '@/components/disable-draft-mode'
export default async function RootLayout({ children }) {
return (
<html>
<body>
{children}
{(await draftMode()).isEnabled && (
<>
<DisableDraftMode />
<VisualEditing />
</>
)}
</body>
</html>
)
}
```
### Disable Draft Mode Button
Useful for content authors to exit preview and see published content:
```typescript
// src/components/disable-draft-mode.tsx
'use client'
import { useDraftModeEnvironment } from 'next-sanity/hooks'
export function DisableDraftMode() {
const environment = useDraftModeEnvironment()
// Only show outside of Presentation Tool
if (environment !== 'live' && environment !== 'unknown') return null
return (
<a href="/api/draft-mode/disable" className="fixed bottom-4 right-4 bg-gray-50 px-4 py-2">
Disable Draft Mode
</a>
)
}
```
**Remix/Svelte:** See framework-specific rules for `useLiveMode` and `enableVisualEditing` patterns.
## 6. SEO & Metadata (Critical)
**NEVER** allow Stega strings in `<head>` tags (Title, Description, Canonical URLs). It destroys SEO rankings and looks broken in search results.
- **Next.js:** Set `stega: false` in `generateMetadata`.
- **General:** Explicitly clean fields used in `<title>` or `<meta>`.
```typescript
// Next.js Example — disable stega at fetch level
export async function generateMetadata({ params }) {
const { data } = await sanityFetch({
query: SEO_QUERY,
stega: false // Critical
})
return { title: data.title }
}
```
**Alternative:** If you can't disable stega at the fetch level, clean explicitly:
```typescript
import { stegaClean } from "@sanity/client/stega";
export async function generateMetadata({ params }) {
const { data } = await sanityFetch({ query: PAGE_QUERY })
return {
title: stegaClean(data.title),
description: stegaClean(data.description),
openGraph: { url: stegaClean(data.canonicalUrl) }
}
}
```
## 7. Drag-and-Drop Reordering (Advanced)
For arrays (e.g., "Related Posts"), enable drag-and-drop in the preview using `data-sanity` attributes and `useOptimistic`:
```typescript
import { createDataAttribute } from 'next-sanity'
import { useOptimistic } from 'next-sanity/hooks'
// Add data-sanity to array container
<ul data-sanity={createDataAttribute({ id: documentId, type: 'post', path: 'relatedPosts' }).toString()}>
{items.map((item) => (
<li key={item._key} data-sanity={createDataAttribute({
id: documentId, type: 'post', path: `relatedPosts[_key=="${item._key}"]`
}).toString()}>
{item.title}
</li>
))}
</ul>
```
**Key requirements:**
- Query must include `_key` for array items
- Use `useOptimistic` hook for instant UI updates during mutations
## 8. Optimistic Updates for Faster Editing
By default, editing a field in the Presentation Tool triggers a full page re-render. For pages with many components, this can feel sluggish. **Presentation queries** solve this by fetching only the specific block being edited.
### The Concept
Instead of:
1. User edits a field -> Full page query re-runs -> All components re-render
You get:
1. User edits a field -> Block-specific query runs -> Only that component re-renders
### How It Works
1. **Create a targeted query** that fetches just the block data using `_key`:
```groq
*[_id == $documentId][0]{
"heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
title, subtitle, image
}
}
```
2. **Use a presentation query hook** in your component (e.g., `usePresentationQuery` in Next.js)
3. **Fall back to initial props** when not in presentation mode
This pattern works for both Page Builder blocks (`pageBuilder[]`) and Portable Text blocks (`body[]`).
**See framework-specific rules for implementation:**
- Next.js: `nextjs.md` (Section 9)
- Page Builder: `page-builder.md` (Section 5)
- Portable Text: `portable-text.md` (Section 7)
## 9. Framework Specifics
| Framework | Loader Package | Key Components |
| :--- | :--- | :--- |
| **Next.js** | `next-sanity` | `<VisualEditing />`, `defineLive`, `usePresentationQuery` |
| **Remix** | `@sanity/react-loader` | `createQueryStore`, `useLiveMode`, `enableVisualEditing` |
| **Svelte** | `@sanity/svelte-loader` | `createRequestHandler`, `useLiveMode`, `enableVisualEditing` |
| **Nuxt** | `@nuxtjs/sanity` | Automatic via module config (`visualEditing: {}`) |
| **Astro** | `@sanity/astro` | `sanity({ useCdn: false, stega: true })` |
@@ -0,0 +1,37 @@
---
name: seo-aeo-best-practices
description: SEO and AEO best practices for metadata, Open Graph, sitemaps, robots.txt, hreflang, JSON-LD structured data, EEAT, and content optimized for search engines and AI answer surfaces. Use this skill when implementing page SEO, technical SEO, schema markup, international SEO, AI-overview readiness, or improving content for Google, ChatGPT, Perplexity, and similar assistants.
---
# SEO & AEO Best Practices
Principles for optimizing content for both traditional search engines (SEO) and AI-powered answer engines (AEO). Includes Google's EEAT guidelines and structured data implementation.
## When to Apply
Reference these guidelines when:
- Implementing metadata and Open Graph tags
- Creating sitemaps and robots.txt
- Adding JSON-LD structured data
- Optimizing content for featured snippets
- Preparing content for AI assistants (ChatGPT, Perplexity, etc.)
- Evaluating content quality using EEAT principles
## Core Concepts
### SEO (Search Engine Optimization)
Optimizing content to rank well in traditional search results (Google, Bing).
### AEO (Answer Engine Optimization)
Optimizing content to be selected as authoritative answers by AI systems.
### EEAT (Experience, Expertise, Authoritativeness, Trustworthiness)
Google's framework for evaluating content quality.
## References
Start with the one reference that matches the task, such as technical SEO, structured data, EEAT, or AI-answer readiness. See `references/` for detailed guidance:
- `references/eeat-principles.md` — EEAT implementation and author schema
- `references/structured-data.md` — JSON-LD patterns (Article, FAQ, Breadcrumb, Product)
- `references/technical-seo.md` — Technical SEO checklist (metadata, sitemaps, hreflang, robots.txt)
- `references/aeo-considerations.md` — AI/AEO considerations (AI Overviews, crawler management)
@@ -0,0 +1,159 @@
# AI/AEO Considerations
Answer Engine Optimization (AEO) prepares content to be selected as authoritative answers by AI systems like ChatGPT, Perplexity, Google AI Overviews, and Bing Copilot.
## How AI Selects Answers
AI systems evaluate content based on:
1. **Clarity:** Is the answer direct and easy to extract?
2. **Authority:** Is the source trustworthy?
3. **Comprehensiveness:** Does it fully address the question?
4. **Recency:** Is the information up to date?
5. **Structure:** Can the AI parse and understand it?
## Content Structure for AI
### Direct Answers First
Lead with the answer, then explain.
**Bad:**
> The history of JavaScript dates back to 1995 when Brendan Eich... [500 words later] ...JavaScript runs in the browser.
**Good:**
> JavaScript is a programming language that runs in web browsers. It was created in 1995 by Brendan Eich...
### Clear Headings
Use descriptive H2/H3 headings that match user questions.
**Bad:** "Overview" → "Details" → "More Information"
**Good:** "What is X?" → "How does X work?" → "When should you use X?"
### Lists and Tables
AI extracts structured information more easily than prose.
```markdown
## Benefits of Structured Content
- **Reusability:** Use content across channels
- **Flexibility:** Change presentation without changing content
- **Scalability:** Manage large content volumes
```
### FAQ Format
Question-answer pairs are ideal for AI extraction.
```typescript
// Schema for AI-friendly FAQs
defineType({
name: 'faq',
type: 'document',
fields: [
defineField({ name: 'question', type: 'string' }),
defineField({ name: 'answer', type: 'text' }),
defineField({ name: 'category', type: 'reference', to: [{ type: 'faqCategory' }] }),
]
})
```
## Technical Implementation
### Structured Data (Critical)
JSON-LD helps AI understand content type and relationships.
```typescript
// FAQ structured data
const faqSchema = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map(faq => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: {
"@type": "Answer",
text: faq.answer
}
}))
}
```
### Canonical Content
Ensure AI finds your authoritative version, not copies.
- Set canonical URLs
- Avoid duplicate content across pages
- Use `rel="canonical"` for syndicated content
### Freshness Signals
AI systems prefer current information.
- Display publish and update dates prominently
- Update content regularly with substantive changes (superficial updates like changing dates without meaningful edits can be counterproductive)
- Use `dateModified` in structured data
## Content Quality Signals
### Author Credentials
AI systems increasingly check author authority.
- Display author name and credentials
- Link to author profiles
- Include author structured data
### Citations and Sources
Linking to authoritative sources increases trust.
- Cite primary sources
- Link to studies, documentation, official sources
- Avoid circular citations (sites citing each other)
### Comprehensive Coverage
AI prefers content that fully answers questions.
- Cover related questions users might have
- Include definitions for technical terms
- Address common misconceptions
## Google AI Overviews
Google's AI Overviews (formerly SGE) now appear in many search results. To optimize:
- **Be the cited source:** AI Overviews cite specific pages. Concise, authoritative answers increase citation likelihood.
- **Structure for extraction:** Use clear headings, direct answers, and lists that AI can easily parse.
- **Cover follow-up questions:** AI Overviews often address related queries. Anticipate and answer them on the same page or link to dedicated pages.
- **Monitor in Search Console:** Google Search Console provides data on AI Overview impressions and clicks.
## AI Crawler Management
Make conscious decisions about which AI systems can crawl your content:
- **robots.txt directives:** Use `User-agent: GPTBot`, `ClaudeBot`, `PerplexityBot`, `Google-Extended` to control access.
- **Allowing crawlers** increases chances of being cited as a source in AI responses.
- **Blocking crawlers** prevents content from being used in AI training (but may reduce AI citations).
- Review your policy regularly — this is one of the most actively evolving areas of SEO.
## Measuring AEO Success
### Monitor AI Mentions
Track when AI assistants cite your content:
- Use Google Search Console's AI Overview data for impression and click tracking
- Monitor referral traffic from AI platforms (Perplexity, ChatGPT, Bing Copilot)
- Search for your brand + "according to" in AI assistants
- Consider third-party AEO tracking tools for comprehensive monitoring
### Track Zero-Click Queries
If AI answers questions directly, traditional rankings matter less.
### Featured Snippet Capture
Featured snippets often become AI answers. Track which you own.
## AEO vs SEO Balance
AEO and SEO largely align—quality content serves both. Key differences:
| Aspect | SEO Focus | AEO Focus |
|--------|-----------|-----------|
| Goal | Rank on page 1 | Be THE answer |
| Format | Varies | Direct, structured |
| Length | Often longer | Concise + comprehensive |
| Links | Link building | Source citations |
@@ -0,0 +1,127 @@
# EEAT Principles
Google's EEAT framework (Experience, Expertise, Authoritativeness, Trustworthiness) guides how content quality is evaluated. This applies to both SEO rankings and AI answer selection.
## The Four Pillars
### Experience
First-hand or life experience with the topic.
**Signals:**
- Personal anecdotes and case studies
- "I tested this" content
- Real-world results and screenshots
- User-generated reviews
**Implementation:**
- Include author bios with relevant experience
- Add "About the Author" sections
- Feature customer testimonials
- Show real examples, not just theory
### Expertise
Knowledge and skill in the subject area.
**Signals:**
- Credentials and qualifications
- Depth of content coverage
- Technical accuracy
- Citations to authoritative sources
**Implementation:**
- Display author credentials
- Link to primary sources
- Cover topics comprehensively
- Keep content technically accurate and updated
### Authoritativeness
Recognition as a go-to source in the field.
**Signals:**
- Backlinks from respected sites
- Mentions in industry publications
- Social proof and follower counts
- Brand recognition
**Implementation:**
- Build thought leadership content
- Contribute to industry publications
- Maintain consistent publishing
- Develop recognizable brand voice
### Trustworthiness
Accuracy, transparency, and legitimacy.
**Signals:**
- Clear authorship and contact info
- Accurate, fact-checked content
- Secure website (HTTPS)
- Privacy policy and terms
**Implementation:**
- Display clear author attribution
- Include publication and update dates
- Provide contact information
- Use HTTPS and maintain security
## Sanity Implementation
```typescript
// Author schema with EEAT signals
defineType({
name: 'author',
type: 'document',
fields: [
defineField({ name: 'name', type: 'string' }),
defineField({ name: 'role', type: 'string' }),
defineField({ name: 'bio', type: 'text' }),
defineField({ name: 'credentials', type: 'array', of: [{ type: 'string' }] }),
defineField({ name: 'image', type: 'image' }),
// sameAs: used for schema.org Person structured data output
defineField({ name: 'sameAs', type: 'array', of: [{ type: 'url' }],
description: 'Canonical profile URLs (LinkedIn, Twitter, etc.) for schema.org Person'
}),
// socialLinks: used for display purposes (platform icons, labels)
defineField({
name: 'socialLinks',
type: 'array',
of: [{ type: 'object', fields: [
defineField({ name: 'platform', type: 'string' }),
defineField({ name: 'url', type: 'url' })
]}],
description: 'Social links for display in the UI. Use sameAs for structured data output.'
}),
]
})
// Content with EEAT metadata
defineType({
name: 'post',
fields: [
defineField({ name: 'author', type: 'reference', to: [{ type: 'author' }] }),
defineField({ name: 'publishedAt', type: 'datetime' }),
defineField({ name: 'updatedAt', type: 'datetime' }),
defineField({
name: 'reviewedBy',
type: 'reference',
to: [{ type: 'author' }],
description: 'Expert reviewer for fact-checking'
}),
defineField({
name: 'sources',
type: 'array',
of: [{ type: 'url' }],
description: 'Citations and references'
}),
]
})
```
## YMYL Considerations
"Your Money or Your Life" topics (health, finance, legal, safety) require extra EEAT rigor:
- Medical content reviewed by healthcare professionals
- Financial advice from certified experts
- Legal content reviewed by attorneys
- Clear disclaimers where appropriate
@@ -0,0 +1,183 @@
# Structured Data (JSON-LD)
Structured data helps search engines and AI understand your content. JSON-LD is the recommended format.
## Why Structured Data Matters
- **Rich snippets:** Enhanced search result appearance
- **Knowledge panels:** Featured information boxes
- **AI training:** Better content understanding
- **Voice search:** Answer selection for voice queries
## Common Schema Types
### Article / Blog Post
```typescript
import { Article, WithContext } from 'schema-dts'
const articleSchema: WithContext<Article> = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.excerpt,
image: post.image?.url,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
"@type": "Person",
name: post.author.name,
url: post.author.url
},
publisher: {
"@type": "Organization",
name: "Your Company",
logo: {
"@type": "ImageObject",
url: "https://example.com/logo.png"
}
}
}
```
### FAQ Page
```typescript
import { FAQPage, WithContext } from 'schema-dts'
const faqSchema: WithContext<FAQPage> = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map(faq => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: {
"@type": "Answer",
text: faq.answer // Plain text, use pt::text() in GROQ
}
}))
}
```
### Organization
```typescript
import { Organization, WithContext } from 'schema-dts'
const orgSchema: WithContext<Organization> = {
"@context": "https://schema.org",
"@type": "Organization",
name: "Your Company",
url: "https://example.com",
logo: "https://example.com/logo.png",
sameAs: [
"https://twitter.com/company",
"https://linkedin.com/company/company"
],
contactPoint: {
"@type": "ContactPoint",
telephone: "+1-555-555-5555",
contactType: "customer service"
}
}
```
### Product
```typescript
import { Product, WithContext } from 'schema-dts'
const productSchema: WithContext<Product> = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: product.images,
offers: {
"@type": "Offer",
price: product.price,
priceCurrency: "USD",
availability: "https://schema.org/InStock"
},
aggregateRating: product.rating ? {
"@type": "AggregateRating",
ratingValue: product.rating.average,
reviewCount: product.rating.count
} : undefined
}
```
### Breadcrumb
```typescript
import { BreadcrumbList, WithContext } from 'schema-dts'
const breadcrumbSchema: WithContext<BreadcrumbList> = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: breadcrumbs.map((crumb, index) => ({
"@type": "ListItem",
position: index + 1, // schema.org positions are 1-based
name: crumb.title,
item: `https://example.com${crumb.path}`
}))
}
```
## Combining Multiple Schemas (@graph)
Real-world pages often need multiple schema types. Use `@graph` to combine them. The `@context` is defined once at the top level — omit it from individual schema generators when used inside `@graph`:
```typescript
const pageSchema = {
"@context": "https://schema.org",
"@graph": [
generateArticleSchema(post), // No @context needed here
generateBreadcrumbSchema(breadcrumbs),
generateOrganizationSchema(),
]
}
```
## Implementation in Next.js
```typescript
// Component to render JSON-LD
// Ensure data comes from trusted sources (your CMS).
// If data could contain user-generated content, strip HTML tags
// and escape special characters before passing to JSON.stringify.
function JsonLd({ data }: { data: WithContext<Thing> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
)
}
// Usage in page
export default function PostPage({ post }) {
return (
<>
<JsonLd data={generateArticleSchema(post)} />
<article>...</article>
</>
)
}
```
## GROQ for Plain Text
Structured data often needs plain text, not rich text:
```groq
*[_type == "faq"]{
question,
"answer": pt::text(answerRichText) // Convert Portable Text to plain string
}
```
## Testing Tools
- [Google Rich Results Test](https://search.google.com/test/rich-results)
- [Schema.org Validator](https://validator.schema.org/)
@@ -0,0 +1,188 @@
# Technical SEO Checklist
Essential technical SEO elements for modern web applications.
## Table of Contents
- Metadata
- Sitemaps
- Canonical URLs
- Redirects
- Performance
- Robots.txt
- International SEO
## Metadata
### Title Tags
- Unique per page
- 50-60 characters
- Primary keyword near the beginning
- Brand name at the end (optional)
### Meta Descriptions
- Unique per page
- 150-160 characters
- Include call-to-action
- Contain relevant keywords
### Open Graph
```html
<meta property="og:title" content="Page Title" />
<meta property="og:description" content="Description" />
<meta property="og:image" content="https://example.com/image.jpg" />
<meta property="og:url" content="https://example.com/page" />
<meta property="og:type" content="article" />
```
### Sanity + Next.js Implementation
```typescript
export async function generateMetadata({ params }): Promise<Metadata> {
const { data } = await sanityFetch({
query: PAGE_QUERY,
stega: false, // Critical: no stega in metadata
})
return {
title: data.seo?.title || data.title,
description: data.seo?.description,
openGraph: {
images: data.seo?.image ? [{
url: urlFor(data.seo.image).width(1200).height(630).url(),
width: 1200,
height: 630,
}] : [],
},
robots: data.seo?.noIndex ? 'noindex' : undefined,
}
}
```
## Sitemaps
Dynamic sitemap from CMS content:
```typescript
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const pages = await client.fetch(`
*[_type in ["page", "post"] && defined(slug.current) && seo.noIndex != true]{
"url": select(
_type == "page" => "/" + slug.current,
_type == "post" => "/blog/" + slug.current
),
_updatedAt
}
`)
return pages.map(page => ({
url: `https://example.com${page.url}`,
lastModified: new Date(page._updatedAt),
// Note: changeFrequency and priority are largely ignored by Google
// but may be used by other search engines
}))
}
```
## Canonical URLs
Prevent duplicate content issues:
```typescript
export async function generateMetadata({ params }): Promise<Metadata> {
return {
alternates: {
canonical: `https://example.com/${params.slug}`,
},
}
}
```
## Redirects
CMS-managed redirects:
```typescript
// next.config.ts
async redirects() {
const redirects = await client.fetch(`
*[_type == "redirect" && isEnabled == true]{
source,
destination,
permanent
}
`)
return redirects
}
```
## Performance
[Core Web Vitals](https://web.dev/articles/defining-core-web-vitals-thresholds) impact rankings:
- **LCP (Largest Contentful Paint):** < 2.5s
- **INP (Interaction to Next Paint):** < 200ms
- **CLS (Cumulative Layout Shift):** < 0.1
### Image Optimization (Next.js example)
- Use `next/image` with Sanity URL builder
- Serve WebP/AVIF formats
- Implement LQIP blur placeholders
- Set explicit dimensions
### Font Loading (Next.js example)
```typescript
// Prevent layout shift
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], display: 'swap' })
```
## Robots.txt
```
# public/robots.txt
User-agent: *
Allow: /
Disallow: /api/
Disallow: /studio/
# AI crawlers — allow or block based on your content strategy
# Uncomment to block specific AI crawlers:
# User-agent: GPTBot
# Disallow: /
# User-agent: ClaudeBot
# Disallow: /
# User-agent: PerplexityBot
# Disallow: /
# User-agent: Google-Extended
# Disallow: /
Sitemap: https://example.com/sitemap.xml
```
**AI crawler considerations:** Decide whether AI training crawlers should access your content. Blocking `Google-Extended` prevents AI training use while still allowing Google Search indexing. Review your policy regularly as this landscape evolves.
## International SEO (hreflang)
For multi-language sites, implement hreflang tags to indicate language/region variants:
```typescript
export async function generateMetadata({ params }: { params: Promise<{ lang: string; slug: string }> }): Promise<Metadata> {
const { lang, slug } = await params
return {
alternates: {
canonical: `https://example.com/${lang}/${slug}`,
languages: {
'en': `https://example.com/en/${slug}`,
'de': `https://example.com/de/${slug}`,
'x-default': `https://example.com/en/${slug}`,
},
},
}
}
```
Include all language variants in sitemaps with `hreflang` annotations for proper indexing.
-2
View File
@@ -2,8 +2,6 @@ node_modules
dist
.astro
public/admin
.env
.env.*
.git
.gitignore
*.md
+9
View File
@@ -0,0 +1,9 @@
# Sanity CMS Configuration
# Get your project ID from https://sanity.io/manage
PUBLIC_SANITY_PROJECT_ID=p49jotqu
PUBLIC_SANITY_DATASET=production
# Only needed when running the migration script (scripts/migrate-to-sanity.mjs)
# Create a write token at https://sanity.io/manage → API → Tokens
# Revoke after migration is complete.
SANITY_API_TOKEN=skQ3tzSF8tp5ETeDq26zlgYAoxv50L4D37uITONtQ0efe4wHxjH64NrauljoyucMGy0F58fOXmUbQXh7C0glLFcD0PUd0tVRwzifuuLYvlZmX0MKOo6aZB2U2P5ISydRNzKd0sPGyMjxRo47psGb3wCtzT0Xcv8ByMVOlqnn35bc6uDj7Afl
-7
View File
@@ -1,7 +0,0 @@
# TinaCMS Configuration
# For local development with Git-based workflow, these can be left empty
# For TinaCMS Cloud, get these values from https://app.tina.io
TINA_CLIENT_ID=
TINA_TOKEN=
TINA_BRANCH=main
+1 -14
View File
@@ -39,16 +39,6 @@ jobs:
${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}:${{ steps.tag.outputs.sha }}
${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}:latest
- name: Build and push backend image
uses: docker/build-push-action@v5
with:
context: .
target: backend
push: true
tags: |
${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}-tina:${{ steps.tag.outputs.sha }}
${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}-tina:latest
- name: Update image tags in gitops repo
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
@@ -63,10 +53,7 @@ jobs:
sed -i \
"s|image: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}:.*|image: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}:${SHA}|" \
"apps/${{ env.APP }}/deployment.yaml"
sed -i \
"s|image: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}-tina:.*|image: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}-tina:${SHA}|" \
"apps/${{ env.APP }}/tina-backend-deployment.yaml"
git add "apps/${{ env.APP }}/deployment.yaml" "apps/${{ env.APP }}/tina-backend-deployment.yaml"
git add "apps/${{ env.APP }}/deployment.yaml"
git diff --cached --quiet && echo "No changes, skipping commit." && exit 0
git commit -m "ci: update ${{ env.APP }} to ${SHA}"
git push origin main
-1
View File
@@ -16,7 +16,6 @@ pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
+11 -21
View File
@@ -1,26 +1,16 @@
# Shared build: generates /dist (Astro site + admin SPA) and tina/__generated__
FROM node:24 AS builder
# Build: generates /dist (Astro SSR build + Sanity Studio SPA)
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --legacy-peer-deps
RUN npm ci
COPY . .
ENV TINA_PUBLIC_IS_LOCAL=true
RUN npm run build:docker
RUN npm run build
# Frontend: Nginx serving the static site + TinaCMS admin SPA
FROM nginx:alpine AS frontend
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
# Backend: TinaCMS self-hosted API server
FROM node:24 AS backend
# Server: Node.js running the Astro SSR server
FROM node:22-alpine AS frontend
WORKDIR /app
COPY package*.json ./
RUN npm ci --legacy-peer-deps --omit=dev
COPY backend/ ./backend/
COPY --from=builder /app/tina/__generated__ ./tina/__generated__
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "backend/index.js"]
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
ENV PORT=80
EXPOSE 80
CMD ["node", "./dist/server/entry.mjs"]
-238
View File
@@ -1,238 +0,0 @@
# Content Editor Guide for Theater Ziefen Website
This guide is for non-technical team members who will be managing the website content.
## 📋 Quick Start
### What You Need
- The development server running (ask your developer to set this up)
- A web browser
- Basic knowledge of writing and formatting text
### Accessing the Editor
1. Open your web browser
2. Go to: `http://localhost:4321/admin`
3. You'll see the TinaCMS admin panel
## ✍️ Creating and Editing Blog Posts
### Creating a New Blog Post
1. **Navigate to Blog Posts**
- In the TinaCMS admin panel, click on "Blog Posts" in the left sidebar
2. **Create New Post**
- Click the "Create New" button
- You'll see a form with several fields
3. **Fill in the Post Details**
- **Title**: Enter your blog post title (e.g., "Rehearsal Updates for March")
- **Publication Date**: Click to select the date (usually today's date)
- **Excerpt**: Write a short summary (2-3 sentences) - this appears on the blog listing page
- **Cover Image**: (Optional) Click "Upload" to add a featured image
4. **Write Your Content**
- Use the rich text editor to write your blog post
- Formatting options available:
- **Bold**: Select text and click B
- **Italic**: Select text and click I
- **Headings**: Use ## for main headings, ### for subheadings
- **Lists**: Click the bullet or numbered list icons
- **Links**: Select text, click the link icon, enter URL
5. **Save Your Post**
- Click "Save" in the top right
- Your post will appear on the website immediately!
### Editing an Existing Blog Post
1. Go to "Blog Posts" in the sidebar
2. Click on the post you want to edit
3. Make your changes
4. Click "Save"
### Deleting a Blog Post
1. Go to "Blog Posts" in the sidebar
2. Click on the post you want to delete
3. Click the "Delete" button
4. Confirm the deletion
## 📄 Editing Static Pages
### Editing the About Page
1. In the TinaCMS admin panel, click on "Pages" in the left sidebar
2. Select "About Theater Ziefen"
3. Edit the content using the rich text editor
4. Click "Save"
### Content Tips for Pages
- **Keep it concise**: Visitors typically scan pages, so use short paragraphs
- **Use headings**: Break up content with headings (## Main Heading, ### Subheading)
- **Add lists**: Bullet points make information easy to scan
- **Include calls to action**: Encourage visitors to check the blog, contact you, etc.
## 🖼️ Working with Images
### Adding Images to Blog Posts
1. In the blog post editor, find the "Cover Image" field
2. Click "Upload"
3. Select your image file (JPG or PNG recommended)
4. The image will be uploaded and displayed
### Image Best Practices
- **Size**: Keep images under 2MB for fast loading
- **Dimensions**: 1200x630 pixels works well for blog post covers
- **Format**: Use JPG for photos, PNG for graphics with text
- **File names**: Use descriptive names (e.g., `rehearsal-march-2026.jpg` instead of `IMG_1234.jpg`)
## 📝 Formatting Tips
### Markdown Basics
The editor uses Markdown, a simple formatting language:
```
# Heading 1 (largest)
## Heading 2
### Heading 3
**Bold text**
*Italic text*
- Bullet point 1
- Bullet point 2
1. Numbered item 1
2. Numbered item 2
[Link text](https://example.com)
```
### Writing Style Guidelines
**For Blog Posts:**
- Use a conversational, friendly tone
- Include dates for time-specific information
- Add personal touches and behind-the-scenes details
- End with a call to action or question
**For Static Pages:**
- Be clear and informative
- Use headings to organize information
- Keep paragraphs short (3-4 lines max)
- Update regularly with current information
## 🔄 Publishing Workflow
### How Changes Go Live
1. **You save your changes** in the TinaCMS editor
2. **Changes are saved to Git** automatically
3. **GitHub receives the changes**
4. **Netlify deploys the updated site** (takes 2-3 minutes)
5. **Your changes are live!**
### Checking Your Published Changes
1. Wait 2-3 minutes after saving
2. Visit the live website
3. Refresh your browser to see the changes
## ⚠️ Common Issues and Solutions
### Problem: Can't access `/admin`
**Solution:** Make sure the development server is running. Ask your developer to start it with `npm run dev`.
### Problem: Changes not appearing
**Solution:**
1. Make sure you clicked "Save"
2. Wait a few minutes for deployment
3. Hard refresh your browser (Ctrl+F5 or Cmd+Shift+R)
4. Check if you're viewing the correct URL (development vs. production)
### Problem: Image won't upload
**Solution:**
1. Check file size (should be under 5MB)
2. Make sure it's a supported format (JPG, PNG, GIF)
3. Try renaming the file to remove special characters
### Problem: Formatting looks weird
**Solution:**
1. Try removing all formatting and reapplying it
2. Make sure you're using valid Markdown syntax
3. Preview your changes before saving
## 📅 Content Planning Tips
### Blog Post Ideas
- Rehearsal updates and progress reports
- Cast and crew spotlights
- Behind-the-scenes photos and stories
- Production timeline updates
- Ticket sales announcements (when ready)
- Event announcements
- Community involvement opportunities
- Historical context about the production
### Posting Frequency
- **Minimum**: 1-2 posts per month to keep the site active
- **Ideal**: 1 post per week during active production
- **Special occasions**: Extra posts for major announcements
### Content Calendar
Create a simple calendar with:
- Upcoming post topics
- Important dates to announce
- Seasonal content (holidays, seasons)
- Milestone celebrations
## 🎯 SEO Tips (Optional but Helpful)
### Make Your Content Searchable
1. **Use descriptive titles**: Instead of "Update", use "March 2026 Rehearsal Schedule"
2. **Write good excerpts**: Include key information in the first 2-3 sentences
3. **Use relevant keywords**: Include terms people might search for
4. **Add alt text to images**: Describe what's in the image
## 🆘 Getting Help
### If You Need Assistance
1. **Check this guide first**
2. **Ask your developer** for technical issues
3. **Consult the README.md** for more detailed information
4. **Visit TinaCMS documentation**: https://tina.io/docs
### Quick Reference
- Admin Panel: `http://localhost:4321/admin`
- Live Website: (your website URL)
- Save Button: Top right corner of editor
- Preview: View your page before saving
## 📞 Support Contacts
- **Technical Issues**: [Developer contact]
- **Content Questions**: [Content manager contact]
- **General Questions**: info@theater-ziefen.ch
---
**Remember**: Don't be afraid to experiment! You can always undo changes or ask for help. The system is designed to be user-friendly, so feel free to explore and learn as you go.
Happy editing! 🎭
+22 -3
View File
@@ -1,14 +1,33 @@
// @ts-check
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import { loadEnv } from 'vite';
import sanity from '@sanity/astro';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
import node from '@astrojs/node';
const env = loadEnv(process.env.NODE_ENV ?? 'development', process.cwd(), '');
// https://astro.build/config
export default defineConfig({
integrations: [mdx()],
output: 'server',
integrations: [
sanity({
projectId: env.PUBLIC_SANITY_PROJECT_ID,
dataset: env.PUBLIC_SANITY_DATASET ?? 'production',
studioBasePath: '/studio',
useCdn: false,
}),
react(),
],
vite: {
plugins: [tailwindcss()],
},
adapter: node({
mode: 'standalone',
}),
});
-50
View File
@@ -1,50 +0,0 @@
import express from 'express'
import { TinaNodeBackend, LocalBackendAuthProvider, createDatabase, createLocalDatabase } from '@tinacms/datalayer'
import { AuthJsBackendAuthProvider, TinaAuthJSOptions } from 'tinacms-authjs'
import { MongodbLevel } from 'mongodb-level'
import { GitHubProvider } from 'tinacms-gitprovider-github'
const isLocal = process.env.TINA_PUBLIC_IS_LOCAL === 'true'
const branch = process.env.GITEA_BRANCH || 'main'
const databaseClient = isLocal
? createLocalDatabase()
: createDatabase({
gitProvider: new GitHubProvider({
branch,
owner: process.env.GITEA_OWNER,
repo: process.env.GITEA_REPO,
token: process.env.GITEA_TOKEN,
octokitOptions: {
baseUrl: 'https://git.idealconnected.com/api/v1',
},
}),
databaseAdapter: new MongodbLevel({
collectionName: 'tinacms',
dbName: 'tinacms',
mongoUri: process.env.MONGODB_URI,
}),
})
const authProvider = isLocal
? LocalBackendAuthProvider()
: AuthJsBackendAuthProvider({
authOptions: TinaAuthJSOptions({
databaseClient,
secret: process.env.NEXTAUTH_SECRET,
}),
})
const tinaHandler = TinaNodeBackend({
authProvider,
databaseClient,
})
const app = express()
app.use(express.json())
// Mount at root — TinaNodeBackend routes /api/tina/* internally
app.all('*', (req, res) => tinaHandler(req, res))
const port = process.env.PORT || 3000
app.listen(port, () => console.log(`Tina backend running on port ${port}`))
-26
View File
@@ -1,26 +0,0 @@
---
title: Welcome to Theater Ziefen
date: 2026-01-28T00:00:00.000Z
excerpt: We're excited to announce our upcoming theatre production in 2029. Stay tuned for more updates!
coverImage: /images/theater-placeholder.jpg
---
# Welcome to Theater Ziefen
We are thrilled to welcome you to the official website of Theater Ziefen. Our theatre production is scheduled to take place in approximately three years, and we can't wait to share this journey with you.
## What to Expect
This blog will keep you updated on:
- Production developments and announcements
- Behind-the-scenes insights
- Cast and crew introductions
- Ticket information (coming soon)
- Event schedules
## Stay Connected
Make sure to check back regularly for updates, or subscribe to our newsletter to receive the latest news directly in your inbox.
We look forward to bringing you an unforgettable theatrical experience!
-27
View File
@@ -1,27 +0,0 @@
---
title: About Theater Ziefen
description: Learn more about our upcoming theatre production and the team behind it
---
# About Theater Ziefen
Theater Ziefen is an exciting theatrical production planned for 2029. Our mission is to bring exceptional live theatre to our community and create memorable experiences for audiences of all ages.
## Our Vision
We believe in the power of theatre to inspire, entertain, and bring people together. Our upcoming production will showcase local talent and creativity while delivering a professional, high-quality performance.
## The Production
Details about our production will be announced in the coming months. Stay tuned to our blog for updates on:
- Show dates and times
- Venue information
- Cast announcements
- Ticket sales
## Get Involved
Interested in being part of Theater Ziefen? We're always looking for passionate individuals to join our team. Whether you're interested in performing, helping backstage, or volunteering, we'd love to hear from you.
Contact us for more information about opportunities to get involved.
-92
View File
@@ -1,92 +0,0 @@
---
title: Freilichttheater Ziefen 2028
hero:
title_de: Freilichttheater in Ziefen
title_en: Open-Air Theater in Ziefen
subtitle_de: '2028 grosses Freilichtspiel geplant. Der Blick auf die Gesellschaft der damaligen Zeit aus der Sicht des in Ziefen aufgewachsenen späteren Pfarrers, Waisenvaters und Mundartautors… Jonas Breitenstein'
subtitle_en: 'A grand open-air play planned for 2028. A view of society of that time from the perspective of Jonas Breitenstein, who grew up in Ziefen and later became a pastor, orphanage father, and dialect author.'
projekt:
title_de: Das Projekt
title_en: The Project
content_de: ''
content_en: ''
team:
title_de: Wer steckt dahinter
title_en: Who's Behind It
content_de: ''
content_en: ''
organizations:
- name: Verein J.B.
role_de: Hauptverantwortlich für das Theaterprojekt
role_en: Main responsibility for the theater project
logo: /images/Frontispitz_SW_clean_web.png
url: 'https://jonas-breitenstein.ch/verein.html'
- name: Verein 4417
role_de: Mitorganisator
role_en: Co-organizer
logo: /images/img_medium.jpg
url: 'https://www.ziefen.ch/freizeit-kultur/vereine.html/87/association/23'
- name: Bürgergemeinde
role_de: Unterstützer
role_en: Supporter
logo: /images/Wappen Ziefen.png
url: 'https://bg-ziefen.ch/'
- name: Einwohnergemeinde
role_de: Unterstützer
role_en: Supporter
logo: /images/Wappen Ziefen.png
url: 'https://www.ziefen.ch/'
stueck:
title_de: Das Stück
title_en: The Play
content_de: ''
content_en: ''
mitwirkung:
title_de: Mitwirkung im Schauspiel
title_en: Participation in the Play
content_de: ''
content_en: ''
director_name: Danny Wehrmüller
director_email: director@theater-ziefen.ch
director_phone: +41 XX XXX XX XX
termine:
title_de: Termine
title_en: Schedule
events:
- title_de: Casting
title_en: Casting
date: Frühjahr 2027
description_de: Gruppencasting zum gegenseitigen Kennenlernen
description_en: Group casting to get to know each other
- title_de: Proben
title_en: Rehearsals
date: Ab Oktober 2027
description_de: Beginn der regelmässigen Proben
description_en: Start of regular rehearsals
- title_de: Intensive Proben
title_en: Intensive Rehearsals
date: Ab Mai 2028
description_de: 'WICHTIG: Keine Ferien oder längere Abwesenheit planen! Mit zwei Probewochenenden.'
description_en: 'IMPORTANT: Do not plan vacations or longer absences! Includes two rehearsal weekends.'
- title_de: Vorstellungen
title_en: Performances
date: Juni 2028
description_de: Acht bis neun Vorstellungen
description_en: Eight to nine performances
presse:
title_de: Presse
title_en: Press
content_de: ''
content_en: ''
gallery:
title_de: Foto Galerie
title_en: Photo Gallery
images: []
tickets:
title_de: Tickets
title_en: Tickets
content_de: ''
content_en: ''
ticket_url: ''
---
-10
View File
@@ -1,10 +0,0 @@
{
"users": [
{
"name": "Admin",
"email": "admin@theater-jb.ch",
"username": "admin",
"password": "changeme"
}
]
}
+11028 -20110
View File
File diff suppressed because it is too large Load Diff
+21 -20
View File
@@ -3,35 +3,36 @@
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "tinacms dev -c \"astro dev\"",
"dev": "astro dev",
"build": "astro check && astro build",
"build:tina": "tinacms build && astro check && astro build",
"build:docker": "tinacms build && astro build",
"preview": "astro preview",
"astro": "astro"
"astro": "astro",
"migrate": "node --env-file=.env scripts/migrate-to-sanity.mjs"
},
"dependencies": {
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "^5.0.4",
"@astrojs/node": "^10.1.1",
"@astrojs/react": "^5.0.4",
"@lucide/astro": "^1.14.0",
"@portabletext/to-html": "^5.0.2",
"@sanity/astro": "^3.4.0",
"@sanity/client": "^7.22.0",
"@sanity/image-url": "^2.1.1",
"@sanity/vision": "^3.0.0",
"@tailwindcss/vite": "^4.2.4",
"@tinacms/cli": "^2.2.5",
"@tinacms/datalayer": "^2.0.18",
"fs-extra": "^9.1.0",
"astro": "^6.2.1",
"express": "^5.2.1",
"mongodb-level": "^0.0.4",
"next": "^15.5.15",
"next-auth": "^4.24.14",
"node-addon-api": "^8.7.0",
"node-gyp": "^12.3.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"slate": "^0.124.1",
"slate-dom": "^0.124.1",
"slate-hyperscript": "^0.100.0",
"slate-react": "^0.124.0",
"react-is": "^19.0.0",
"sanity": "^5.24.0",
"styled-components": "^6.1.19",
"tailwindcss": "^4.2.4",
"tinacms": "^3.7.5",
"tinacms-authjs": "^21.0.5",
"tinacms-gitprovider-github": "^4.1.5",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"vite": "^7"
},
"devDependencies": {
"@portabletext/markdown": "^1.2.0"
}
}
+45
View File
@@ -0,0 +1,45 @@
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { visionTool } from '@sanity/vision'
import { schemaTypes } from './src/sanity/schemas'
export default defineConfig({
projectId: import.meta.env.PUBLIC_SANITY_PROJECT_ID,
dataset: import.meta.env.PUBLIC_SANITY_DATASET ?? 'production',
document: {
// Filter document actions
actions: (prev, context) => {
// For singleton documents like site settings
if (context.schemaType === 'theaterHomepage') {
// Only allow publish action (equivalent to ["update", "publish"])
return prev.filter(({action}) => action === 'publish')
}
return prev
},
// Prevent creating new documents of this type
newDocumentOptions: (prev, context) => {
return prev.filter(
(template) => template.templateId !== 'siteSettings'
)
}
},
plugins: [
structureTool({
structure: (S) =>
S.list()
.title('Content')
.items([
S.listItem()
.title('Theater Homepage')
.child(
S.document()
.schemaType('theaterHomepage')
.documentId('theaterHomepage')
),
]),
}),
visionTool(),
],
schema: { types: schemaTypes },
})
+41
View File
@@ -0,0 +1,41 @@
{
"version": 1,
"skills": {
"content-experimentation-best-practices": {
"source": "sanity-io/agent-toolkit",
"sourceType": "github",
"skillPath": "skills/content-experimentation-best-practices/SKILL.md",
"computedHash": "97c7d16a8a93362feb82ae569b5cf84e02845a2fb17d9a8cd528b96d76f304d3"
},
"content-modeling-best-practices": {
"source": "sanity-io/agent-toolkit",
"sourceType": "github",
"skillPath": "skills/content-modeling-best-practices/SKILL.md",
"computedHash": "9f0c62274cf8ccf41dd32fc2f2629f4c9a8e5a920826f30c2eb226d7e796b031"
},
"portable-text-conversion": {
"source": "sanity-io/agent-toolkit",
"sourceType": "github",
"skillPath": "skills/portable-text-conversion/SKILL.md",
"computedHash": "7c6ca2fc2ecb2d802deefeb6385ec121197d3fc6eff0da8f32eaeee74ba432ac"
},
"portable-text-serialization": {
"source": "sanity-io/agent-toolkit",
"sourceType": "github",
"skillPath": "skills/portable-text-serialization/SKILL.md",
"computedHash": "a4cec92378298c8ef502bf9698112f054d86fc03e814257fa07d6aaac12b9376"
},
"sanity-best-practices": {
"source": "sanity-io/agent-toolkit",
"sourceType": "github",
"skillPath": "skills/sanity-best-practices/SKILL.md",
"computedHash": "cc5b4bd65ddd554c2ba2faf14f11858e59b48f6e600c578551cc58cc81b3e084"
},
"seo-aeo-best-practices": {
"source": "sanity-io/agent-toolkit",
"sourceType": "github",
"skillPath": "skills/seo-aeo-best-practices/SKILL.md",
"computedHash": "edb99db85683ff99678fcb1bebc3a5653c3ea927eea9ee2534f19be86c7b8b81"
}
}
}
+96 -39
View File
@@ -1,29 +1,74 @@
---
// Navigation items for the theater homepage sections
const navItems = [
{ id: 'projekt', label_de: 'Das Projekt', label_en: 'The Project' },
{ id: 'team', label_de: 'Wer steckt dahinter', label_en: 'Who\'s Behind It' },
{ id: 'stueck', label_de: 'Das Stück', label_en: 'The Play' },
{ id: 'mitwirkung', label_de: 'Mitwirkung', label_en: 'Participation' },
{ id: 'termine', label_de: 'Termine', label_en: 'Schedule' },
{ id: 'presse', label_de: 'Presse', label_en: 'Press' },
{ id: 'gallery', label_de: 'Galerie', label_en: 'Gallery' },
{ id: 'tickets', label_de: 'Tickets', label_en: 'Tickets' },
{ id: 'projekt', label: 'Das Projekt' },
{ id: 'team', label: 'Wer steckt dahinter' },
{ id: 'stueck', label: 'Das Stück' },
{ id: 'mitwirkung', label: 'Mitwirkung' },
{ id: 'termine', label: 'Termine' },
{ id: 'presse', label: 'Presse' },
{ id: 'gallery', label: 'Galerie' },
{ id: 'tickets', label: 'Tickets' },
];
---
<nav class="anchor-nav bg-[#2b2b56] py-4 md:py-3 transition-all duration-300 z-[100] shadow-[0_2px_5px_rgba(0,0,0,0.1)] [&.sticky]:sticky [&.sticky]:top-0 [&.sticky]:shadow-[0_2px_10px_rgba(0,0,0,0.3)]" id="anchor-nav">
<nav class="max-md:sticky anchor-nav bg-[#2b2b56] py-4 md:py-3 transition-all duration-300 z-[100] shadow-[0_2px_5px_rgba(0,0,0,0.1)] [&.sticky]:sticky [&.sticky]:top-0 [&.sticky]:shadow-[0_2px_10px_rgba(0,0,0,0.3)]" id="anchor-nav">
<div class="max-w-[--max-width-container] mx-auto px-[--spacing-md]">
<ul class="list-none m-0 p-0 flex flex-wrap justify-center gap-2 md:gap-1">
<!-- Desktop nav -->
<ul class="list-none m-0 p-0 hidden md:flex flex-wrap justify-center gap-1">
{navItems.map((item) => (
<li class="m-0">
<a
href={`#${item.id}`}
class="nav-link block text-white no-underline py-2 px-4 md:py-1.5 md:px-3 rounded transition-all duration-300 text-sm md:text-[0.85rem] font-medium whitespace-nowrap hover:bg-white/20 hover:-translate-y-0.5 [&.active]:bg-white/30 [&.active]:font-bold"
class="nav-link block text-white no-underline py-1.5 px-3 rounded transition-all duration-300 text-[0.85rem] font-medium whitespace-nowrap hover:bg-white/20 hover:-translate-y-0.5 [&.active]:bg-white/30 [&.active]:font-bold"
data-section={item.id}
>
<span class="nav-label-de" data-lang="de">{item.label_de}</span>
<span class="nav-label-en" data-lang="en">{item.label_en}</span>
{item.label}
</a>
</li>
))}
</ul>
<!-- Mobile header row -->
<div class="flex md:hidden items-center justify-between px-2">
<span class="text-white/60 text-xs font-medium tracking-widest uppercase">Theater Ziefen</span>
<button
id="hamburger-btn"
class="text-white p-2 -mr-2 rounded hover:bg-white/20 transition-colors duration-200"
aria-label="Menü öffnen"
aria-expanded="false"
aria-controls="mobile-menu"
>
<!-- Hamburger icon -->
<svg id="hamburger-icon" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
<!-- Close icon -->
<svg id="close-icon" class="hidden" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<!-- Mobile dropdown -->
<div
id="mobile-menu"
class="md:hidden overflow-hidden max-h-0 transition-[max-height] duration-300 ease-in-out"
aria-hidden="true"
>
<ul class="list-none m-0 p-0 px-4 pb-3 pt-1 border-t border-white/20 mt-3">
{navItems.map((item) => (
<li class="m-0">
<a
href={`#${item.id}`}
class="mobile-nav-link nav-link flex items-center text-white no-underline py-3 px-3 rounded transition-all duration-200 text-base font-medium hover:bg-white/20 [&.active]:bg-white/30 [&.active]:font-bold"
data-section={item.id}
>
{item.label}
</a>
</li>
))}
@@ -42,22 +87,13 @@ const navItems = [
if (targetSection) {
const navHeight = document.querySelector('.anchor-nav')?.clientHeight || 0;
const targetPosition = targetSection.getBoundingClientRect().top + window.scrollY - navHeight - 20;
window.scrollTo({
top: targetPosition,
behavior: 'smooth'
});
window.scrollTo({ top: targetPosition, behavior: 'smooth' });
}
}
});
});
// Highlight active section on scroll
const observerOptions = {
root: null,
rootMargin: '-20% 0px -70% 0px',
threshold: 0
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
@@ -70,26 +106,47 @@ const navItems = [
});
}
});
}, observerOptions);
}, { root: null, rootMargin: '-20% 0px -70% 0px', threshold: 0 });
// Observe all sections
document.querySelectorAll('.theater-section').forEach(section => {
observer.observe(section);
document.querySelectorAll('.theater-section').forEach(section => observer.observe(section));
// Sticky nav on scroll
const nav = document.querySelector('.anchor-nav');
window.addEventListener('scroll', () => {
nav?.classList.toggle('sticky', window.scrollY > 400);
//console.log(window.scrollY);
});
// Make nav sticky on scroll
let lastScroll = 0;
const nav = document.querySelector('.anchor-nav');
// Mobile menu toggle
const hamburgerBtn = document.getElementById('hamburger-btn')!;
const mobileMenu = document.getElementById('mobile-menu')!;
const hamburgerIcon = document.getElementById('hamburger-icon')!;
const closeIcon = document.getElementById('close-icon')!;
window.addEventListener('scroll', () => {
const currentScroll = window.scrollY;
function openMenu() {
mobileMenu.style.maxHeight = mobileMenu.scrollHeight + 'px';
mobileMenu.setAttribute('aria-hidden', 'false');
hamburgerIcon.classList.add('hidden');
closeIcon.classList.remove('hidden');
hamburgerBtn.setAttribute('aria-expanded', 'true');
hamburgerBtn.setAttribute('aria-label', 'Menü schließen');
}
if (currentScroll > 400) {
nav?.classList.add('sticky');
} else {
nav?.classList.remove('sticky');
}
function closeMenu() {
mobileMenu.style.maxHeight = '0';
mobileMenu.setAttribute('aria-hidden', 'true');
hamburgerIcon.classList.remove('hidden');
closeIcon.classList.add('hidden');
hamburgerBtn.setAttribute('aria-expanded', 'false');
hamburgerBtn.setAttribute('aria-label', 'Menü öffnen');
}
lastScroll = currentScroll;
hamburgerBtn.addEventListener('click', () => {
hamburgerBtn.getAttribute('aria-expanded') === 'true' ? closeMenu() : openMenu();
});
// Close menu when a mobile link is clicked
document.querySelectorAll('.mobile-nav-link').forEach(link => {
link.addEventListener('click', () => closeMenu());
});
</script>
-55
View File
@@ -1,55 +0,0 @@
---
interface Props {
currentLang: 'de' | 'en';
}
const { currentLang } = Astro.props;
---
<div class="flex items-center gap-2">
<button
class="lang-btn bg-transparent border-none text-white text-base md:text-sm font-medium cursor-pointer p-2 transition-all duration-300 opacity-60 hover:opacity-100 hover:scale-110 data-[active=true]:opacity-100 data-[active=true]:font-bold data-[active=true]:underline"
data-lang="de"
data-active={currentLang === 'de'}
>
DE
</button>
<span class="text-white opacity-40">|</span>
<button
class="lang-btn bg-transparent border-none text-white text-base md:text-sm font-medium cursor-pointer p-2 transition-all duration-300 opacity-60 hover:opacity-100 hover:scale-110 data-[active=true]:opacity-100 data-[active=true]:font-bold data-[active=true]:underline"
data-lang="en"
data-active={currentLang === 'en'}
>
EN
</button>
</div>
<script>
// Initialize language from localStorage or default to 'de'
let currentLanguage = localStorage.getItem('language') || 'de';
// Apply language on page load
document.documentElement.setAttribute('data-language', currentLanguage);
updateActiveButton();
// Add click handlers to language buttons
document.querySelectorAll('.lang-btn').forEach(button => {
button.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const lang = target.getAttribute('data-lang');
if (lang) {
currentLanguage = lang;
localStorage.setItem('language', lang);
document.documentElement.setAttribute('data-language', lang);
updateActiveButton();
}
});
});
function updateActiveButton() {
document.querySelectorAll('.lang-btn').forEach(btn => {
const btnLang = btn.getAttribute('data-lang');
btn.setAttribute('data-active', (btnLang === currentLanguage).toString());
});
}
</script>
+5 -4
View File
@@ -1,6 +1,8 @@
---
import { urlFor } from '../sanity/imageUrl';
interface Props {
images: string[];
images: any[];
}
const { images } = Astro.props;
@@ -10,13 +12,12 @@ const { images } = Astro.props;
<div class="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-[--spacing-md] md:gap-[--spacing-sm] mt-[--spacing-lg]">
{images.map((image) => (
<div class="relative overflow-hidden rounded-lg shadow-[0_4px_10px_rgba(0,0,0,0.2)] transition-all duration-300 aspect-[4/3] hover:-translate-y-1 hover:shadow-[0_8px_20px_rgba(0,0,0,0.3)] group">
<img src={image} alt="Theater photo" loading="lazy" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" />
<img src={urlFor(image)} alt="Theater photo" loading="lazy" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" />
</div>
))}
</div>
) : (
<div class="text-center p-[--spacing-xl] bg-white/10 rounded-lg italic">
<p class="placeholder-de m-0 text-lg" data-lang="de">Fotos werden bald hinzugefügt. Besuchen Sie uns später wieder!</p>
<p class="placeholder-en m-0 text-lg" data-lang="en">Photos will be added soon. Check back later!</p>
<p class="m-0 text-lg">Fotos werden bald hinzugefügt. Besuchen Sie uns später wieder!</p>
</div>
)}
+3 -5
View File
@@ -1,19 +1,17 @@
---
interface Props {
id: string;
titleDe: string;
titleEn: string;
title: string;
variant?: 'light' | 'dark';
}
const { id, titleDe, titleEn, variant = 'light' } = Astro.props;
const { id, title, variant = 'light' } = Astro.props;
---
<section class={`theater-section py-12 px-4 md:py-20 md:px-16 scroll-mt-[100px] ${variant === 'light' ? 'bg-[#3d3d6f]' : 'bg-primary'} text-white`} id={id}>
<div class="max-w-7xl mx-auto">
<h2 class="font-heading text-4xl md:text-6xl mb-8 text-center font-normal text-white [text-shadow:2px_2px_4px_rgba(0,0,0,0.3)]">
<span class="title-de" data-lang="de">{titleDe}</span>
<span class="title-en" data-lang="en">{titleEn}</span>
{title}
</h2>
<div class="section-content text-lg md:text-base leading-relaxed">
<slot />
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="astro/client" />
/// <reference types="@sanity/astro/module" />
+5 -12
View File
@@ -10,7 +10,7 @@ const { title, description = "Theater Ziefen - Coming 2029" } = Astro.props;
---
<!doctype html>
<html lang="de" data-language="de">
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="description" content={description} />
@@ -19,25 +19,18 @@ const { title, description = "Theater Ziefen - Coming 2029" } = Astro.props;
<meta name="generator" content={Astro.generator} />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Great+Vibes&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Kaushan+Script&display=swap" rel="stylesheet">
<title>{title} | Theater Ziefen</title>
<script is:inline>
// Set language immediately before page renders
(function() {
const savedLang = localStorage.getItem('language') || 'de';
document.documentElement.setAttribute('data-language', savedLang);
})();
</script>
</head>
<body>
<main class="min-h-[calc(100vh-200px)]">
<main class="min-h-[calc(100vh-200px)] flex flex-col items-center *:w-full">
<slot />
</main>
<footer class="bg-[#2b2b56] text-white py-[--spacing-md] text-center mt-[--spacing-xl] border-t-2 border-white/10">
<div class="max-w-[--max-width-container] mx-auto px-[--spacing-md] md:px-[--spacing-md] px-[--spacing-sm]">
<div class="mx-auto px-[--spacing-md] md:px-[--spacing-md] px-[--spacing-sm]">
<p>&copy; {new Date().getFullYear()} Theater Ziefen. All rights reserved.</p>
<p class="mt-[--spacing-xs] text-[--color-accent] font-semibold opacity-90">Coming 2029</p>
<p class="mt- text-accent font-semibold opacity-90">Coming 2029</p>
</div>
</footer>
</body>
-120
View File
@@ -1,120 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
const { Content, frontmatter } = await import('../../content/pages/about.mdx');
---
<BaseLayout title={frontmatter.title} description={frontmatter.description}>
<div class="page-header">
<div class="container">
<h1>{frontmatter.title}</h1>
</div>
</div>
<div class="page-content">
<div class="container">
<div class="prose">
<Content />
</div>
</div>
</div>
</BaseLayout>
<style>
.page-header {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
color: white;
padding: 4rem 0 3rem;
text-align: center;
}
.page-header h1 {
font-size: 3rem;
margin: 0;
}
.page-content {
padding: 3rem 0;
}
.prose {
max-width: 800px;
margin: 0 auto;
font-size: 1.125rem;
line-height: 1.8;
color: #333;
}
.prose :global(h1) {
font-size: 2.5rem;
margin-top: 2.5rem;
margin-bottom: 1rem;
color: #2c3e50;
line-height: 1.2;
}
.prose :global(h2) {
font-size: 2rem;
margin-top: 2rem;
margin-bottom: 0.75rem;
color: #2c3e50;
line-height: 1.3;
}
.prose :global(h3) {
font-size: 1.5rem;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
color: #2c3e50;
}
.prose :global(p) {
margin-bottom: 1.5rem;
}
.prose :global(ul),
.prose :global(ol) {
margin-bottom: 1.5rem;
padding-left: 2rem;
}
.prose :global(li) {
margin-bottom: 0.5rem;
}
.prose :global(a) {
color: #8b0000;
text-decoration: underline;
}
.prose :global(a:hover) {
color: #c0392b;
}
.prose :global(strong) {
font-weight: 700;
color: #2c3e50;
}
@media (max-width: 768px) {
.page-header h1 {
font-size: 2.5rem;
}
.prose {
font-size: 1rem;
}
.prose :global(h1) {
font-size: 2rem;
}
.prose :global(h2) {
font-size: 1.5rem;
}
.prose :global(h3) {
font-size: 1.25rem;
}
}
</style>
-236
View File
@@ -1,236 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
export async function getStaticPaths() {
const allPosts = import.meta.glob<{ Content: any; frontmatter: Record<string, any> }>('../../../content/blog/*.mdx', { eager: true });
return Object.entries(allPosts).map(([path, post]) => {
const slug = path.split('/').pop()?.replace('.mdx', '') || '';
return {
params: { slug },
props: { post },
};
});
}
const { post } = Astro.props as { post: { Content: any; frontmatter: Record<string, any> } };
const { Content, frontmatter } = post;
---
<BaseLayout title={frontmatter.title} description={frontmatter.excerpt}>
<article class="blog-post">
<header class="post-header">
<div class="container">
<h1>{frontmatter.title}</h1>
<p class="post-meta">
<time datetime={frontmatter.date}>
{new Date(frontmatter.date).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
</p>
</div>
</header>
{frontmatter.coverImage && (
<div class="post-cover">
<img src={frontmatter.coverImage} alt={frontmatter.title} />
</div>
)}
<div class="post-content">
<div class="container">
<div class="prose">
<Content />
</div>
</div>
</div>
<footer class="post-footer">
<div class="container">
<a href="/blog" class="back-link">← Back to all posts</a>
</div>
</footer>
</article>
</BaseLayout>
<style>
.post-header {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
color: white;
padding: 4rem 0 3rem;
text-align: center;
}
.post-header h1 {
font-size: 3rem;
margin-bottom: 1rem;
line-height: 1.2;
}
.post-meta {
font-size: 1.1rem;
opacity: 0.9;
}
.post-cover {
width: 100%;
max-height: 500px;
overflow: hidden;
background-color: #f8f9fa;
}
.post-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.post-content {
padding: 3rem 0;
}
.prose {
max-width: 800px;
margin: 0 auto;
font-size: 1.125rem;
line-height: 1.8;
color: #333;
}
.prose :global(h1) {
font-size: 2.5rem;
margin-top: 2.5rem;
margin-bottom: 1rem;
color: #2c3e50;
line-height: 1.2;
}
.prose :global(h2) {
font-size: 2rem;
margin-top: 2rem;
margin-bottom: 0.75rem;
color: #2c3e50;
line-height: 1.3;
}
.prose :global(h3) {
font-size: 1.5rem;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
color: #2c3e50;
}
.prose :global(p) {
margin-bottom: 1.5rem;
}
.prose :global(ul),
.prose :global(ol) {
margin-bottom: 1.5rem;
padding-left: 2rem;
}
.prose :global(li) {
margin-bottom: 0.5rem;
}
.prose :global(a) {
color: #8b0000;
text-decoration: underline;
}
.prose :global(a:hover) {
color: #c0392b;
}
.prose :global(strong) {
font-weight: 700;
color: #2c3e50;
}
.prose :global(blockquote) {
border-left: 4px solid #8b0000;
padding-left: 1.5rem;
margin: 2rem 0;
font-style: italic;
color: #666;
}
.prose :global(code) {
background-color: #f8f9fa;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.prose :global(pre) {
background-color: #2c3e50;
color: white;
padding: 1.5rem;
border-radius: 6px;
overflow-x: auto;
margin: 2rem 0;
}
.prose :global(pre code) {
background-color: transparent;
padding: 0;
}
.prose :global(img) {
max-width: 100%;
height: auto;
border-radius: 6px;
margin: 2rem 0;
}
.post-footer {
padding: 2rem 0 3rem;
border-top: 1px solid #e1e8ed;
}
.back-link {
color: #8b0000;
text-decoration: none;
font-weight: 600;
font-size: 1.1rem;
}
.back-link:hover {
color: #c0392b;
}
@media (max-width: 768px) {
.post-header h1 {
font-size: 2rem;
}
.post-meta {
font-size: 1rem;
}
.prose {
font-size: 1rem;
}
.prose :global(h1) {
font-size: 2rem;
}
.prose :global(h2) {
font-size: 1.5rem;
}
.prose :global(h3) {
font-size: 1.25rem;
}
.post-cover {
max-height: 300px;
}
}
</style>
-184
View File
@@ -1,184 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
const allPostFiles = import.meta.glob<{ frontmatter: Record<string, any> }>('../../../content/blog/*.mdx', { eager: true });
const allPosts = Object.entries(allPostFiles).map(([path, post]) => ({
...post,
slug: path.split('/').pop()?.replace('.mdx', '') || ''
}));
const sortedPosts = allPosts.sort(
(a, b) => new Date(b.frontmatter.date).valueOf() - new Date(a.frontmatter.date).valueOf()
);
---
<BaseLayout title="Blog" description="Latest news and updates from Theater Ziefen">
<div class="blog-header">
<div class="container">
<h1>Blog</h1>
<p>Stay updated with the latest news and behind-the-scenes insights</p>
</div>
</div>
<div class="blog-content">
<div class="container">
{sortedPosts.length === 0 ? (
<p class="no-posts">No blog posts yet. Check back soon!</p>
) : (
<div class="posts-list">
{sortedPosts.map((post) => (
<article class="post-item">
{post.frontmatter.coverImage && (
<div class="post-image">
<img src={post.frontmatter.coverImage} alt={post.frontmatter.title} />
</div>
)}
<div class="post-content">
<h2>
<a href={`/blog/${post.slug}`}>
{post.frontmatter.title}
</a>
</h2>
<p class="post-meta">
<time datetime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
</p>
<p class="post-excerpt">{post.frontmatter.excerpt}</p>
<a href={`/blog/${post.slug}`} class="read-more">
Read full post →
</a>
</div>
</article>
))}
</div>
)}
</div>
</div>
</BaseLayout>
<style>
.blog-header {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
color: white;
padding: 4rem 0 3rem;
text-align: center;
}
.blog-header h1 {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.blog-header p {
font-size: 1.25rem;
opacity: 0.9;
}
.blog-content {
padding: 3rem 0;
}
.no-posts {
text-align: center;
font-size: 1.25rem;
color: #666;
padding: 4rem 0;
}
.posts-list {
display: flex;
flex-direction: column;
gap: 3rem;
max-width: 900px;
margin: 0 auto;
}
.post-item {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.post-item:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.post-image {
width: 100%;
height: 300px;
overflow: hidden;
}
.post-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.post-content {
padding: 2rem;
}
.post-content h2 {
font-size: 2rem;
margin-bottom: 0.75rem;
}
.post-content h2 a {
color: #2c3e50;
text-decoration: none;
}
.post-content h2 a:hover {
color: #8b0000;
}
.post-meta {
color: #999;
font-size: 0.95rem;
margin-bottom: 1rem;
}
.post-excerpt {
color: #666;
line-height: 1.7;
margin-bottom: 1.5rem;
font-size: 1.05rem;
}
.read-more {
color: #8b0000;
text-decoration: none;
font-weight: 600;
font-size: 1rem;
}
.read-more:hover {
color: #c0392b;
}
@media (max-width: 768px) {
.blog-header h1 {
font-size: 2.5rem;
}
.blog-header p {
font-size: 1.1rem;
}
.post-content h2 {
font-size: 1.5rem;
}
.post-image {
height: 200px;
}
}
</style>
-241
View File
@@ -1,241 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Contact" description="Get in touch with Theater Ziefen">
<div class="page-header">
<div class="container">
<h1>Contact Us</h1>
<p>We'd love to hear from you</p>
</div>
</div>
<div class="page-content">
<div class="container">
<div class="contact-content">
<div class="contact-info">
<h2>Get in Touch</h2>
<p>
Have questions about our upcoming production? Interested in getting involved?
We're here to help!
</p>
<div class="info-section">
<h3>Email</h3>
<p><a href="mailto:info@theater-ziefen.ch">info@theater-ziefen.ch</a></p>
</div>
<div class="info-section">
<h3>Follow Us</h3>
<p>Stay connected on social media for the latest updates and behind-the-scenes content.</p>
<div class="social-links">
<a href="#" class="social-link">Facebook</a>
<a href="#" class="social-link">Instagram</a>
<a href="#" class="social-link">Twitter</a>
</div>
</div>
<div class="info-section">
<h3>Newsletter</h3>
<p>
Subscribe to our newsletter to receive updates directly in your inbox.
We'll keep you informed about production progress, ticket sales, and special events.
</p>
</div>
</div>
<div class="contact-form">
<h2>Send us a Message</h2>
<form name="contact" method="POST" data-netlify="true">
<input type="hidden" name="form-name" value="contact" />
<div class="form-group">
<label for="name">Name *</label>
<input type="text" id="name" name="name" required />
</div>
<div class="form-group">
<label for="email">Email *</label>
<input type="email" id="email" name="email" required />
</div>
<div class="form-group">
<label for="subject">Subject *</label>
<input type="text" id="subject" name="subject" required />
</div>
<div class="form-group">
<label for="message">Message *</label>
<textarea id="message" name="message" rows="6" required></textarea>
</div>
<button type="submit" class="btn-submit">Send Message</button>
</form>
</div>
</div>
</div>
</div>
</BaseLayout>
<style>
.page-header {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
color: white;
padding: 4rem 0 3rem;
text-align: center;
}
.page-header h1 {
font-size: 3rem;
margin: 0 0 0.5rem 0;
}
.page-header p {
font-size: 1.25rem;
opacity: 0.9;
}
.page-content {
padding: 3rem 0;
}
.contact-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
max-width: 1000px;
margin: 0 auto;
}
.contact-info h2,
.contact-form h2 {
font-size: 2rem;
color: #2c3e50;
margin-bottom: 1.5rem;
}
.contact-info p {
color: #666;
line-height: 1.7;
margin-bottom: 1rem;
}
.info-section {
margin-top: 2rem;
}
.info-section h3 {
font-size: 1.25rem;
color: #2c3e50;
margin-bottom: 0.5rem;
}
.info-section a {
color: #8b0000;
text-decoration: none;
}
.info-section a:hover {
color: #c0392b;
text-decoration: underline;
}
.social-links {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.social-link {
padding: 0.5rem 1rem;
background-color: #2c3e50;
color: white !important;
border-radius: 4px;
text-decoration: none !important;
font-weight: 600;
transition: background-color 0.3s ease;
}
.social-link:hover {
background-color: #8b0000;
}
.contact-form {
background-color: #f8f9fa;
padding: 2rem;
border-radius: 8px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.5rem;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-family: inherit;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #8b0000;
}
.form-group textarea {
resize: vertical;
}
.btn-submit {
width: 100%;
padding: 1rem;
background-color: #8b0000;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease;
}
.btn-submit:hover {
background-color: #c0392b;
}
@media (max-width: 768px) {
.page-header h1 {
font-size: 2.5rem;
}
.page-header p {
font-size: 1.1rem;
}
.contact-content {
grid-template-columns: 1fr;
gap: 2rem;
}
.contact-info h2,
.contact-form h2 {
font-size: 1.5rem;
}
.social-links {
flex-direction: column;
}
}
</style>
+43 -97
View File
@@ -3,39 +3,15 @@ import BaseLayout from '../layouts/BaseLayout.astro';
import AnchorNav from '../components/AnchorNav.astro';
import TheaterSection from '../components/TheaterSection.astro';
import PhotoGallery from '../components/PhotoGallery.astro';
import { sanityClient } from '../sanity/client';
import { renderPortableText } from '../sanity/portableText';
import { urlFor } from '../sanity/imageUrl';
import { theaterHomepageQuery } from '../sanity/queries';
import { MapPin } from '@lucide/astro';
// Import theater homepage content
const theaterFiles = import.meta.glob('../../content/theater/*.mdx', { eager: true });
const theaterHomepage = Object.values(theaterFiles)[0] as any;
const content = theaterHomepage?.frontmatter || {};
export const prerender = false
// Helper function to render rich-text content
function renderRichText(richText: any, lang: 'de' | 'en'): string {
if (!richText || !richText.children) return '';
let html = '';
richText.children.forEach((child: any) => {
if (child.type === 'p') {
html += '<p>';
child.children.forEach((textNode: any) => {
if (textNode.type === 'text') {
html += textNode.text;
}
});
html += '</p>';
} else if (child.type === 'h3') {
html += '<h3>';
child.children.forEach((textNode: any) => {
if (textNode.type === 'text') {
html += textNode.text;
}
});
html += '</h3>';
}
});
return html;
}
const content = await sanityClient.fetch(theaterHomepageQuery) ?? {};
---
<BaseLayout title={content.title || 'Freilichttheater Ziefen 2028'}>
@@ -43,45 +19,37 @@ function renderRichText(richText: any, lang: 'de' | 'en'): string {
<AnchorNav />
<!-- Hero Section -->
<div class="bg-[#2b2b56] text-white py-12 pb-24 md:py-8 md:pb-16 relative overflow-hidden min-h-[500px] md:min-h-[400px]">
<div class="bg-[#2b2b56] text-white py-12 pb-24 md:py-8 md:pb-16 relative overflow-hidden min-h-[500px] md:min-h-[400px] md:max-w-[100rem]">
<div class="max-w-[--max-width-container] mx-auto px-[--spacing-md] md:px-[--spacing-sm]">
<div class="relative min-h-96 md:min-h-80">
<img src="/logo.svg" alt="Theater Ziefen Logo" class="absolute h-full right-2 md:right-10 opacity-95 z-[5] md:opacity-70" />
<div class="relative z-8 pt-16 pl-12 md:pt-24 md:pl-16 text-left md:max-w-full">
<h1 class="text-8xl md:text-5xl font-normal mb-6 md:mb-4 leading-[1.3] [text-shadow:2px_2px_4px_rgba(0,0,0,0.3)]">
<span class="title-de" data-lang="de">{content.hero?.title_de || 'Freilichttheater in Ziefen'}</span>
<span class="title-en" data-lang="en">{content.hero?.title_en || 'Open-Air Theater in Ziefen'}</span>
<div class="relative flex flex-col items-center md:items-start gap-2 z-8 text-center md:text-left md:pl-12 md:pl-16 min-h-96 md:min-h-80 ">
<h1 class="text-6xl md:text-8xl md:pt-24 md:h-auto font-normalmd:mb-4 leading-[1.3] [text-shadow:2px_2px_4px_rgba(0,0,0,0.3)]">
{content.hero?.title || 'Freilichttheater in Ziefen'}
</h1>
<p class="text-xl md:text-lg leading-relaxed mb-8 md:mb-6 opacity-95 max-w-2xl">
<span class="subtitle-de" data-lang="de">{content.hero?.subtitle_de || ''}</span>
<span class="subtitle-en" data-lang="en">{content.hero?.subtitle_en || ''}</span>
<img src="/logo.svg" alt="Theater Ziefen Logo" class="max-h-[30rem] md:absolute md:right-2 md:right-10 opacity-95 z-[5] md:opacity-70" />
<p class="text-xl md:text-lg leading-relaxed mb-8 md:mb-6 opacity-95 max-w-2xl ">
{content.hero?.subtitle || ''}
</p>
<div class="font-heading text-8xl md:text-6xl font-normal text-accent [text-shadow:3px_3px_6px_rgba(0,0,0,0.4)] mt-4">2028</div>
</div>
</div>
</div>
</div>
<!-- Section 1: Das Projekt -->
<TheaterSection
id="projekt"
titleDe={content.projekt?.title_de || 'Das Projekt'}
titleEn={content.projekt?.title_en || 'The Project'}
title={content.projekt?.title || 'Das Projekt'}
variant="light"
>
<div class="content-de" data-lang="de" set:html={renderRichText(content.projekt?.content_de, 'de')} />
<div class="content-en" data-lang="en" set:html={renderRichText(content.projekt?.content_en, 'en')} />
<div class="prose-measure" set:html={renderPortableText(content.projekt?.content)} />
</TheaterSection>
<!-- Section 2: Wer steckt dahinter -->
<TheaterSection
id="team"
titleDe={content.team?.title_de || 'Wer steckt dahinter'}
titleEn={content.team?.title_en || "Who's Behind It"}
title={content.team?.title || 'Wer steckt dahinter'}
variant="dark"
>
<div class="content-de" data-lang="de" set:html={renderRichText(content.team?.content_de, 'de')} />
<div class="content-en" data-lang="en" set:html={renderRichText(content.team?.content_en, 'en')} />
<div class="prose-measure" set:html={renderPortableText(content.team?.content)} />
{content.team?.organizations && content.team.organizations.length > 0 && (
<div class="grid grid-cols-1 md:grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-12 mt-16">
@@ -90,12 +58,11 @@ function renderRichText(richText: any, lang: 'de' | 'en'): string {
<Fragment>
{org.logo && (
<div class="mb-4 flex justify-center">
<img src={org.logo} alt={`${org.name} logo`} class="h-20 w-auto object-contain" />
<img src={urlFor(org.logo)} alt={`${org.name} logo`} class="h-20 w-auto object-contain" />
</div>
)}
<h3 class="text-3xl mb-2 text-white">{org.name}</h3>
<p class="org-role-de m-0 opacity-90" data-lang="de">{org.role_de}</p>
<p class="org-role-en m-0 opacity-90" data-lang="en">{org.role_en}</p>
<p class="org-role m-0 opacity-90">{org.role}</p>
</Fragment>
);
@@ -121,44 +88,33 @@ function renderRichText(richText: any, lang: 'de' | 'en'): string {
<!-- Section 3: Das Stück -->
<TheaterSection
id="stueck"
titleDe={content.stueck?.title_de || 'Das Stück'}
titleEn={content.stueck?.title_en || 'The Play'}
title={content.stueck?.title || 'Das Stück'}
variant="light"
>
<div class="content-de" data-lang="de" set:html={renderRichText(content.stueck?.content_de, 'de')} />
<div class="content-en" data-lang="en" set:html={renderRichText(content.stueck?.content_en, 'en')} />
<div class="prose-measure" set:html={renderPortableText(content.stueck?.content)} />
</TheaterSection>
<!-- Section 4: Mitwirkung -->
<TheaterSection
id="mitwirkung"
titleDe={content.mitwirkung?.title_de || 'Mitwirkung im Schauspiel'}
titleEn={content.mitwirkung?.title_en || 'Participation in the Play'}
title={content.mitwirkung?.title || 'Mitwirkung im Schauspiel'}
variant="dark"
>
<div class="content-de" data-lang="de" set:html={renderRichText(content.mitwirkung?.content_de, 'de')} />
<div class="content-en" data-lang="en" set:html={renderRichText(content.mitwirkung?.content_en, 'en')} />
<div class="prose-measure" set:html={renderPortableText(content.mitwirkung?.content)} />
{content.mitwirkung?.director_name && (
<div class="mt-16 p-12 bg-white/10 rounded-lg text-center">
<h3 class="text-3xl text-white mb-4">
<span data-lang="de">Kontakt Regisseur:</span>
<span data-lang="en">Contact Director:</span>
</h3>
<h3 class="text-3xl text-white mb-4">Kontakt Regisseur:</h3>
<p><strong>{content.mitwirkung.director_name}</strong></p>
{content.mitwirkung.director_email && (
<p>
<span data-lang="de">E-Mail:</span>
<span data-lang="en">Email:</span>
{' '}
<span>E-Mail:</span>
<a href={`mailto:${content.mitwirkung.director_email}`} class="text-accent no-underline hover:underline">{content.mitwirkung.director_email}</a>
</p>
)}
{content.mitwirkung.director_phone && (
<p>
<span data-lang="de">Telefon:</span>
<span data-lang="en">Phone:</span>
{' '}
<span>Telefon:</span>
<a href={`tel:${content.mitwirkung.director_phone}`} class="text-accent no-underline hover:underline">{content.mitwirkung.director_phone}</a>
</p>
)}
@@ -169,8 +125,7 @@ function renderRichText(richText: any, lang: 'de' | 'en'): string {
<!-- Section 5: Termine -->
<TheaterSection
id="termine"
titleDe={content.termine?.title_de || 'Termine'}
titleEn={content.termine?.title_en || 'Schedule'}
title={content.termine?.title || 'Termine'}
variant="light"
>
{content.termine?.events && content.termine.events.length > 0 && (
@@ -186,18 +141,15 @@ function renderRichText(richText: any, lang: 'de' | 'en'): string {
/>
</svg>
{content.termine.events.map((event: any, index: number) => (
<div class={`relative z-2 flex items-center gap-12 text-left ${(index + 1) % 2 === 0 ? 'flex-row-reverse text-right right-0' : 'left-0'}`}>
<div class="flex flex-col items-center min-w-30 max-w-60 md:min-w-auto md:self-start">
<div class="relative w-8 h-8 bg-accent rounded-full border-4 border-white shadow-[0_4px_10px_rgba(0,0,0,0.3)] after:content-[''] after:absolute after:-bottom-4 after:left-1/2 after:-translate-x-1/2 after:w-0 after:h-0 after:border-l-[10px] after:border-l-transparent after:border-r-[10px] after:border-r-transparent after:border-t-[15px] after:border-t-[--color-accent]"></div>
<div class="mt-6 text-lg font-bold text-accent text-center whitespace-nowrap md:text-base">{event.date}</div>
<div class={`relative z-2 flex items-center gap-12 text-left ${(index + 1) % 2 === 0 ? 'md:flex-row-reverse md:text-right md:right-0' : 'md:left-0'}`}>
<div class="hidden md:flex flex-col items-center in-w-30 max-w-60 md:min-w-auto md:self-start">
<MapPin class="text-accent" size={40} ></MapPin>
<div class="mt-6 text-lg font-bold text-accent text-center whitespace-nowrap md:text-base">{event.date}</div>
</div>
<div class="flex-1 bg-white/10 p-12 rounded-xl backdrop-blur-[10px] border-2 border-white/20 transition-all duration-300 hover:-translate-y-1 hover:bg-white/15 hover:shadow-[0_8px_20px_rgba(0,0,0,0.3)] max-w-full md:max-w-2xl">
<h3 class="text-3xl mb-2 text-white md:text-2xl">
<span class="event-title-de" data-lang="de">{event.title_de}</span>
<span class="event-title-en" data-lang="en">{event.title_en}</span>
</h3>
<p class="event-desc-de m-0 text-white/90 leading-relaxed" data-lang="de">{event.description_de}</p>
<p class="event-desc-en m-0 text-white/90 leading-relaxed" data-lang="en">{event.description_en}</p>
<div class="md:hidden text-lg font-bold text-accent text-center whitespace-nowrap md:text-base">{event.date}</div>
<h3 class="text-3xl mb-2 text-white md:text-2xl">{event.title}</h3>
<p class="event-desc m-0 text-white/90 leading-relaxed">{event.description}</p>
</div>
</div>
))}
@@ -208,19 +160,16 @@ function renderRichText(richText: any, lang: 'de' | 'en'): string {
<!-- Section 6: Presse -->
<TheaterSection
id="presse"
titleDe={content.presse?.title_de || 'Presse'}
titleEn={content.presse?.title_en || 'Press'}
title={content.presse?.title || 'Presse'}
variant="dark"
>
<div class="content-de" data-lang="de" set:html={renderRichText(content.presse?.content_de, 'de')} />
<div class="content-en" data-lang="en" set:html={renderRichText(content.presse?.content_en, 'en')} />
<div class="prose-measure" set:html={renderPortableText(content.presse?.content)} />
</TheaterSection>
<!-- Section 7: Foto Galerie -->
<TheaterSection
id="gallery"
titleDe={content.gallery?.title_de || 'Foto Galerie'}
titleEn={content.gallery?.title_en || 'Photo Gallery'}
title={content.gallery?.title || 'Foto Galerie'}
variant="light"
>
<PhotoGallery images={content.gallery?.images || []} />
@@ -229,19 +178,16 @@ function renderRichText(richText: any, lang: 'de' | 'en'): string {
<!-- Section 8: Tickets -->
<TheaterSection
id="tickets"
titleDe={content.tickets?.title_de || 'Tickets'}
titleEn={content.tickets?.title_en || 'Tickets'}
title={content.tickets?.title || 'Tickets'}
variant="dark"
>
<div class="content-de" data-lang="de" set:html={renderRichText(content.tickets?.content_de, 'de')} />
<div class="content-en" data-lang="en" set:html={renderRichText(content.tickets?.content_en, 'en')} />
<div class="prose-measure" set:html={renderPortableText(content.tickets?.content)} />
{content.tickets?.ticket_url && (
<div class="mt-[--spacing-lg] text-center">
<a href={content.tickets.ticket_url} class="inline-block py-4 px-10 bg-accent text-secondary no-underline font-bold text-xl rounded-lg transition-all duration-300 shadow-[0_4px_10px_rgba(0,0,0,0.2)] hover:-translate-y-1 hover:shadow-[0_6px_15px_rgba(0,0,0,0.3)] hover:bg-[#fbfa9a]" target="_blank" rel="noopener noreferrer">
<span data-lang="de">Tickets kaufen</span>
<span data-lang="en">Buy Tickets</span>
</a>
<a href={content.tickets.ticket_url} class="inline-block py-4 px-10 bg-accent text-secondary no-underline font-bold text-xl rounded-lg transition-all duration-300 shadow-[0_4px_10px_rgba(0,0,0,0.2)] hover:-translate-y-1 hover:shadow-[0_6px_15px_rgba(0,0,0,0.3)] hover:bg-[#fbfa9a]" target="_blank" rel="noopener noreferrer">
Tickets kaufen
</a>
</div>
)}
</TheaterSection>
+26
View File
@@ -0,0 +1,26 @@
interface CacheEntry {
data: unknown;
expiresAt: number;
}
const store = new Map<string, CacheEntry>();
const DEFAULT_TTL = 60 * 1000; // 1 minute
function cacheKey(query: string, params: Record<string, unknown>): string {
const p = Object.keys(params).sort().map(k => `${k}=${JSON.stringify(params[k])}`).join('&');
return `${query}${p ? `?${p}` : ''}`;
}
export function get(key: string): unknown | null {
const entry = store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
store.delete(key);
return null;
}
return entry.data;
}
export function set(key: string, data: unknown, ms?: number): void {
store.set(key, { data, expiresAt: Date.now() + (ms ?? DEFAULT_TTL) });
}
+14
View File
@@ -0,0 +1,14 @@
import { sanityClient } from 'sanity:client';
import { get, set } from './cache';
export async function fetchWithCache<T>(query: string, params?: Record<string, unknown>): Promise<T | null> {
const key = `sanity:${query}:${JSON.stringify(params ?? {})}`;
const cached = get(key) as T | null;
if (cached) return cached;
const data = await sanityClient.fetch(query, params);
if (data != null) set(key, data);
return data;
}
export { sanityClient };
+11
View File
@@ -0,0 +1,11 @@
import { createImageUrlBuilder } from '@sanity/image-url'
const builder = createImageUrlBuilder({
projectId: import.meta.env.PUBLIC_SANITY_PROJECT_ID,
dataset: import.meta.env.PUBLIC_SANITY_DATASET ?? 'production',
})
export function urlFor(source: any): string {
if (!source) return ''
return builder.image(source).url()
}
+17
View File
@@ -0,0 +1,17 @@
import { toHTML } from '@portabletext/to-html'
import { urlFor } from './imageUrl'
export function renderPortableText(blocks: any[]): string {
if (!blocks?.length) return ''
return toHTML(blocks, {
components: {
types: {
image: ({ value }) => {
const url = urlFor(value)
if (!url) return ''
return `<img src="${url}" alt="${value.alt ?? ''}" />`
},
},
},
})
}
+1
View File
@@ -0,0 +1 @@
export const theaterHomepageQuery = `*[_type == "theaterHomepage" && _id == "theaterHomepage"][0]`
+3
View File
@@ -0,0 +1,3 @@
import { theaterHomepageSchema } from './theaterHomepage'
export const schemaTypes = [theaterHomepageSchema]
+153
View File
@@ -0,0 +1,153 @@
import { defineType, defineField, defineArrayMember } from 'sanity'
export const theaterHomepageSchema = defineType({
name: 'theaterHomepage',
title: 'Theater Homepage',
type: 'document',
// Singleton: disable create/delete — only update and publish
fields: [
defineField({
name: 'title',
title: 'Page Title',
type: 'string',
}),
// Hero Section
defineField({
name: 'hero',
title: 'Hero Section',
type: 'object',
fields: [
defineField({ name: 'title', title: 'Title', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'subtitle', title: 'Subtitle', type: 'text' }),
],
}),
// Section 1: Das Projekt / The Project
defineField({
name: 'projekt',
title: 'Section 1: Das Projekt / The Project',
type: 'object',
fields: [
defineField({ name: 'title', title: 'Title', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'content', title: 'Content', type: 'array', of: [{ type: 'block' }, defineArrayMember({ type: 'image', options: { hotspot: true } })] }),
],
}),
// Section 2: Wer steckt dahinter / Who's Behind It
defineField({
name: 'team',
title: "Section 2: Wer steckt dahinter / Who's Behind It",
type: 'object',
fields: [
defineField({ name: 'title', title: 'Title', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'content', title: 'Content', type: 'array', of: [{ type: 'block' }, defineArrayMember({ type: 'image', options: { hotspot: true } })] }),
defineField({
name: 'organizations',
title: 'Organizations',
type: 'array',
of: [
defineArrayMember({
type: 'object',
fields: [
defineField({ name: 'name', title: 'Name', type: 'string' }),
defineField({ name: 'role', title: 'Role', type: 'string' }),
defineField({ name: 'logo', title: 'Logo', type: 'image', options: { hotspot: false } }),
defineField({ name: 'url', title: 'Website URL', type: 'url' }),
],
}),
],
}),
],
}),
// Section 3: Das Stück / The Play
defineField({
name: 'stueck',
title: 'Section 3: Das Stück / The Play',
type: 'object',
fields: [
defineField({ name: 'title', title: 'Title', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'content', title: 'Content', type: 'array', of: [{ type: 'block' }, defineArrayMember({ type: 'image', options: { hotspot: true } })] }),
],
}),
// Section 4: Mitwirkung / Participation
defineField({
name: 'mitwirkung',
title: 'Section 4: Mitwirkung / Participation',
type: 'object',
fields: [
defineField({ name: 'title', title: 'Title', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'content', title: 'Content', type: 'array', of: [{ type: 'block' }, defineArrayMember({ type: 'image', options: { hotspot: true } })] }),
defineField({ name: 'director_name', title: 'Director Name', type: 'string' }),
defineField({ name: 'director_email', title: 'Director Email', type: 'string' }),
defineField({ name: 'director_phone', title: 'Director Phone', type: 'string' }),
],
}),
// Section 5: Termine / Schedule
defineField({
name: 'termine',
title: 'Section 5: Termine / Schedule',
type: 'object',
fields: [
defineField({ name: 'title', title: 'Title', type: 'string', validation: (r) => r.required() }),
defineField({
name: 'events',
title: 'Events',
type: 'array',
of: [
defineArrayMember({
type: 'object',
fields: [
defineField({ name: 'title', title: 'Event Title', type: 'string' }),
defineField({ name: 'date', title: 'Date / Period', type: 'string' }),
defineField({ name: 'description', title: 'Description', type: 'text' }),
],
}),
],
}),
],
}),
// Section 6: Presse / Press
defineField({
name: 'presse',
title: 'Section 6: Presse / Press',
type: 'object',
fields: [
defineField({ name: 'title', title: 'Title', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'content', title: 'Content', type: 'array', of: [{ type: 'block' }, defineArrayMember({ type: 'image', options: { hotspot: true } })] }),
],
}),
// Section 7: Foto Galerie / Photo Gallery
defineField({
name: 'gallery',
title: 'Section 7: Foto Galerie / Photo Gallery',
type: 'object',
fields: [
defineField({ name: 'title', title: 'Title', type: 'string', validation: (r) => r.required() }),
defineField({
name: 'images',
title: 'Gallery Images',
type: 'array',
of: [{ type: 'image' }],
}),
],
}),
// Section 8: Tickets
defineField({
name: 'tickets',
title: 'Section 8: Tickets',
type: 'object',
fields: [
defineField({ name: 'title', title: 'Title', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'content', title: 'Content', type: 'array', of: [{ type: 'block' }, defineArrayMember({ type: 'image', options: { hotspot: true } })] }),
defineField({ name: 'ticket_url', title: 'Ticket Purchase URL', type: 'url' }),
],
}),
],
})
+10 -37
View File
@@ -9,16 +9,11 @@
--color-bg: #ffffff;
--color-bg-alt: #fafaf5;
--spacing-xs: 0.5rem;
--spacing-sm: 1rem;
--spacing-md: 2rem;
--spacing-lg: 3rem;
--spacing-xl: 4rem;
--breakpoint-md: 50rem;
--font-heading: 'Great Vibes', cursive;
--font-heading: 'Kaushan Script', cursive;
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--max-width-container: 1200px;
}
@layer base {
@@ -45,36 +40,6 @@
}
/* Language-specific content visibility */
[data-language="de"] .title-en,
[data-language="de"] .subtitle-en,
[data-language="de"] .content-en,
[data-language="de"] .org-role-en,
[data-language="de"] .event-title-en,
[data-language="de"] .event-desc-en,
[data-language="de"] .nav-label-en,
[data-language="de"] .placeholder-en,
[data-language="de"] [data-lang="en"] {
display: none !important;
}
[data-language="en"] .title-de,
[data-language="en"] .subtitle-de,
[data-language="en"] .content-de,
[data-language="en"] .org-role-de,
[data-language="en"] .event-title-de,
[data-language="en"] .event-desc-de,
[data-language="en"] .nav-label-de,
[data-language="en"] .placeholder-de,
[data-language="en"] [data-lang="de"] {
display: none !important;
}
[data-language="de"] .nav-label-de,
[data-language="en"] .nav-label-en,
[data-language="de"] .placeholder-de,
[data-language="en"] .placeholder-en {
display: inline;
}
/* Section content styling */
.section-content p {
@@ -85,4 +50,12 @@
font-size: 1.8rem;
margin: 1.5rem 0 1rem;
}
/* Keep flowing prose to a comfortable reading measure so lines
don't stretch across the full section width. */
.prose-measure {
max-width: 42rem;
margin-left: auto;
margin-right: auto;
}
}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"DocumentConnection":{"type":"DocumentConnection","resolveType":"multiCollectionDocumentList","collections":["user","post","page","theaterHomepage"]},"Node":{"type":"Node","resolveType":"nodeDocument"},"DocumentNode":{"type":"DocumentNode","resolveType":"multiCollectionDocument","createDocument":"create","updateDocument":"update"},"User":{"type":"User","resolveType":"collectionDocument","collection":"user","createUser":"create","updateUser":"update"},"UserConnection":{"type":"UserConnection","resolveType":"collectionDocumentList","collection":"user"},"Post":{"type":"Post","resolveType":"collectionDocument","collection":"post","createPost":"create","updatePost":"update"},"PostConnection":{"type":"PostConnection","resolveType":"collectionDocumentList","collection":"post"},"Page":{"type":"Page","resolveType":"collectionDocument","collection":"page","createPage":"create","updatePage":"update"},"PageConnection":{"type":"PageConnection","resolveType":"collectionDocumentList","collection":"page"},"TheaterHomepage":{"type":"TheaterHomepage","resolveType":"collectionDocument","collection":"theaterHomepage","createTheaterHomepage":"create","updateTheaterHomepage":"update"},"TheaterHomepageConnection":{"type":"TheaterHomepageConnection","resolveType":"collectionDocumentList","collection":"theaterHomepage"}}
-1
View File
File diff suppressed because one or more lines are too long
-5
View File
@@ -1,5 +0,0 @@
import { createClient } from "tinacms/dist/client";
import { queries } from "./types";
export const client = createClient({ url: '/api/tina/gql', token: 'undefined', queries, });
export default client;
-494
View File
@@ -1,494 +0,0 @@
// tina/config.ts
import { defineConfig } from "tinacms";
import { UsernamePasswordAuthJSProvider, TinaUserCollection } from "tinacms-authjs/dist/tinacms";
var config_default = defineConfig({
contentApiUrlOverride: "/api/tina/gql",
authProvider: new UsernamePasswordAuthJSProvider(),
build: {
outputFolder: "admin",
publicFolder: "public"
},
media: {
tina: {
mediaRoot: "images",
publicFolder: "public",
static: false
}
},
schema: {
collections: [
TinaUserCollection,
{
name: "post",
label: "Blog Posts",
path: "content/blog",
format: "mdx",
fields: [
{
type: "string",
name: "title",
label: "Title",
isTitle: true,
required: true
},
{
type: "datetime",
name: "date",
label: "Publication Date",
required: true
},
{
type: "string",
name: "excerpt",
label: "Excerpt",
required: true,
description: "Short summary of the blog post"
},
{
type: "image",
name: "coverImage",
label: "Cover Image",
description: "Featured image for the blog post"
},
{
type: "rich-text",
name: "body",
label: "Body",
isBody: true,
required: true
}
],
defaultItem: () => {
return {
title: "New Blog Post",
date: (/* @__PURE__ */ new Date()).toISOString(),
excerpt: ""
};
}
},
{
name: "page",
label: "Pages",
path: "content/pages",
format: "mdx",
fields: [
{
type: "string",
name: "title",
label: "Title",
isTitle: true,
required: true
},
{
type: "string",
name: "description",
label: "Description",
description: "Meta description for SEO"
},
{
type: "rich-text",
name: "body",
label: "Content",
isBody: true,
required: true
}
]
},
{
name: "theaterHomepage",
label: "Theater Homepage",
path: "content/theater",
format: "mdx",
ui: {
allowedActions: {
create: false,
delete: false
}
},
fields: [
{
type: "string",
name: "title",
label: "Page Title",
isTitle: true,
required: true
},
// Hero Section
{
type: "object",
name: "hero",
label: "Hero Section",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true
},
{
type: "string",
name: "subtitle_de",
label: "Subtitle (German)",
required: true,
ui: {
component: "textarea"
}
},
{
type: "string",
name: "subtitle_en",
label: "Subtitle (English)",
required: true,
ui: {
component: "textarea"
}
}
]
},
// Section 1: Das Projekt
{
type: "object",
name: "projekt",
label: "Section 1: Das Projekt / The Project",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true
},
{
type: "rich-text",
name: "content_de",
label: "Content (German)",
required: true
},
{
type: "rich-text",
name: "content_en",
label: "Content (English)",
required: true
}
]
},
// Section 2: Wer steckt dahinter
{
type: "object",
name: "team",
label: "Section 2: Wer steckt dahinter / Who's Behind It",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true
},
{
type: "rich-text",
name: "content_de",
label: "Content (German)",
required: true
},
{
type: "rich-text",
name: "content_en",
label: "Content (English)",
required: true
},
{
type: "object",
name: "organizations",
label: "Organizations",
list: true,
fields: [
{
type: "string",
name: "name",
label: "Name"
},
{
type: "string",
name: "role_de",
label: "Role (German)"
},
{
type: "string",
name: "role_en",
label: "Role (English)"
},
{
type: "image",
name: "logo",
label: "Logo",
description: "Organization logo image (SVG, PNG, JPG supported)"
},
{
type: "string",
name: "url",
label: "Website URL",
description: "Organization website link"
}
]
}
]
},
// Section 3: Das Stück
{
type: "object",
name: "stueck",
label: "Section 3: Das St\xFCck / The Play",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true
},
{
type: "rich-text",
name: "content_de",
label: "Content (German)",
required: true
},
{
type: "rich-text",
name: "content_en",
label: "Content (English)",
required: true
}
]
},
// Section 4: Mitwirkung
{
type: "object",
name: "mitwirkung",
label: "Section 4: Mitwirkung / Participation",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true
},
{
type: "rich-text",
name: "content_de",
label: "Content (German)",
required: true
},
{
type: "rich-text",
name: "content_en",
label: "Content (English)",
required: true
},
{
type: "string",
name: "director_name",
label: "Director Name"
},
{
type: "string",
name: "director_email",
label: "Director Email"
},
{
type: "string",
name: "director_phone",
label: "Director Phone"
}
]
},
// Section 5: Termine
{
type: "object",
name: "termine",
label: "Section 5: Termine / Schedule",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true
},
{
type: "object",
name: "events",
label: "Events",
list: true,
fields: [
{
type: "string",
name: "title_de",
label: "Event Title (German)"
},
{
type: "string",
name: "title_en",
label: "Event Title (English)"
},
{
type: "string",
name: "date",
label: "Date/Period"
},
{
type: "string",
name: "description_de",
label: "Description (German)",
ui: {
component: "textarea"
}
},
{
type: "string",
name: "description_en",
label: "Description (English)",
ui: {
component: "textarea"
}
}
]
}
]
},
// Section 6: Presse
{
type: "object",
name: "presse",
label: "Section 6: Presse / Press",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true
},
{
type: "rich-text",
name: "content_de",
label: "Content (German)",
required: true
},
{
type: "rich-text",
name: "content_en",
label: "Content (English)",
required: true
}
]
},
// Section 7: Foto Galerie
{
type: "object",
name: "gallery",
label: "Section 7: Foto Galerie / Photo Gallery",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true
},
{
type: "image",
name: "images",
label: "Gallery Images",
list: true
}
]
},
// Section 8: Tickets
{
type: "object",
name: "tickets",
label: "Section 8: Tickets",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true
},
{
type: "rich-text",
name: "content_de",
label: "Content (German)",
required: true
},
{
type: "rich-text",
name: "content_en",
label: "Content (English)",
required: true
},
{
type: "string",
name: "ticket_url",
label: "Ticket Purchase URL",
description: "External link to ticket partner"
}
]
}
]
}
]
}
});
export {
config_default as default
};
-63
View File
@@ -1,63 +0,0 @@
// @ts-nocheck
import { resolve } from "@tinacms/datalayer";
import type { TinaClient } from "tinacms/dist/client";
import { queries } from "./types";
import database from "../database";
export async function databaseRequest({ query, variables, user }) {
const result = await resolve({
config: {
useRelativeMedia: true,
},
database,
query,
variables,
verbose: true,
ctxUser: user,
});
return result;
}
export async function authenticate({ username, password }) {
return databaseRequest({
query: `query auth($username:String!, $password:String!) {
authenticate(sub:$username, password:$password) {
id:username name email _password: password { passwordChangeRequired }
}
}`,
variables: { username, password },
})
}
export async function authorize(user: { sub: string }) {
return databaseRequest({
query: `query authz { authorize { id:username name email _password: password { passwordChangeRequired }} }`,
variables: {},
user
})
}
function createDatabaseClient<GenQueries = Record<string, unknown>>({
queries,
}: {
queries: (client: {
request: TinaClient<GenQueries>["request"];
}) => GenQueries;
}) {
const request = async ({ query, variables, user }) => {
const data = await databaseRequest({ query, variables, user });
return { data: data.data as any, query, variables, errors: data.errors || null };
};
const q = queries({
request,
});
return { queries: q, request, authenticate, authorize };
}
export const databaseClient = createDatabaseClient({ queries });
export const client = databaseClient;
export default databaseClient;
-114
View File
@@ -1,114 +0,0 @@
fragment UserParts on User {
__typename
users {
__typename
username
name
email
password {
value
passwordChangeRequired
}
}
}
fragment PostParts on Post {
__typename
title
date
excerpt
coverImage
body
}
fragment PageParts on Page {
__typename
title
description
body
}
fragment TheaterHomepageParts on TheaterHomepage {
__typename
title
hero {
__typename
title_de
title_en
subtitle_de
subtitle_en
}
projekt {
__typename
title_de
title_en
content_de
content_en
}
team {
__typename
title_de
title_en
content_de
content_en
organizations {
__typename
name
role_de
role_en
logo
url
}
}
stueck {
__typename
title_de
title_en
content_de
content_en
}
mitwirkung {
__typename
title_de
title_en
content_de
content_en
director_name
director_email
director_phone
}
termine {
__typename
title_de
title_en
events {
__typename
title_de
title_en
date
description_de
description_en
}
}
presse {
__typename
title_de
title_en
content_de
content_en
}
gallery {
__typename
title_de
title_en
images
}
tickets {
__typename
title_de
title_en
content_de
content_en
ticket_url
}
}
-219
View File
@@ -1,219 +0,0 @@
query user($relativePath: String!) {
user(relativePath: $relativePath) {
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
...UserParts
}
}
query userConnection($before: String, $after: String, $first: Float, $last: Float, $sort: String, $filter: UserFilter) {
userConnection(
before: $before
after: $after
first: $first
last: $last
sort: $sort
filter: $filter
) {
pageInfo {
hasPreviousPage
hasNextPage
startCursor
endCursor
}
totalCount
edges {
cursor
node {
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
...UserParts
}
}
}
}
query post($relativePath: String!) {
post(relativePath: $relativePath) {
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
...PostParts
}
}
query postConnection($before: String, $after: String, $first: Float, $last: Float, $sort: String, $filter: PostFilter) {
postConnection(
before: $before
after: $after
first: $first
last: $last
sort: $sort
filter: $filter
) {
pageInfo {
hasPreviousPage
hasNextPage
startCursor
endCursor
}
totalCount
edges {
cursor
node {
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
...PostParts
}
}
}
}
query page($relativePath: String!) {
page(relativePath: $relativePath) {
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
...PageParts
}
}
query pageConnection($before: String, $after: String, $first: Float, $last: Float, $sort: String, $filter: PageFilter) {
pageConnection(
before: $before
after: $after
first: $first
last: $last
sort: $sort
filter: $filter
) {
pageInfo {
hasPreviousPage
hasNextPage
startCursor
endCursor
}
totalCount
edges {
cursor
node {
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
...PageParts
}
}
}
}
query theaterHomepage($relativePath: String!) {
theaterHomepage(relativePath: $relativePath) {
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
...TheaterHomepageParts
}
}
query theaterHomepageConnection($before: String, $after: String, $first: Float, $last: Float, $sort: String, $filter: TheaterHomepageFilter) {
theaterHomepageConnection(
before: $before
after: $after
first: $first
last: $last
sort: $sort
filter: $filter
) {
pageInfo {
hasPreviousPage
hasNextPage
startCursor
endCursor
}
totalCount
edges {
cursor
node {
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
...TheaterHomepageParts
}
}
}
}
-589
View File
@@ -1,589 +0,0 @@
# DO NOT MODIFY THIS FILE. This file is automatically generated by Tina
"""References another document, used as a foreign key"""
scalar Reference
""""""
scalar JSON
type SystemInfo {
filename: String!
title: String
basename: String!
hasReferences: Boolean
breadcrumbs(excludeExtension: Boolean): [String!]!
path: String!
relativePath: String!
extension: String!
template: String!
collection: Collection!
}
type Folder {
name: String!
path: String!
}
type PageInfo {
hasPreviousPage: Boolean!
hasNextPage: Boolean!
startCursor: String!
endCursor: String!
}
""""""
interface Node {
id: ID!
}
""""""
interface Document {
id: ID!
_sys: SystemInfo
_values: JSON!
}
"""A relay-compliant pagination connection"""
interface Connection {
totalCount: Float!
pageInfo: PageInfo!
}
type Query {
getOptimizedQuery(queryString: String!): String
collection(collection: String): Collection!
collections: [Collection!]!
node(id: String): Node!
document(collection: String, relativePath: String): DocumentNode!
user(relativePath: String): User!
authenticate(sub: String!, password: String!): UserUsers
authorize: UserUsers
userConnection(before: String, after: String, first: Float, last: Float, sort: String, filter: UserFilter): UserConnection!
post(relativePath: String): Post!
postConnection(before: String, after: String, first: Float, last: Float, sort: String, filter: PostFilter): PostConnection!
page(relativePath: String): Page!
pageConnection(before: String, after: String, first: Float, last: Float, sort: String, filter: PageFilter): PageConnection!
theaterHomepage(relativePath: String): TheaterHomepage!
theaterHomepageConnection(before: String, after: String, first: Float, last: Float, sort: String, filter: TheaterHomepageFilter): TheaterHomepageConnection!
}
input DocumentFilter {
user: UserFilter
post: PostFilter
page: PageFilter
theaterHomepage: TheaterHomepageFilter
}
type DocumentConnectionEdges {
cursor: String!
node: DocumentNode
}
type DocumentConnection implements Connection {
pageInfo: PageInfo!
totalCount: Float!
edges: [DocumentConnectionEdges]
}
type Collection {
name: String!
slug: String!
label: String
path: String!
format: String
matches: String
templates: [JSON]
fields: [JSON]
documents(before: String, after: String, first: Float, last: Float, sort: String, filter: DocumentFilter, folder: String): DocumentConnection!
}
union DocumentNode = User | Post | Page | TheaterHomepage | Folder
type UserUsersPassword {
value: String!
passwordChangeRequired: Boolean
}
type UserUsers {
username: String!
name: String
email: String
password: UserUsersPassword!
}
type User implements Node & Document {
users: [UserUsers]
id: ID!
_sys: SystemInfo!
_values: JSON!
}
input StringFilter {
startsWith: String
eq: String
exists: Boolean
in: [String]
}
input UserUsersFilter {
username: StringFilter
name: StringFilter
email: StringFilter
}
input UserFilter {
users: UserUsersFilter
}
type UserConnectionEdges {
cursor: String!
node: User
}
type UserConnection implements Connection {
pageInfo: PageInfo!
totalCount: Float!
edges: [UserConnectionEdges]
}
type Post implements Node & Document {
title: String!
date: String!
excerpt: String!
coverImage: String
body: JSON!
id: ID!
_sys: SystemInfo!
_values: JSON!
}
input DatetimeFilter {
after: String
before: String
eq: String
exists: Boolean
in: [String]
}
input ImageFilter {
startsWith: String
eq: String
exists: Boolean
in: [String]
}
input RichTextFilter {
startsWith: String
eq: String
exists: Boolean
}
input PostFilter {
title: StringFilter
date: DatetimeFilter
excerpt: StringFilter
coverImage: ImageFilter
body: RichTextFilter
}
type PostConnectionEdges {
cursor: String!
node: Post
}
type PostConnection implements Connection {
pageInfo: PageInfo!
totalCount: Float!
edges: [PostConnectionEdges]
}
type Page implements Node & Document {
title: String!
description: String
body: JSON!
id: ID!
_sys: SystemInfo!
_values: JSON!
}
input PageFilter {
title: StringFilter
description: StringFilter
body: RichTextFilter
}
type PageConnectionEdges {
cursor: String!
node: Page
}
type PageConnection implements Connection {
pageInfo: PageInfo!
totalCount: Float!
edges: [PageConnectionEdges]
}
type TheaterHomepageHero {
title_de: String!
title_en: String!
subtitle_de: String!
subtitle_en: String!
}
type TheaterHomepageProjekt {
title_de: String!
title_en: String!
content_de: JSON!
content_en: JSON!
}
type TheaterHomepageTeamOrganizations {
name: String
role_de: String
role_en: String
logo: String
url: String
}
type TheaterHomepageTeam {
title_de: String!
title_en: String!
content_de: JSON!
content_en: JSON!
organizations: [TheaterHomepageTeamOrganizations]
}
type TheaterHomepageStueck {
title_de: String!
title_en: String!
content_de: JSON!
content_en: JSON!
}
type TheaterHomepageMitwirkung {
title_de: String!
title_en: String!
content_de: JSON!
content_en: JSON!
director_name: String
director_email: String
director_phone: String
}
type TheaterHomepageTermineEvents {
title_de: String
title_en: String
date: String
description_de: String
description_en: String
}
type TheaterHomepageTermine {
title_de: String!
title_en: String!
events: [TheaterHomepageTermineEvents]
}
type TheaterHomepagePresse {
title_de: String!
title_en: String!
content_de: JSON!
content_en: JSON!
}
type TheaterHomepageGallery {
title_de: String!
title_en: String!
images: [String]
}
type TheaterHomepageTickets {
title_de: String!
title_en: String!
content_de: JSON!
content_en: JSON!
ticket_url: String
}
type TheaterHomepage implements Node & Document {
title: String!
hero: TheaterHomepageHero
projekt: TheaterHomepageProjekt
team: TheaterHomepageTeam
stueck: TheaterHomepageStueck
mitwirkung: TheaterHomepageMitwirkung
termine: TheaterHomepageTermine
presse: TheaterHomepagePresse
gallery: TheaterHomepageGallery
tickets: TheaterHomepageTickets
id: ID!
_sys: SystemInfo!
_values: JSON!
}
input TheaterHomepageHeroFilter {
title_de: StringFilter
title_en: StringFilter
subtitle_de: StringFilter
subtitle_en: StringFilter
}
input TheaterHomepageProjektFilter {
title_de: StringFilter
title_en: StringFilter
content_de: RichTextFilter
content_en: RichTextFilter
}
input TheaterHomepageTeamOrganizationsFilter {
name: StringFilter
role_de: StringFilter
role_en: StringFilter
logo: ImageFilter
url: StringFilter
}
input TheaterHomepageTeamFilter {
title_de: StringFilter
title_en: StringFilter
content_de: RichTextFilter
content_en: RichTextFilter
organizations: TheaterHomepageTeamOrganizationsFilter
}
input TheaterHomepageStueckFilter {
title_de: StringFilter
title_en: StringFilter
content_de: RichTextFilter
content_en: RichTextFilter
}
input TheaterHomepageMitwirkungFilter {
title_de: StringFilter
title_en: StringFilter
content_de: RichTextFilter
content_en: RichTextFilter
director_name: StringFilter
director_email: StringFilter
director_phone: StringFilter
}
input TheaterHomepageTermineEventsFilter {
title_de: StringFilter
title_en: StringFilter
date: StringFilter
description_de: StringFilter
description_en: StringFilter
}
input TheaterHomepageTermineFilter {
title_de: StringFilter
title_en: StringFilter
events: TheaterHomepageTermineEventsFilter
}
input TheaterHomepagePresseFilter {
title_de: StringFilter
title_en: StringFilter
content_de: RichTextFilter
content_en: RichTextFilter
}
input TheaterHomepageGalleryFilter {
title_de: StringFilter
title_en: StringFilter
images: ImageFilter
}
input TheaterHomepageTicketsFilter {
title_de: StringFilter
title_en: StringFilter
content_de: RichTextFilter
content_en: RichTextFilter
ticket_url: StringFilter
}
input TheaterHomepageFilter {
title: StringFilter
hero: TheaterHomepageHeroFilter
projekt: TheaterHomepageProjektFilter
team: TheaterHomepageTeamFilter
stueck: TheaterHomepageStueckFilter
mitwirkung: TheaterHomepageMitwirkungFilter
termine: TheaterHomepageTermineFilter
presse: TheaterHomepagePresseFilter
gallery: TheaterHomepageGalleryFilter
tickets: TheaterHomepageTicketsFilter
}
type TheaterHomepageConnectionEdges {
cursor: String!
node: TheaterHomepage
}
type TheaterHomepageConnection implements Connection {
pageInfo: PageInfo!
totalCount: Float!
edges: [TheaterHomepageConnectionEdges]
}
type Mutation {
addPendingDocument(collection: String!, relativePath: String!, template: String): DocumentNode!
updateDocument(collection: String, relativePath: String!, params: DocumentUpdateMutation!): DocumentNode!
deleteDocument(collection: String, relativePath: String!): DocumentNode!
createDocument(collection: String, relativePath: String!, params: DocumentMutation!): DocumentNode!
createFolder(collection: String, relativePath: String!): DocumentNode!
updatePassword(password: String!): Boolean!
updateUser(relativePath: String!, params: UserMutation!): User!
createUser(relativePath: String!, params: UserMutation!): User!
updatePost(relativePath: String!, params: PostMutation!): Post!
createPost(relativePath: String!, params: PostMutation!): Post!
updatePage(relativePath: String!, params: PageMutation!): Page!
createPage(relativePath: String!, params: PageMutation!): Page!
updateTheaterHomepage(relativePath: String!, params: TheaterHomepageMutation!): TheaterHomepage!
createTheaterHomepage(relativePath: String!, params: TheaterHomepageMutation!): TheaterHomepage!
}
input DocumentUpdateMutation {
user: UserMutation
post: PostMutation
page: PageMutation
theaterHomepage: TheaterHomepageMutation
relativePath: String
}
input DocumentMutation {
user: UserMutation
post: PostMutation
page: PageMutation
theaterHomepage: TheaterHomepageMutation
}
input UserUsersPasswordMutation {
value: String
passwordChangeRequired: Boolean!
}
input UserUsersMutation {
username: String
name: String
email: String
password: UserUsersPasswordMutation
}
input UserMutation {
users: [UserUsersMutation]
}
input PostMutation {
title: String
date: String
excerpt: String
coverImage: String
body: JSON
}
input PageMutation {
title: String
description: String
body: JSON
}
input TheaterHomepageHeroMutation {
title_de: String
title_en: String
subtitle_de: String
subtitle_en: String
}
input TheaterHomepageProjektMutation {
title_de: String
title_en: String
content_de: JSON
content_en: JSON
}
input TheaterHomepageTeamOrganizationsMutation {
name: String
role_de: String
role_en: String
logo: String
url: String
}
input TheaterHomepageTeamMutation {
title_de: String
title_en: String
content_de: JSON
content_en: JSON
organizations: [TheaterHomepageTeamOrganizationsMutation]
}
input TheaterHomepageStueckMutation {
title_de: String
title_en: String
content_de: JSON
content_en: JSON
}
input TheaterHomepageMitwirkungMutation {
title_de: String
title_en: String
content_de: JSON
content_en: JSON
director_name: String
director_email: String
director_phone: String
}
input TheaterHomepageTermineEventsMutation {
title_de: String
title_en: String
date: String
description_de: String
description_en: String
}
input TheaterHomepageTermineMutation {
title_de: String
title_en: String
events: [TheaterHomepageTermineEventsMutation]
}
input TheaterHomepagePresseMutation {
title_de: String
title_en: String
content_de: JSON
content_en: JSON
}
input TheaterHomepageGalleryMutation {
title_de: String
title_en: String
images: [String]
}
input TheaterHomepageTicketsMutation {
title_de: String
title_en: String
content_de: JSON
content_en: JSON
ticket_url: String
}
input TheaterHomepageMutation {
title: String
hero: TheaterHomepageHeroMutation
projekt: TheaterHomepageProjektMutation
team: TheaterHomepageTeamMutation
stueck: TheaterHomepageStueckMutation
mitwirkung: TheaterHomepageMitwirkungMutation
termine: TheaterHomepageTermineMutation
presse: TheaterHomepagePresseMutation
gallery: TheaterHomepageGalleryMutation
tickets: TheaterHomepageTicketsMutation
}
schema {
query: Query
mutation: Mutation
}
-1
View File
@@ -1 +0,0 @@
[]
-1345
View File
File diff suppressed because it is too large Load Diff
-492
View File
@@ -1,492 +0,0 @@
import { defineConfig } from "tinacms";
import { UsernamePasswordAuthJSProvider, TinaUserCollection } from "tinacms-authjs/dist/tinacms";
export default defineConfig({
contentApiUrlOverride: "/api/tina/gql",
authProvider: new UsernamePasswordAuthJSProvider(),
build: {
outputFolder: "admin",
publicFolder: "public",
},
media: {
tina: {
mediaRoot: "images",
publicFolder: "public",
static: false,
},
},
schema: {
collections: [
TinaUserCollection,
{
name: "post",
label: "Blog Posts",
path: "content/blog",
format: "mdx",
fields: [
{
type: "string",
name: "title",
label: "Title",
isTitle: true,
required: true,
},
{
type: "datetime",
name: "date",
label: "Publication Date",
required: true,
},
{
type: "string",
name: "excerpt",
label: "Excerpt",
required: true,
description: "Short summary of the blog post",
},
{
type: "image",
name: "coverImage",
label: "Cover Image",
description: "Featured image for the blog post",
},
{
type: "rich-text",
name: "body",
label: "Body",
isBody: true,
required: true,
},
],
defaultItem: () => {
return {
title: "New Blog Post",
date: new Date().toISOString(),
excerpt: "",
};
},
},
{
name: "page",
label: "Pages",
path: "content/pages",
format: "mdx",
fields: [
{
type: "string",
name: "title",
label: "Title",
isTitle: true,
required: true,
},
{
type: "string",
name: "description",
label: "Description",
description: "Meta description for SEO",
},
{
type: "rich-text",
name: "body",
label: "Content",
isBody: true,
required: true,
},
],
},
{
name: "theaterHomepage",
label: "Theater Homepage",
path: "content/theater",
format: "mdx",
ui: {
allowedActions: {
create: false,
delete: false,
},
},
fields: [
{
type: "string",
name: "title",
label: "Page Title",
isTitle: true,
required: true,
},
// Hero Section
{
type: "object",
name: "hero",
label: "Hero Section",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true,
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true,
},
{
type: "string",
name: "subtitle_de",
label: "Subtitle (German)",
required: true,
ui: {
component: "textarea",
},
},
{
type: "string",
name: "subtitle_en",
label: "Subtitle (English)",
required: true,
ui: {
component: "textarea",
},
},
],
},
// Section 1: Das Projekt
{
type: "object",
name: "projekt",
label: "Section 1: Das Projekt / The Project",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true,
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true,
},
{
type: "rich-text",
name: "content_de",
label: "Content (German)",
required: true,
},
{
type: "rich-text",
name: "content_en",
label: "Content (English)",
required: true,
},
],
},
// Section 2: Wer steckt dahinter
{
type: "object",
name: "team",
label: "Section 2: Wer steckt dahinter / Who's Behind It",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true,
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true,
},
{
type: "rich-text",
name: "content_de",
label: "Content (German)",
required: true,
},
{
type: "rich-text",
name: "content_en",
label: "Content (English)",
required: true,
},
{
type: "object",
name: "organizations",
label: "Organizations",
list: true,
fields: [
{
type: "string",
name: "name",
label: "Name",
},
{
type: "string",
name: "role_de",
label: "Role (German)",
},
{
type: "string",
name: "role_en",
label: "Role (English)",
},
{
type: "image",
name: "logo",
label: "Logo",
description: "Organization logo image (SVG, PNG, JPG supported)",
},
{
type: "string",
name: "url",
label: "Website URL",
description: "Organization website link",
},
],
},
],
},
// Section 3: Das Stück
{
type: "object",
name: "stueck",
label: "Section 3: Das Stück / The Play",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true,
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true,
},
{
type: "rich-text",
name: "content_de",
label: "Content (German)",
required: true,
},
{
type: "rich-text",
name: "content_en",
label: "Content (English)",
required: true,
},
],
},
// Section 4: Mitwirkung
{
type: "object",
name: "mitwirkung",
label: "Section 4: Mitwirkung / Participation",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true,
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true,
},
{
type: "rich-text",
name: "content_de",
label: "Content (German)",
required: true,
},
{
type: "rich-text",
name: "content_en",
label: "Content (English)",
required: true,
},
{
type: "string",
name: "director_name",
label: "Director Name",
},
{
type: "string",
name: "director_email",
label: "Director Email",
},
{
type: "string",
name: "director_phone",
label: "Director Phone",
},
],
},
// Section 5: Termine
{
type: "object",
name: "termine",
label: "Section 5: Termine / Schedule",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true,
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true,
},
{
type: "object",
name: "events",
label: "Events",
list: true,
fields: [
{
type: "string",
name: "title_de",
label: "Event Title (German)",
},
{
type: "string",
name: "title_en",
label: "Event Title (English)",
},
{
type: "string",
name: "date",
label: "Date/Period",
},
{
type: "string",
name: "description_de",
label: "Description (German)",
ui: {
component: "textarea",
},
},
{
type: "string",
name: "description_en",
label: "Description (English)",
ui: {
component: "textarea",
},
},
],
},
],
},
// Section 6: Presse
{
type: "object",
name: "presse",
label: "Section 6: Presse / Press",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true,
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true,
},
{
type: "rich-text",
name: "content_de",
label: "Content (German)",
required: true,
},
{
type: "rich-text",
name: "content_en",
label: "Content (English)",
required: true,
},
],
},
// Section 7: Foto Galerie
{
type: "object",
name: "gallery",
label: "Section 7: Foto Galerie / Photo Gallery",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true,
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true,
},
{
type: "image",
name: "images",
label: "Gallery Images",
list: true,
},
],
},
// Section 8: Tickets
{
type: "object",
name: "tickets",
label: "Section 8: Tickets",
fields: [
{
type: "string",
name: "title_de",
label: "Title (German)",
required: true,
},
{
type: "string",
name: "title_en",
label: "Title (English)",
required: true,
},
{
type: "rich-text",
name: "content_de",
label: "Content (German)",
required: true,
},
{
type: "rich-text",
name: "content_en",
label: "Content (English)",
required: true,
},
{
type: "string",
name: "ticket_url",
label: "Ticket Purchase URL",
description: "External link to ticket partner",
},
],
},
],
},
],
},
});

Some files were not shown because too many files have changed in this diff Show More