# [Architecture & DevOps](/learn/course/architecture-and-devops)
Whether you're first getting your project off the ground or developing new features in an existing one, having a well-defined end-to-end development workflow is crucial for shipping your work efficiently and reliably without impacting the day-to-day content operations of your editors.
## [Introduction to Development Workflow](/learn/course/architecture-and-devops/introduction-to-development-workflow)
Sanity’s code-first approach makes it uniquely suited for automation and naturally aligns with CI/CD to supports safe, continuous iteration without disrupting content teams.
## Sanity's Mental Model
The Studio, from your schema definitions and configuration, defines the structure of your content, enforces the rules and validation of that content, and allows you to customize the editorial interface for how your editors interact with and manage that content. You can think of the Studio as a customizable "window" through which your editors interact with Content Lake. Sanity provides a unique architecture that decouples the content editing experience in the Studio from the underlying content storage in the Content Lake.

One of the key benefits of this architecture is that the Studio's schema and configuration live entirely in code, which means you can manage them in source control and test any changes as part of your regular development and QA process.
In other words, a solid development workflow allows you to:
- Develop and test new features in an isolated environment separate from production
- Promote changes from development to production environments in a controlled manner
- Allow content editors to continue their work uninterrupted in the production environment while development is ongoing
By setting up separate development and production environments, along with processes to migrate code and content between them, you can establish a smooth flow from development to release. This ensures that new features are properly tested before reaching production, and that content editors always have a stable production environment to work in that is insulated from development activities.
## What is DevOps?
According to [Atlassian](https://www.atlassian.com/devops),
> DevOps is a set of [practices](https://www.atlassian.com/devops/what-is-devops/devops-best-practices), [tools](https://www.atlassian.com/devops/devops-tools/choose-devops-tools), and a [cultural philosophy](https://www.atlassian.com/devops/what-is-devops/devops-culture) that automate and integrate the processes between software development and IT teams. It emphasizes team empowerment, cross-team communication and collaboration, and technology automation.
Continuous integration and continuous delivery (CI/CD) automates the development workflow above and allows developers to iterate quickly, catch issues early, and deliver new features seamlessly. Integrating the development and content workflows through CI/CD empowers developers and content editors to collaborate effectively on delivering new experiences. Developers can focus on building and shipping features, while content editors can create and manage content without disruption. This setup provides a robust foundation for ongoing development and content operations to occur in parallel–all in service of enabling your organization to realize its business goals.
In the upcoming lessons, we'll walk through the specific steps to configure your Sanity project with multiple environments and datasets to support this development workflow. You'll learn how to structure your project, manage datasets, and deploy Studios. By the end, you'll have a the foundations of a setup to confidently develop and ship ongoing improvements to your project.
## [Setting Up Your Environments](/learn/course/architecture-and-devops/setting-up-your-environments)
Separate development and production environments ensure isolated testing, stable workflows, and safe content migrations without disrupting editors.
> [!TIP]
> This course assumes you have already initialized a Sanity Studio as described in [Day one content operations](https://www.sanity.io/learn/course/day-one-with-sanity-studio).
## Create Development Dataset
When developing new features, the code changes made by developers to schemas and other Studio configuration shouldn't impact content editors working in the production environment. That's why it's a best practice to have separate datasets and Studio deployments for development and production environments.
By provisioning a dedicated development dataset, developers can freely iterate and test code changes without worrying about interrupting the day-to-day content operations. This clean separation allows both content and development workflows to proceed in parallel, while keeping the production environment stable. As new features are validated in the development environment, the code changes can be promoted to production, and any necessary content migrations can be performed in a controlled manner. Developing in a separate environment can also ensure that any schema changes they make will have migration scripts. Meanwhile, content editors can continue their work in the production dataset, insulated from any development activities.
First, to complement the `production` dataset you should already have, create a `development` dataset using the CLI:
```sh
npx sanity dataset create development --visibility private
```
You should now see two datasets when you run `npx sanity dataset list` and in Manage (`npx sanity manage)`.
## Using Environment Variables
Environment variables allow your Studio configuration to adapt to each deployment without modifying the codebase—making them essential for managing different environments. Rather than hardcoding the project ID and dataset, for example, you can instead use environment variables to statically replace them at build time.
First, initialize a new environment file by running:
```sh
npx sanity init --env --project [your-project-id] --dataset production
```
You'll see a new `.env` file in your workspace:
```:.env
# Warning: Do not add secrets (API keys and similar) to this file, as it is source controlled!
# Use `.env.local` for any secrets, and ensure it is not added to source control
SANITY_STUDIO_PROJECT_ID="[your-project-id]"
SANITY_STUDIO_DATASET="production"
```
> [!NOTE]
> `.env` by default won't be ignored by Git; however, these two environment variables aren't considered sensitive. If you'd rather they weren't checked into source control, you can use `--env .env.local`.
Now duplicate `.env` and name it `.env.development`, and remove the project ID so you just have the following:
```:.env.development
# Warning: Do not add secrets (API keys and similar) to this file, as it is source controlled!
# Use `.env.local` for any secrets, and ensure it is not added to source control
SANITY_STUDIO_DATASET="development"
```
> [!NOTE]
> In this example, we'll be checking these into source control. If you needed to override a variable on your local machine, you could add a `.env[.mode].local` file with your override(s).
Finally, let's update our configuration files to read from environment variables:
```typescript:sanity.config.ts
import {defineConfig} from 'sanity'
export default defineConfig({
// ...
projectId: process.env.SANITY_STUDIO_PROJECT_ID!,
dataset: process.env.SANITY_STUDIO_DATASET!,
// ...
})
```
```typescript:sanity.cli.ts
import {defineCliConfig} from 'sanity/cli'
export default defineCliConfig({
api: {
projectId: process.env.SANITY_STUDIO_PROJECT_ID!,
dataset: process.env.SANITY_STUDIO_DATASET!,
},
// ...
})
```
## Conclusion
In this lesson, we covered how to set up separate development and production datasets in your Sanity project. By creating dedicated datasets and configuring environment variables, you can establish a clean separation between ongoing development work and the stable production environment used by content editors.
With this foundation in place, you're ready to deploy separate Sanity Studios for each environment. In the next lesson, we'll walk through the process of deploying a development Studio and how to set CORS origins for each environment.
## [Deploying Environment-Specific Studios](/learn/course/architecture-and-devops/deploying-environment-specific-studios)
Deploying separate Studios ensures clean environment separation, safer iteration, and uninterrupted content editing.
With our datasets and environment files in place, let's now walk through the process of deploying separate Sanity Studios for each environment and setting up the proper CORS origins.
## Designate a Studio Host
First, we'll need to configure the subdomain for our Studio deployments. Sanity CLI will either use the `studioHost` option in `sanity.cli.ts`, if it's provided, or prompt for a hostname in the terminal.
Like our project ID and dataset, we can use an environment variable to configure the hostname for our Studio deployment. So let's add a new environment variable, `SANITY_STUDIO_HOSTNAME`, to our `.env` file:
```:.env
# Warning: Do not add secrets (API keys and similar) to this file, as it source is controlled!
# Use `.env.local` for any secrets, and ensure it is not added to source control
SANITY_STUDIO_PROJECT_ID="[your-project-id]"
SANITY_STUDIO_DATASET="development"
# [hostname].sanity.studio
HOSTNAME="[your-hostname]"
SANITY_STUDIO_HOSTNAME="$HOSTNAME"
```
Then in your `.env.development` file add:
```:.env.development
# https://www.sanity.io/docs/environment-variables
# Warning: Do not add secrets (API keys and similar) to this file, as it is source controlled!
# Use `.env.local` for any secrets, and ensure it is not added to source control
SANITY_STUDIO_DATASET="development"
# [hostname]-development.sanity.studio
SANITY_STUDIO_HOSTNAME="${HOSTNAME}-development"
```
> [!NOTE]
> Here we're using a variable `HOSTNAME` as the base and then using the `dotenv-expand` syntax to reference and add a suffix.
Now let's add a `studioHost` option and set it to the value of our new environment variable:
```typescript:sanity.cli.ts
import {defineCliConfig} from 'sanity/cli'
export default defineCliConfig({
api: {
projectId: process.env.SANITY_STUDIO_PROJECT_ID!,
dataset: process.env.SANITY_STUDIO_DATASET!,
},
studioHost: process.env.SANITY_STUDIO_HOSTNAME!,
// ...
})
```
## Targeting Environments with Modes
Now that we've setup our environment variables and configured our CLI and Studio configuration files to read from them, we need a way to target a specific environment.
Sanity CLI will load your environment variables in a predictable order, which we have leveraged to set environment variables for our different modes (`production` vs. `development`). `.env` will be loaded in all modes, so we'll use it as our fallback and add `development`-specific overrides. For example, we've overridden `SANITY_STUDIO_DATASET` and we've suffixed `HOSTNAME` to set `SANITY_STUDIO_HOSTNAME`.
When running Sanity CLI, you can specify the intended mode for your commands. Commands like `build` and `deploy` will run in `production` mode by default. To target a different environment, you can set the mode by specifying `SANITY_ACTIVE_ENV` in your terminal:
```sh
# builds Sanity Studio in `development` mode, loading `.env` and then `.env.development`
SANITY_ACTIVE_ENV=development npm run build
```
> [!TIP]
> You can learn more about modes and environment variable loading order in [Environment Variables](https://www.sanity.io/learn/studio/environment-variables)
Now let's deploy our two Studio environments:
```sh
# Deploy the development Studio
SANITY_ACTIVE_ENV=development npm run deploy
# Deploy the production Studio
npm run deploy
```
The last step will be to add your Studio environments to your CORS origin. Navigate to the Studio URL's that you've just created. If they haven't yet been added as CORS origins, you'll be prompted to add them to Manage. You can also either run `npx sanity manage` from your terminal or open Manage in your browser directly. Navigate to the 'API' tab and add your Studio URL's as origins with credentials allowed.
## Conclusion
Congratulations—you now have separate, environment-specific Studios configured and deployed! This setup gives your team the freedom to iterate safely in development while keeping production stable for content editors. Up until now, you've been running CLI commands manually, carefully passing the right environment mode. With everything now structured and standardized, you're ready to take the next step: automating your deployment. In the next lesson, we’ll connect these pieces into a CI/CD pipeline that streamlines your workflow and eliminates manual steps.
## [Automating Development Workflow](/learn/course/architecture-and-devops/automating-development-workflow)
Automate Sanity Studio deployments and CI checks that validate schemas and content, ensuring every code change is rigorously reviewed and production-ready.
Now that your environments and Studios are fully configured, it’s time to automate the workflow.
In this lesson, we will explore how automating the deployment of your Sanity Studio streamlines your development process and helps you achieve faster, more reliable releases. By transitioning from manual deployments to an automated workflow, you not only ensure that your production code is built and deployed consistently, but you also gain immediate feedback on changes with minimal human intervention.
## Development Workflow

When developing new features, for example adding a new schema definition or creating a custom input component, should follow a consistent process:
1. Start by checking out a new feature branch
2. Making code changes while running the Studio locally
3. Once they're ready to deploy and looking for a code review, the developer will push their branch to the remote and open a pull request
4. Once their code has been reviewed and validated, they'll merge their pull request to the main branch.
## Automate Deployment
Imagine you have just committed changes to a feature branch and opened a pull request. Instead of manually building and deploying your Sanity Studio, like we did in the previous lesson, an automated process springs into action. The workflow is triggered by push or pull request events. First, it checks out the latest code from your branch, sets up the Node.js environment, and installs the dependencies. It'll then build your Studio and deploy it to a PR-numbered hostname. As an added benefit, the workflow also automatically posts a comment with a link to the preview environment where reviewers can see your changes. When your code is merged into the main branch, the workflow builds and deploys the Studio to the production environment. Once a pull request is closed, a separate job is triggered to clean up the associated preview deployment.
Here is a sample GitHub workflow that demonstrates this automated deployment process for a Sanity Studio.
> [!NOTE]
> Though written here for GitHub, these steps can be ported to any CI/CD provider and can be adapted to your preferred solution.
```yaml:deploy.yml
name: Deploy Sanity Studio
on:
push:
branches:
- main
- development
pull_request:
types: [opened, synchronize, reopened, closed]
permissions:
contents: read
pull-requests: write
env:
SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }}
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
environment:
name: ${{ github.ref == 'refs/heads/main' && 'Production' || github.ref == 'refs/heads/development' && 'Development' || 'Preview' }}
url: ${{ steps.deploy.outputs.STUDIO_URL }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- run: npm ci
- name: Set Studio hostname
run: |
if [ "${{ github.event_name }}" == "pull_request" ]; then
echo "SANITY_STUDIO_HOSTNAME=${HOSTNAME}-pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV
else
echo "SANITY_STUDIO_HOSTNAME=${HOSTNAME}" >> $GITHUB_ENV
fi
- name: Build and deploy Sanity Studio
id: deploy
run: |
if [ -z "${SANITY_STUDIO_HOSTNAME}" ]; then
echo "Error: SANITY_STUDIO_HOSTNAME is not set" >&2
exit 1
fi
if [[ "$SANITY_ACTIVE_ENV" == "development" ]]; then
npm run deploy -- --yes --source-maps
else
npm run deploy -- --yes
fi
echo "STUDIO_URL=https://${SANITY_STUDIO_HOSTNAME}.sanity.studio" >> $GITHUB_OUTPUT
- name: Post preview link
if: github.event_name == 'pull_request' && github.event.action == 'opened'
uses: actions/github-script@v7
with:
script: |
const body = [
'**🚀 Preview environment has been deployed!**',
`Visit [${process.env.STUDIO_URL}](${process.env.STUDIO_URL}) to see your changes.`,
"*This is a temporary environment that will be undeployed when this PR is merged or closed.*"
].join('\n\n')
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body,
})
env:
STUDIO_URL: ${{ steps.deploy.outputs.STUDIO_URL }}
teardown:
name: Teardown
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.action == 'closed'
environment:
name: Preview
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- name: Install dependencies
run: npm ci
- name: Cleanup PR preview
run: npx sanity undeploy -- --yes
env:
SANITY_STUDIO_HOSTNAME: ${HOSTNAME}-pr-${{ github.event.pull_request.number }}
```
## Adding Pull Request Checks
Now that your Sanity Studio is deployed automatically, it’s crucial that every change merged into the main branch has been thoroughly reviewed and validated. When a pull request is opened or updated, your CI pipeline not only runs the typical linting and type-checking jobs but also includes Sanity-specific checks to catch errors early. If any of these jobs fail, detailed reports are automatically posted to the pull request, providing instant feedback for your team. In this way, before any merge occurs, your code is guaranteed to have passed all the necessary automated checks.
Within your CI pipeline, the commands `sanity schema validate` and `sanity documents validate` play critical roles in ensuring that your code does not introduce breaking changes. These validation steps create a robust safety net that goes beyond simply automating deployments.
The command `sanity schema validate` is used to verify that your schema definitions are error-free. When you run this command, it checks your schema files for syntax errors, misconfigurations, or other issues that might cause runtime errors.
In contrast, the command `sanity documents validate` verifies that the content stored in your Sanity dataset conform to the constraints defined in your schema. This command inspects each document to ensure that required fields are present, data types match the expected formats, and any additional validation rules you have implemented are adhered to. This step is essential for maintaining data integrity, and any discrepancies—such as missing values or incorrect data formats—are flagged to prevent problematic changes from being merged into production.
> [!TIP]
> Changes to your content model often require migration scripts to ensure data integrity. You can learn more about migrating data in [Handling schema changes confidently](https://www.sanity.io/learn/course/handling-schema-changes-confidently).
```yaml:ci.yml
name: CI
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
env:
SCHEMA_VALIDATION_REPORT: schema-report.txt
DATASET_VALIDATION_REPORT: dataset-report.txt
jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: npm
node-version: lts/*
- run: npm ci
- name: Typecheck
run: npm run typecheck
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: npm
node-version: lts/*
- run: npm ci
- name: Lint
run: npm run lint -- --max-warnings 0
validate-schema:
name: Validate Studio schema
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: npm
node-version: lts/*
- run: npm ci
- name: Validate Studio schema
id: validate
run: |
npx sanity schema validate >> ${{ env.SCHEMA_VALIDATION_REPORT }}
exit_code=$?
{
echo "## Schema Validation Results"
echo "\`\`\`"
cat ${{ env.SCHEMA_VALIDATION_REPORT }}
echo "\`\`\`"
} >> $GITHUB_STEP_SUMMARY
exit $exit_code
- name: Post schema validation report
uses: actions/github-script@v6
if: failure() && steps.validate.outcome == 'failure'
with:
script: |
const fs = require('fs');
const report = fs.readFileSync('${{ env.SCHEMA_VALIDATION_REPORT }}', 'utf8');
const body = [
'### ❌ Schema validation failed',
'',
`\`\`\`${report}\`\`\``,
].join('\n');
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
validate-dataset:
name: Validate dataset
runs-on: ubuntu-latest
if: (github.event_name == 'pull_request' && github.base_ref == 'main') || (github.ref == 'refs/heads/main')
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: npm
node-version: lts/*
- run: npm ci
- name: Validate dataset
id: validate
run: |
npx sanity documents validate --yes --level info >> ${{ env.DATASET_VALIDATION_REPORT }}
exit_code=$?
{
echo "## Dataset Validation Results"
echo "\`\`\`"
cat ${{ env.DATASET_VALIDATION_REPORT }}
echo "\`\`\`"
} >> $GITHUB_STEP_SUMMARY
exit $exit_code
env:
SANITY_ACTIVE_ENV: production
SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }}
# TODO: delete
SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_PROJECT_ID }}
- name: Post dataset validation report
if: failure() && steps.validate.outcome == 'failure'
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
const report = fs.readFileSync('${{ env.DATASET_VALIDATION_REPORT }}', 'utf8');
const body = [
'### ❌ Dataset validation failed',
'',
`\`\`\`${report}\`\`\``,
].join('\n');
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});
```
By incorporating these validation steps into your GitHub workflow, you ensure that changes undergo rigorous review before they trigger the automated deployment process. This CI process not only enhances the quality and reliability of your Sanity Studio but also builds confidence that both its structure and underlying data are sound when updates are pushed to production.
> [!TIP]
> Changes to your content model often require migration scripts to ensure data integrity. You can learn more about migrating data in [Handling schema changes confidently](https://www.sanity.io/learn/course/handling-schema-changes-confidently).
You'll now have a robust DevOps process that enables continuous development while maintaining a stable production environment for your content team. This approach balances the needs of both developers and content editors, ensuring smooth operations and reliable deployments.
# [AI-powered Sanity development](/learn/course/code-with-ai)
With its all-code configuration, AI tools make Sanity a perfect choice as a content backend, and developers of varying levels of experience—including those with none at all—greatly benefit from AI tooling. Do it the right way for the best results, take on more ambitious projects, lower the barrier to entry and create happier content authors.
## [The present future of Sanity development](/learn/course/code-with-ai/the-present-future-of-sanity-development)
Coding is no longer just for developers, however AI won't "do it all." Level-set your expectations on what AI tooling can and can't do for the pace and quality of development.
With its all-code configuration, thanks to AI-assisted development you can rapidly build Sanity-powered applications faster than low-code or no-code tools, which require hundreds of clicks in a browser to complete.
> [!NOTE]
> The videos in this course are also available as a full-length walkthrough, watch [Build web apps with your voice and Cursor](https://youtu.be/j6zrfJ56KYE) on YouTube.
However without guidance AI tools will not write excellent code. Among other problems, this can lead to a Sanity Studio configured only with the defaults and will not provide the best possible experience for your content creators.
While most AI tools understand the APIs Sanity makes available, they will not follow our opinionated best practices. Nor have they—like human developers—formed their own. We have! They're published in guides and courses on Sanity Learn and when followed lead to great implementations.
## What you'll learn
In this course, you'll be onboarded to simple AI tools to level-up your ambition to build content-driven web applications.
This course covers how to:
- Write prompts that get desired results
- Understand what makes a great "prompt"
- Setup the AI-powered code editor, Cursor
- Apply best-practice skills to guide the responses to your prompts
- Setup the Sanity MCP server to connect AI to your content
- Rapidly build a new web application front-end
### What you may already know
People who have never written code before have successfully completed this course!
These lessons are less about being technically demanding and more about introducing how to most efficiently develop by using AI tools.
You will benefit by having some amount of understanding of web application development and some appreciation for Sanity, the Content Operating System.
In short: Sanity offers a hosted backend—the Content Lake—and an all-code administration dashboard called Sanity Studio. Along with many more features.
If you have any other questions, ask your favorite AI tool.
> [!TIP]
> If you would prefer to learn Sanity's feature set more deeply with less of an AI focus, take the [Day one content operations](https://www.sanity.io/learn/course/day-one-with-sanity-studio) course.
## Why we wrote this course
Whether you’re a seasoned programmer, a rookie developer, or a complete novice—you may have formed some opinion on AI tools. I’m a 10+ year web developer and recently reformed AI cynic who has seen the light that our futures involve AI tooling, and there’s no going back.
If you’re new to programming, welcome! There’s never been a better time to dip your toe in the water. AI tools will do all the work writing things you haven’t learned yet. You’ll get started faster than ever before and get to focus purely on the outcomes that you want—less so on the code.
On the other hand, if you have programming experience, I hope that this course will show you how to work faster, better and with more ambition with AI-tooling.
## More reading
Beyond the material presented in this course, I recommend reading the following valuable, hype-free content:
> [!TIP]
> [Get started with AI-powered Sanity development](https://www.sanity.io/learn/ai/ai-quickstart)
> [!TIP]
> [How I use LLMs to help me write code](https://simonw.substack.com/p/how-i-use-llms-to-help-me-write-code) by Simon Willison
> [!TIP]
> [The AI Engineer Roadmap](https://www.aihero.dev/ai-engineer-roadmap) by Matt Pocock
## [You don't need this course](/learn/course/code-with-ai/you-dont-need-this-course)
You could build something with AI in the time it takes to complete this course. You'd probably not learn much though.
It's true. You could go to Cursor, Claude, or ChatGPT right now and ask it...
```:Prompt
How do I make a website with Sanity?
```
...and it will give you an answer.
You'll make progress. You'll have to keep asking questions. You'll copy and paste code from a web browser into some other format. You'll probably get frustrated along the way. Maybe the context window will fill up. And while you'll make progress, it's not the sort of progress that's sustainable, nor will it lead to a best practice implementation.
So you don't need this course, but it will help greatly.
I want to set you up with the best possible coding environment so that the project that you begin today, you will be able to maintain well into the future.
This course will give you a better understanding of not just how to author code, but how to prompt an LLM to write code on your behalf—and get the result you want.
Completing this course will give your Sanity implementation a huge head start. Instead of just being given install instructions for a bare bones template, you'll be working with an LLM that will be pre-trained on the best way to implement and work with Sanity, The Content Operating System.
That's the reason why this course contains many lessons that set the foundations of your project instead of just diving into an install script.
A little preparation now is going to pay dividends soon. Just you wait and see.
## [The value of working with AI](/learn/course/code-with-ai/the-value-of-working-with-ai)
What do you get from working with AI tools that you never had before?
## Rapid prototyping
The first version of anything you create is unlikely to be perfect. This has always been true in product development, but is now magnified when AI tooling allows you to go from 0% to 80% in a very short time.
As you build any project, you will begin to uncover edge cases that you had not considered and you'll begin to build context of what a final version of your product might look like.
Fortunately, our AI tooling allows you to take everything you'll learn along the way and write it into updated versions of the prompts that started the project.
Allowing you to build, trash, and build again in a short time.
This prompt, evaluate, destroy cycle is one of the great benefits of AI tooling.
You can get something that looks almost finished into the hands of test users and authors faster than ever before.
## Wider skillset
AI tooling should also allow you to take on more ambitious projects than ever before.
Previously, before approaching a new front-end technology, framework, or any area of expertise, you would need to gather some amount of understanding in that area first before proceeding.
You can now take the things that you already know, apply them to the concepts that you don't, and an LLM will help translate and close the knowledge gap.
For example, if you don't know how to build a 3D model in a front-end application, LLMs do, and so long as you can sufficiently describe the outcome that you want with enough context to get what you need, you should be able to get very close to a result you are happy with.
Along the way, if there are any concepts that you do not understand, you can ask your LLM and it should be able to give you accurate information.
## [Glossary of terms](/learn/course/code-with-ai/glossary-of-terms)
> [!NOTE]
> If you have web development application experience, you can skip this lesson.
There are a number of unavoidable technical terms that will be used throughout the following lessons. This is not a comprehensive guide of all definitions that make up web application development. Just explanations of the main concepts that will be introduced in the following lessons.
## AI tooling and technology
### AI (Artificial Intelligence)
Computer systems designed to perform tasks that typically require human intelligence, such as understanding language, recognizing patterns, or making decisions.
### LLM (Large Language Model)
A type of AI system trained on vast amounts of text data to predict and generate human-like language. Examples include GPT-4, Claude, and Gemini.
### Model
The underlying AI system that processes your input and generates responses. Think of it as the "brain" that has learned patterns from training data and uses those patterns to predict what text should come next.
### Prompt
The input text you provide to guide the model's response. Rather than just a question, it's the complete context that sets up the scenario for the model to complete in the way you want.
## Version Control & Development
### GitHub
GitHub uses the "Git" version control system that tracks changes to your code over time. Think of it like "track changes" for code - you can see what changed, when, and revert to previous versions if something breaks.
### Repository (Repo)
A folder containing your project files along with the complete history of changes made to those files. Repositories can be stored locally on your computer or remotely on services like GitHub.
### Commit
A snapshot of your code at a specific point in time. When you commit, you're saving a version of your project with a message describing what you changed.
### Branch
A separate line of development in your project. You can create branches to work on new features without affecting the main code, then merge them back when ready.
## Web Development
### Frontend
The part of a web application that users interact with directly in their browser. Also called the "client-side," it includes everything users see and click on - the interface, buttons, forms, and visual design.
### Framework
A pre-built foundation that provides structure and common functionality for building applications. React, Angular, and Vue.js are popular frontend frameworks.
### Meta-framework
A higher-level framework built on top of existing frameworks that adds additional features like routing, server-side rendering, and build optimization. Next.js (built on React), React Router, and Astro are popular meta-frameworks.
## Development Tools & Concepts
### IDE/Code Editor
Software for writing and editing code. Popular options include Cursor and Visual Studio Code. These provide syntax highlighting, error detection, and debugging tools.
### Terminal/Command Line
A text-based interface for interacting with your computer. Developers use it to run commands, install packages, and manage files without using a graphical interface.
## Deployment & Hosting
### Build Process
The steps that convert your development code into optimized files ready for production. This might include minifying code, compiling TypeScript, or bundling assets.
### Hosting
Modern web hosting providers connects to your repositories to build and host version of your application which can be served around the world to users. Examples include Vercel and Netlify.
## [Prompting primer](/learn/course/code-with-ai/introduction-to-prompting)
Get better results from AI tools by crafting effective prompts, setting realistic expectations, and using them for interactive brainstorming sessions.
Much of working with AI tooling requires “prompting,“ and writing great prompts is part of "prompt engineering." Essentially, writing instructions into a text box that the AI will execute.
For the entire history of computing, programming relied on getting consistent outputs from consistent inputs. However, this is not true when working with AI tools and so knowing how to write good inputs to get *predictably good *outputs becomes critically important.
Many factors will define the results that you get. The model that you are working with (for example, Claude Sonnet by Anthropic, ChatGPT by OpenAI, etc), the context that it has, and above all the quality of your prompt.
## What a prompt does
When an LLM responds with words, it is generating the most likely next word, word-by-word in its response.
It will compare the text that you have written to everything that it already knows to create the most likely response to your prompt.
So writing a prompt is less about getting an answer and more about shaping the most likely response.
> If you ask a question in your prompt, the model isn't following a separate “Q&A” code path, but rather it appears to answer the question because an answer is the most likely sort of response for the given question as input.
> [!TIP]
> Read [Prompt engineering techniques](https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/prompt-engineering) in the Microsoft Azure documentation.
## Testing prompts
Prompts are the default way most people interact with LLM's. Try any of the examples below in [ChatGPT](https://chatgpt.com/), [Claude](https://chatgpt.com/) or [Grok](https://grok.com/).
## Writing great prompts
Through a lot of hype and the need for attention, AI tools have been largely oversold in terms of the scale what they can do with short prompts and large code-bases.
If you have only seen a few tweets and demos, you may expect to be able to write a short description of what you need, watch the computer magically do the rest, and put your feet up.
This is not the case. Bad prompting will lead to frustration. Great prompting leads to success.
### Set clear goals for success
On the first page of Anthropic's documentation on Prompt Engineering they recommend before prompting to have a clear definition of success.
> [!TIP]
> Read [Prompt engineering](https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview) on the Anthropic documentation
Without knowing what "good" or "finished" looks like, how can you begin to evaluate the work that AI in response to your prompt?
A strong recommendation for new and existing projects is to create a "product requirements document" that lives at the root of your code base to provide as much context as possible about its aims and functionality.
So before you next prompt, think about “I need X that achieves Y measured by Z.”
```:Prompt
Please review this marketing copy. It is intentionally short, deliberate to the point and conversational.
The goal of this copy is to dramatically increase conversions. The target audience is busy professionals. If this landing page works I'm more likely to get a promotion.
```
The first part of this prompt is the "what," the second part explains the "why" and the benefits of being succesful.
### Provide examples
While prompts *describe* what you want, the best way to keep an LLM on track is to *show* it what you want.
A simple prompt without examples might ask a question or define a task to be done. But the response will be greatly enhanced by providing an example of what you are going to provide and how you would like it to be returned.
```:Prompt
I'm writing a course on how to build web applications with AI tooling. It is primarily directed at people with application development experience, but some novices want to take the course as well. I want to build out a glossary of terms page for topics that they may be expected to know. Here are some examples. Can you think of any other useful terminology that a web development novice or beginner may not know? And write out more explanations of the same style as the examples that I'm providing.
INPUT:
GitHub
The world's most popular service for version controlling files. Developers make changes to files locally and "commit" their files to a GitHub repository in order to keep projects organized.
OUTPUT:
## {{ title }}
{{ description }}
```
Without the example "input" and "output" the model would likely have given me way too much information and formatted in some random way. With the examples I got exactly what I wanted, in the short manner in which I described.
When working with code, it is useful to provide snippets of what works or patterns you already implemented that should be copied.
### Invite criticism and failure
The default setting with most LLMs is subservient agreement. You can ask for some pretty ridiculous things, and the model will cheerfully enable you.
For this reason, it can be useful—particularly the less familiar you are with the code—to invite criticism and enable a failure mode if your ask is poorly defined or will lead to bad results.
```:Prompt
Change the button color to lime green. The intention is to make it more eye-catching so that more people click on the button.
If this will cause accessibility problems, or goes against our brand style guide, don't make this change and instead suggest alternatives toward the same goal.
```
### Provide context
Perhaps the most common reason prompts fall short of a user's expectation is a lack of context. LLMs simply do not know everything that you know.
Bad prompting highlights the "curse of knowledge" where a person may find it difficult to communicate a requirement to someone that does not have the same context they have.
Whatever context you leave out of your prompt will be filled by the LLM's own knowledge. This means whatever context you are missing makes getting the response you want a roll of the dice.
It can be time-consuming, but any amount of context, no matter how small, provided in the prompt will greatly improve the responses you receive.
This is also why it is a major benefit to put together files that contain your context that can be fed into every prompt—such as rules or product requirements documents.
Many LLM's will accept URL's which can be fetched and read for additional context.
### Role play
"System prompting" is a term given to beginning your prompt by asking the LLM to inhabit a character for their response.
You may have seen silly demos of this where LLM's respond by talking like a pirate or fictional characters.
The same method can be applied to tell the model that it is an expert in a particular topic or has a particular role within your organization.
```:Prompt
You are an expert in mobile application onboarding with high activation rates.
Please review our onboarding flow as described in the product requirements document and identify any friction that may lead users to bounce or churn.
```
Without the first part of this prompt the response may have been generic to address any website or application. Since we've narrowed the role down to "mobile" the response should be more useful.
## Summary
- Before prompting, create a definition of success to evaluate your responses
- Provide examples of what you want the response to contain
- Enable criticism and allow failure to reduce blind prompt following
- Provide context to reduce guess-work by LLM's
- Make the LLM and domain-expert with a system role to narrow the response
## [Voice dictated prompts](/learn/course/code-with-ai/voice-dictated-prompts)
You may find it much faster and more natural to write prompts with your voice rather than your hands. Here's how I like to do it.
I can type over 100 words per minute. I've been proud of that for a long time, to the point of—for better or worse—considering it part of my personality.
But there's just something about writing prompts that I find so laborious. It feels like micromanagement. Having to write out a task in full instead of just *doing* it.
Fortunately, I have found a way to reduce the friction of writing prompts: I don’t write them at all; I say them.
In fact, I barely “wrote” any of this course. The words you are reading right now, I spoke into my microphone and cleaned up with AI. What a time to be alive.
## Install Superwhisper
While there are many voice dictation tools available, and your system likely comes with one built in, I have found Superwhisper to be an excellent choice. It's free to try.
- [ ] **Download** and install [Superwhisper](https://superwhisper.com/).
## Speak a prompt
- [ ] **Open** a new chat on [ChatGPT](https://chatgpt.com/), [Claude](https://chatgpt.com/) or [Grok](https://grok.com/).
```:Prompt
List the planets of our solar system from smallest to largest. Measure by diameter in kilometres. Ignore dwarf planets.
```
You should get a nicely formatted response.
You may need to modify the prompt after it is transcribed to include markup for examples or URLs to more documentation.
### Speech-to-text alternatives
If for some reason you're not happy with Superwhisper, there are alternatives that will perform similarly.
- [VS Code Speech](https://marketplace.visualstudio.com/items?itemName=ms-vscode.vscode-speech) is an extension by Microsoft for use in VS Code
- [Wispr Flow](https://wisprflow.ai/) is a similar OS application with some additional features
Now we’re more comfortable with prompting, let’s add some guardrails to the results.
## [Cursor IDE](/learn/course/code-with-ai/writing-code-with-ai-assistance)
An introduction to Cursor, the AI-powered code editor you'll use in this course. Get to know the Chat window and the difference between an Ask and an Agent.
In 2021, [VS Code](https://code.visualstudio.com/) popularized AI coding assistance with GitHub Copilot. This coding assistant helps modify or complete single lines of code. Recently, it has been expanded into a chat interface and multi-line editing. While it continues to receive updates, in my opinion, it has not kept pace with alternative options.
This space is evolving so rapidly that it's likely that this course will be updated in future with alternate recommendations.
In this course, we'll use Cursor, a fork of VS Code. At the time of writing, it is emerging as the most popular Integrated Development Environment (IDE) for authoring code with AI assistance.
There are popular alternatives, but we will not use them in this course.
There are also AI tools that only use the command line. We will not use those in this course.
You may also have experience writing code through copy-and-paste sessions with in-browser AI tools like Open AI's ChatGPT or Anthropic's Claude. However, in this course, you'll use AI tools closer to the code base.
## Installation
Cursor is a free app which can be [downloaded from their website](https://www.cursor.com/). While the paid plan provides access to better models and features, the free version is still feature-rich. You should be able to complete this course without upgrading.
- [ ] **Download** and install [Cursor](https://www.cursor.com/)
Open Cursor, and you should see a screen something like this:

> [!NOTE]
> Cursor is a rapidly evolving application and so in some ways may look slightly different from the screenshots in this course.
## Command palette
One of the most powerful ways to move around an IDE is by using the command palette. Press `Cmd+Shift+P` to bring up the palette, in which you can through files in your project or settings in Cursor.
- [ ] **Open** the Command palette `Cmd+Shift+P` and type "Terminal" to create a new terminal
Notice that many Cursor commands will also show their keyboard shortcuts in the command palette. Keep an eye on these as moving around with these shortcuts is much more efficient.
## Terminal
The terminal is a Command Line Interface (CLI) in which you can run commands that will perform actions on your computer. It can be used to begin new coding projects, start local development servers and ... almost anything else.
Some terminals like [Warp](https://www.warp.dev/warp-ai) are AI powered, which will convert your prompts into commands which can be run, and the terminal will do its own debugging to address any issues.
The terminal in Cursor is **not** naturally AI-assisted, and so commands written into the terminal must be correct and specific to your computer. However you can use the Chat pane to interact with the terminal.
You can now close the terminal `Cmd+J`
## Chat and file panes
- [ ] Click **Open project** and create a new folder to work in
When you are opening and editing files, these will fill the middle section of the Cursor window. You should now also see two panes open to the left and right.
### Chat pane
On the left-hand side in my screenshot is the chat pane. This is where you can write prompts to LLMs which will interact with your project.
You can send prompts in "Ask" mode, which will just return responses in the chat pane, or you can change over to "Agent" mode, where Cursor will make changes directly to files in your project.
Toggle the Chat pane with `Cmd+I`, `Cmd+L` or using the Command Palette.

### File browser
The files in your project will begin to show up in the "Primary Sidebar" which can be toggled with the Command Palette or by pressing `Cmd+B`.
We don't have any files yet, but let's make our first in the next lesson.
## Sanity setup
In the following lessons you'll prepare your environment for the best possible implementation of Sanity. You could skip these steps, but your prompt responses would not nearly be as good.
Let's prepare for success.
## [Claude Code](/learn/course/code-with-ai/claude-code-ide)
An introduction to Claude Code, Anthropic's AI-powered coding tool. Learn how to install it, navigate its interface, and use it to write and edit code with AI assistance.
Claude Code is a terminal-based AI coding agent. Instead of a GUI, you work in your shell. It reads your project files, applies changes, runs commands, and explains what it's doing, all from the command line.
It's a good fit if you're already comfortable in the terminal and prefer not to maintain a full IDE setup, or if you're working on a remote machine or in a CI context.
## Installation
Claude Code is installed as a global npm package. Before you begin, make sure you have [Node.js](https://nodejs.org/) installed on your machine. npm comes bundled with it.
To install Claude Code, run the following command in your terminal:
```
npm install -g @anthropic-ai/claude-code
```
Claude Code is free to try, with usage-based pricing. You pay only for what you use. If you're already on a Claude Pro, Max, or Team plan, Claude Code usage is included.
- [ ] **Install** Claude Code by running `npm install -g @anthropic-ai/claude-code` in your terminal
Once installation is complete, you can verify it worked by running `claude --version` in your terminal. You should see the installed version number printed to the screen.
## Start a session
Unlike GUI-based IDEs, Claude Code has no separate application window. You do not launch it from a dock, a desktop shortcut, or an applications folder. It lives entirely in the terminal.
- [ ] **Navigate** to your project directory and run `claude` to start a session
Claude Code can read any file in the directory where you ran `claude`, and its subdirectories. The closer your working directory is to the task, the better its responses will be.
This opens an interactive session. Claude reads your project structure and is ready for prompts. You can describe what you want to build, and it will work through the task: editing files, running commands, iterating on errors.
## Chat and agent modes
Once Claude Code is running, you interact with it through a REPL-style interface. You type a prompt, press Enter, and Claude Code responds.
There are two broad ways to use it.
### Chat mode
You can ask Claude Code questions or request explanations about your code. This is similar to Ask mode in Cursor: you get a response in the terminal, but nothing in your project changes. It is useful for understanding unfamiliar code, exploring options, or getting a second opinion before making changes.
### Agent mode
You can also give Claude Code tasks to perform autonomously. In this mode, it will read files, write new ones, and edit existing code in your project, all without you having to copy and paste anything. By default, Claude Code shows you exactly what it intends to do and asks for your confirmation before applying any changes. You stay in control, and can loosen these guardrails later once you trust it for routine edits.
**A concrete example.** From a Sanity Studio project, a prompt like *"Add a category field to my post schema, with references to a new category document type, and regenerate the TypeGen types"* triggers a predictable pattern:
1. Claude Code reads `schemas/post.ts` and nearby schema files.
2. It drafts `schemas/category.ts`, adds a `reference` field to `post`, and shows you both diffs.
3. After you approve, it runs `npx sanity schema typegen` and shows the updated `sanity.types.ts`.
4. If the TypeGen run fails, it reads the error, proposes a fix, and asks again.
The pattern holds for most tasks: read → propose → confirm → apply → verify.
- [ ] **Type** a question or task into the Claude Code prompt and press Enter to see it respond
## Slash commands
Claude Code supports slash commands for common workflows. Type `/` in a session to see what's available. The Sanity Agent Toolkit (covered in the next lesson) adds its own slash commands here, which is one reason Claude Code pairs well with Sanity development.
## How it compares to Cursor
The main difference is the environment, not the capability. Claude Code uses the same underlying model as Cursor's Agent mode. The practical difference is that Claude Code is fully keyboard and terminal-driven, with no GUI for reviewing file diffs visually. Some developers find this faster; others find the Cursor interface easier to navigate when changes are complex.
## Sanity setup
In the following lessons you'll prepare your environment for the best possible Sanity implementation. You could skip these steps, but your results wouldn't be nearly as good.
Let's prepare for success.
## [OpenAI Codex](/learn/course/code-with-ai/openai-codex-ide)
Install Codex CLI, start a session in your project, and learn the prompt-and-confirm loop that makes it useful for Sanity work.
Codex CLI is OpenAI's terminal-based AI coding agent. Like Claude Code, it runs in your shell, reads your project files, makes edits, and works through tasks autonomously. No IDE required.
It's a good fit if you're already in a ChatGPT-heavy workflow, or if you want to run multiple agents in parallel across different parts of a project (Codex supports subagents for this).
## Installation
```sh
npm install -g @openai/codex
```
You'll need a ChatGPT Plus, Pro, Business, Edu, or Enterprise account. Codex is included in these plans.
- [ ] **Install** Codex CLI by running `npm install -g @openai/codex` in your terminal
Once installation is complete, you can verify it worked by running `codex --version` in your terminal. You should see the installed version number printed to the screen.
## Start a session
Unlike Cursor, which embeds a terminal pane inside a graphical IDE, Codex CLI runs entirely in the terminal. There is no graphical user interface. The terminal is the interface.
- [ ] **Navigate** to your project directory and run `codex` to start a session
On first run, Codex prompts you to authenticate with your ChatGPT account. Once in, you'll see the interactive TUI: a prompt where you describe what you want done, and Codex works through it.
**A concrete example.** From a Sanity Studio project, a prompt like *"Add a category field to my post schema, with references to a new category document type, and regenerate the TypeGen types"* triggers a predictable pattern:
1. Codex reads `schemas/post.ts` and nearby schema files.
2. It drafts `schemas/category.ts`, adds a `reference` field to `post`, and shows you both diffs.
3. After you approve, it runs `npx sanity schema typegen` and shows the updated `sanity.types.ts`.
4. If the TypeGen run fails, it reads the error, proposes a fix, and asks again.
The pattern holds for most tasks: read → propose → confirm → apply → verify.
## Slash commands
Type `/` in a session to see available commands. `/mcp` shows your connected MCP servers once you've set those up in the next lessons.
## How it compares to Claude Code
Both are terminal-based agents that operate on your local files. The main differences are the underlying model (GPT vs. Claude) and some workflow tooling. Codex has built-in subagents for parallelising tasks, while Claude Code has tighter integration with the Sanity Agent Toolkit via its plugin system. Either works well for this course.
## Sanity setup
In the following lessons you'll prepare your environment for the best possible Sanity implementation. You could skip these steps, but your results wouldn't be nearly as good.
Let's set things up properly from the start.
## [Add best practice skills](/learn/course/code-with-ai/agent-toolkit-skills)
The Sanity Agent Toolkit gives your AI assistant opinionated, Sanity-specific guidance—so it writes better code from the start.
Without guidance, AI tools write generic Sanity code. They know the APIs, but they won't follow our opinionated best practices—things like how to structure schemas, write GROQ queries, handle localization, or set up Visual Editing. The Sanity Agent Toolkit fixes that.
## What is the Sanity Agent Toolkit?
The Sanity Agent Toolkit (github.com/sanity-io/agent-toolkit) is a collection of resources that helps AI agents build better with Sanity. It includes:
- 20+ context skills covering schema design, GROQ, Visual Editing, SEO, localization, migrations, and framework integrations
- A Claude Code plugin with slash commands and interactive skills for common workflows
- A knowledge router (AGENTS.md) that directs AI to the right guidance based on task keywords
## Install with Skills
The fastest way to add the toolkit to your project is with the skills CLI. Run this from your project directory:
```sh
npx skills add sanity-io/agent-toolkit
```
This installs the toolkit's rules and skills directly into your project, making them available to your AI assistant in Cursor and other supported editors.
## What changes after install?
Once installed, your AI assistant will have access to Sanity-specific guidance whenever it's working on your project. You'll notice it makes better decisions about schema design, writes more idiomatic GROQ, and avoids common pitfalls—without you having to explain Sanity conventions in every prompt.
- Read more in the [Get started with AI-powered Sanity development](https://www.sanity.io/docs/ai/ai-quickstart) guide
## [Give your AI the full picture](/learn/course/code-with-ai/import-the-sanity-documentation)
LLMs know a lot, but not everything. Here are several ways to make sure your AI assistant always has accurate, up-to-date Sanity context.
AI tools are trained on public data up to a cutoff date. That means they may have outdated knowledge of Sanity APIs, miss recent features, or fill gaps with plausible-but-wrong code. Here are the best ways to keep your AI well-informed.
## Use the MCP server (recommended)
The Sanity MCP server includes built-in search_docs and read_docs tools. When connected, your AI can look up accurate, current documentation on demand—without you having to paste anything in. This is the most reliable approach.
## Use llms.txt for comprehensive context
The Sanity docs are available in the LLM-friendly llms.txt format. You can point your AI to either of these URLs:
- `/docs/llms.txt` — an abbreviated index of all content with links
- `/docs/llms-full.txt` — the complete documentation corpus as markdown
All Sanity Learn course and lesson content is also available at /learn/llms.txt and /learn/llms-full.txt.
## Add docs via @Docs in Cursor
In Cursor, you can index the Sanity documentation directly by typing @Docs in the agent chat and adding sanity.io/docs as a source. This gives Cursor persistent, searchable access to the docs without needing to paste content into every prompt.
## Copy individual articles
Every article on sanity.io/docs has a Copy article button that puts the markdown version on your clipboard. You can also append .md to any docs URL to get the raw markdown. Useful when you need to give your AI a specific reference for a focused task.
- See all options in the [Get started with AI-powered Sanity development](https://www.sanity.io/docs/ai/ai-quickstart) guide
## Request markdown directly with Accept: text/markdown
Both sanity.io/docs and sanity.io/learn now support content negotiation. Any page can return its content as markdown by sending an Accept: text/markdown header in the request. This means you can fetch any docs article or Learn lesson as clean, LLM-ready markdown without scraping HTML.
```sh
curl -H "Accept: text/markdown" https://www.sanity.io/docs/ai/mcp-server
```
This is especially useful when you want to give your AI a specific article as context, or when building tools that programmatically fetch Sanity documentation.
- Want to implement this pattern in your own Sanity-powered site? Take the [Markdown Routes with Next.js](https://www.sanity.io/learn/course/markdown-routes-with-nextjs) course on Sanity Learn.
## [Connect to the Sanity MCP server](/learn/course/code-with-ai/rapidly-generating-placeholder-content)
The Model Context Protocol (MCP) allows AI tools to reach out and perform actions on external applications—like your Sanity project.
The Sanity MCP server is a hosted service at mcp.sanity.io that gives AI assistants like Cursor direct access to your Sanity project. Once connected, your AI can execute GROQ queries, read and write documents, explore your schema, and more—without you having to paste in context manually.
## Quick install
The fastest way to get set up is with the Sanity CLI. Run this command from your project directory—it auto-detects Cursor, Claude Code, and VS Code and configures the MCP server for you:
```sh
npx sanity@latest mcp configure
```
- This uses your logged-in CLI user for authentication. No manual token management needed.
## Install into Cursor manually
If you prefer to configure manually, open the Command Palette (Cmd+Shift+P / Ctrl+Shift+P), run View: Open MCP Settings, select + New MCP Server, and add the following to your mcp.json file:
```json
{
"mcpServers": {
"Sanity": {
"type": "http",
"url": "https://mcp.sanity.io"
}
}
}
```
Save the file. Cursor will detect the new server and prompt you to authenticate via OAuth.
## Verify the connection
Open Cursor's agent chat and ask:
```
Do you have access to the Sanity MCP server? What can you tell me about the project?
```
If it responds with details about your schema and project, you are connected and ready to go.
- For the full list of available tools—GROQ queries, document creation, schema exploration, and more—see the [Sanity MCP server documentation](https://www.sanity.io/docs/ai/mcp-server).
## [Define your project specifications](/learn/course/code-with-ai/creating-a-prd-with-ai)
Now we have the perfect environment to build something. But that something is still undefined.
It could be that we've got a great idea in our heads of the content-driven application we'd like to build. But if it's not written down, our LLMs won't know anything about it.
One of the emerging ways to keep projects on track is "spec driven development". Where you create something like a "Product Requirements Document" (PRD) to describe what you're building and how you'd like the LLM to perform tasks. This typically lives at the root of the project as either `PRD.md` or `README.md`.
It's not always easy to know everything that you want before you start building anything. So the purpose of this document is not to get everything right in one shot, but to give the LLM a starting point to build.
It may be that you alter this document as your requirements change. Or should your work ever get out of sync with the document, you can prompt the LLM to update the document to match the current state of the project.
## What we're building
If you have a specific project in mind, you could take the example prompts in the rest of the course and modify them to what you are working on. If you don't have an idea of what you'd like to build, then follow the prompts and build with me!
- [ ] **Open** the Chat Panel, and in Agent mode prompt the creation of a PRD
```:Prompt
We need to create a PRD that describes the creation of a content-driven web application experience. The content backend will be powered by Sanity. The frontend will be an Astro template. We are trying to build a content model that describes a solar system. We'll start with our own solar system. And our frontend will render facts and images about the solar system.
Given this simplified outline, write a `PRD.md` file to the root of this project. We are in the early planning phases, so only address the requirements as I have discussed.
If you would like to clarify these requirements please ask questions.
```
Depending on the model that you have chosen, it may write your document, ask clarifying questions, or both. Review the work that it has done and address the questions to the best of your ability.
Remember one of the benefits of AI tooling is the ability to rapidly prototype, so there's nothing that we can't tear down, destroy, and start again.
## [Initialize a new Studio](/learn/course/code-with-ai/initialize-a-new-studio)
## Create a new Sanity project
Open Cursor and open the Terminal from the Command Palette or press `Cmd+J`
> [!WARNING]
> We could have "prompted" a new Sanity project into existence, but I wanted to make sure you start with this specific experience.
Run the following command to create a new Sanity project. If you are not logged into Sanity in the terminal, you will be asked to do so. If you do not yet have a Sanity account, you can create one for free.
The install command below has a few opinionated options preselected so that you won’t need to weigh up the options.
- [ ] **Run** the following from the command line to create a new Sanity project
```sh:Terminal
npm create sanity@latest -- --template blog --create-project "AI-powered Sanity" --dataset production --typescript --output-path apps/studio
cd apps/studio
```
> [!WARNING]
> Getting errors during the install process? If this is your first time developing you may not have the necessary software installed. Copy any errors you see in the **terminal** into a new **chat** and the LLM should help you debug your system.
The files in your project (press `Cmd+B` to open the file browser pane) should now look like this:
```sh:Terminal
ai-demo
├── .cursor/
│ └── rules
├── apps/
│ └── studio/
│ └── ...other files and folders
└── PRD.md
```
Install the dependencies as instructed in the `apps/studio` directory.
- [ ] **Run **the following from the command line to start the development server
```sh:Terminal
# in apps/studio
npm run dev
```
You can now open the Studio at [http://localhost:3333](http://localhost:3333) and log in.

You now have a configured Sanity project that is a cloud-hosted real-time content database and a local Sanity Studio development server that is an admin interface for authoring content.
In the script that created this project we chose the “blog” template to create a new Sanity Studio. This is why you can currently see Post, Author, and Category document schema types in your studio.
> [!WARNING]
> This course **won't** cover [Hosting and deployment](https://www.sanity.io/learn/studio/deployment)—see our documentation and other courses on Sanity Learn for more details.
You now have a Sanity Studio locally but it's only configured with a default blog template, let's prompt updates to the Studio to make it ours.
## [Create schema types](/learn/course/code-with-ai/create-schema-types)
Your Sanity Studio came with some content types for a blog. But this might not be what your project is about.
- [ ] **Open** the Chat Pane and prompt it to look through the context we've provided to create schema types according to our requirements
```:Prompt
Take a look at the @PRD.md file and create schema types in our Sanity Studio that express the content model.
Follow the guidance in @sanity.mdc for implementation.
If any of the instructions I have given you are unclear, please ask clarifying questions before proceeding.
```
This could take a little while!
Thanks to our product requirement document, the LLM has a good idea already of the sort of content we're going to be creating.
Thanks to our Sanity cursor rules, the LLM knows how to create content types the right way and what scripts to run after it has completed work to verify what it has done.
You may be prompted to execute a few scripts along the way. Follow the LLM's chain of thought in the Chat Pane as it works through each step of the request.
Once complete, your Sanity Studio should have a few more content types than before. Try creating a new document, yours may look something like this:

You may have individual content types for moons and planets, or it may have made one generic content type, something like "celestial body" in which you can select its type.
The variation between this prompt and your results can be minimized by more strict instructions in the PRD or the prompt.
This is where it pays to:
1. Have a definition of success, to know what good looks like.
2. Have a way to evaluate the quality of the response by comparing our product requirements with what has been generated.
3. Perform all of this in Cursor so that you if you need to reject this work, you can do with one button click (by clicking **Undo All**), update the prompt, the PRD (or both) and try again.
If you are happy with the results so far, you can proceed. Otherwise, refine your content model through prompting or updates to your product requirements before continuing.
## [Automatically generate content](/learn/course/code-with-ai/create-placeholder-content)
The Sanity MCP server is going to do the heavy lifting of creating our initial content.
Sanity Studio is the ultimate workspace to create and edit individual pieces of content. However, if we want to get a good feel for how our studio looks with content in it, and we want to have something to query in our front end later, we're going to need a lot more than a few blog posts.
This is why we installed the Sanity MCP server. It is able to not only query content and interact with the project, it can write data in huge volume with an understanding of your Sanity Studio schema.
You've already set up and confirmed the installation of the MCP server, but just to be sure, you can write a quick prompt now to confirm the LLM has access to the tools.
- [ ] **Open** the Chat Pane and **prompt** the LLM to confirm it has access to the Sanity MCP server.
```:Prompt
Do you have access to the Sanity MCP server? What can you tell me about the project currently?
```
> [!NOTE]
> The MCP server relies on a deployed version of your Sanity Studio schema, which this course has not yet prompted you to do. Your LLM might prompt you to do this, or it may just do it itself.
Hopefully you now have confirmation that the Sanity MCP server is operational and your LLM understands the project.
- [ ] **Prompt** the LLM to create a document
```:Prompt
Please just create one document first, one that represents the Sun in our solar system.
```
Find the document that was created and verify that you're happy with the results.
## Examine the content and schema
This is a good opportunity to double-check that the schema types that were created in the previous lesson are actually suitable for the content being created in this lesson.
For example, my content schema does not contain a `type` of "star," so it has described the sun as a "planet."
It has also decided to store `mass` in kilograms, which is going to require numbers larger than it makes sense to store.
With all the context that we have given our LLM, it should be smart enough to be able to make changes to the schema, redeploy the schema, and update the document using the MCP server.
The following prompt is specific to the schema type in my project, you may need to adjust it to suit your results:
```:Prompt
1. Update the schema for this document type to include "star" as an option.
2. You attempted to write "mass" as a string, but the schema type was setup for kilograms. Modify the schema for this document type to use a more suitable measure than kilograms.
Update the schema types, deploy the schema, and update the document that you have just created.
```
## Prompt bulk content
Once you've modified your schema and content into a shape you're happier with, it's time to prompt for the rest.
- [ ] **Prompt** the LLM to create documents for the planets and moons
```:Prompt
Perfect, can we please create documents for the planets and moons of the solar system
```
Your Studio should now have many documents filled with rich information. This is perfect for creating a front-end.
## [Initialize a new front end](/learn/course/code-with-ai/adding-sanity-content-to-any-front-end)
You can pick whatever framework you'd like to complete this lesson. It's up to AI—and your prompting skills—to make it work.
In every course that I have ever written before this one, I've had to be very specific about what steps you are going to take. However, I can take a great liberty with this in an AI-powered course because I am relying on the AI's ability to perform most of the technical tasks.
All we need to do is describe *what* we want, not recall *how* we want it.
And so instead of telling you which framework to use, I'm going to trust the AI has a reasonable understanding on the best way to do things.
Admittedly, this is not the most reliable method to get bulletproof code. If you do want to start with a particular framework, we have many templates available, which will give you a handcrafted starting point.
> [!TIP]
> [Visit the Templates page](https://www.sanity.io/templates) for hand-written starter kits and inspiration
## Prompt a front end
Next.js is the most popular front-end framework, Sanity already provides many templates and tutorials for it.
I'm not an expert in Astro, so I'm going to use it instead in this course.
- [ ] **Open** a new Chat in "Agent" mode and prompt it to create a new project in the front end of your choice
```:Prompt
Take a look at @PRD.md. I want to begin the process of building a frontend for our content. For this, I’d like to use the Astro framework, written in TypeScript and styled with Tailwind CSS. For now, just start with a homepage that queries and renders data from our Sanity project.
Here's some documentation on how to best integrate Astro + Sanity:
@https://github.com/sanity-io/sanity-astro
```
Adding the URL to the official Astro + Sanity integration keeps the LLM on track by providing examples. Find a guide or template you like and supply it as an input example.
You should now have a great home page with editable content. Make some changes in Sanity Studio and you should see them update on your front-end.
## Back-to-front coding
One of the major benefits of having both your back and front end in code is now that you can make changes to both at once. Say you wanted to rename a field or add a new field to a document type. You could write a prompt that will update both the Sanity Studio configuration and the front end layout.
## [Continuing to build](/learn/course/code-with-ai/building-more-routes)
Right now you only have a homepage, but what you do next is up to you.
## Update the PRD
The product requirements document that you created initially may now be a little out of line with what has actually been developed. Whether you change the document to suit what you have created or you prompt changes to what you have created based on the document is up to you.
```:Prompt
Examine the files in the project and how they relate to the product requirements document. Give me a list of ways in which they differ, whether there are tasks yet to be done in the product requirements, or if there are features built out in the project that were never specified. Give me a list to decide which actions to take.
```
## Create more routes
If you know what additional pages you would like to have in your web application, you could prompt the LLM to create these. Or you might ask it to have a look at the schema types in your studio and suggest what pages it makes sense to build next.
```:Prompt
Look at our Sanity Studio schema types and what we have built in our frontend application. We will need to create more routes, likely for individual celestial bodies, but perhaps for blog posts or other web pages as well. Give me a list of suggested schema types that we should turn into frontend pages.
```
## Version control
While all these files are saved on your computer, you have no version control system in place. The most popular one in development is Git, and the most popular provider is GitHub.
LLMs understand Git very well, and having version control set up in your project will be invaluable should your LLM ever go off the rails and make far too many changes that you don't agree with.
You will need to setup a Git repository in order to deploy your frontend.
```:Prompt
I need to set up Git in this project so that we can track changes and push them to a repository. Please help guide me through the process.
```
## Deployment
Currently, your frontend and studio are both only visible on your computer. You can prompt the LLM to help guide you through the process of going live.
Your studio can be deployed to Sanity's hosting. Your frontend can be deployed to one of many providers. Vercel is likely the easiest.
```:Prompt
I'd like to deploy the Sanity Studio to Sanity's own hosting and deploy my frontend to Vercel. Can you help guide me through the process for each?
```
## Go nuts
LLMs allow us to take on tasks that we otherwise may feel too daunted by to even consider...
```:Prompt
I'd like to use Three.js to create an interactive 3D model of the solar system on our Astro frontend driven by our Sanity Studio.
Plan this out in our product requirements document and make any necessary changes required to our Sanity Studio schema for missing fields or content that will help us map our Sanity documents to a 3D model.
```## [AI-unassisted quiz](/learn/course/code-with-ai/ai-unassisted-quiz)
AI can't help you here. Let's reflect on what you've learned.
We're in a wonderful future where AI can do most of the grunt work for you and you can focus on solving problems of yours and your authors. But without intentionality to commit your new understandings to memory you may find yourself tripping over time and time again.
Here's a quick quiz to reinforce what you've learned in this course. You're on your own, now.
> **Question:** What will an "agentic" AI tool do?
>
> 1. Brainstorm more deeply
> 2. Take actions independently **[correct]**
> 3. Give better responses
> 4. Work faster
> **Question:** What will improve the response from a prompt
>
> 1. Shorter words in your prompt
> 2. A faster internet connection
> 3. More context about what you're doing **[correct]**
> 4. Writing TypeScript
> **Question:** What is the purpose of Cursor rules?
>
> 1. To get the same result from prompt responses
> 2. To add guardrails to prompt responses **[correct]**
> 3. To get faster prompt responses
> 4. To teach the AI new skills
> **Question:** Before having an AI write code, you should
>
> 1. Take a good, hard look at yourself
> 2. Memorize the documentation
> 3. Ask it about the best approach forward **[correct]**
> 4. Ask it to rewrite your app in Rust
> **Question:** The major benefit of AI tooling for development is:
>
> 1. Building applications with code you don't understand
> 2. Forcing authors to configure their own apps
> 3. Rapidly prototyping things no one will ever use
> 4. A way to do better work, faster **[correct]**# [Content-driven web application foundations](/learn/course/content-driven-web-application-foundations)
Combine Sanity and Next.js and deploy to Vercel via GitHub to get the fundamentals right. Powering a fast and collaborative development and content editing experience.
## [Building content-editable websites](/learn/course/content-driven-web-application-foundations/building-a-content-editable-website)
Sanity powers content operations beyond a single website or application, while Next.js focuses on best-in-class content delivery. Combine them into a powerful modern stack to build content-driven experiences.
> [!WARNING]
> The videos in this course, in parts, are out of step with the written lessons. Follow the lesson text and code examples for the latest implementation best practices.
There are no shortcuts to achieving outstanding results. Time spent learning the fundamentals of website development in a modern context will set you up for future success.
## About this course
There are [ready-made templates](https://www.sanity.io/templates) to create websites.
There are "One-click Deploy" buttons to rapidly get something online.
You'll get *something* faster with those but learn very little.
This course will teach you how developer teams build production-ready web applications from the ground up and gain an appreciation of Sanity and Next.js from first principles.
To complete this course, you will copy and paste commands, create and modify local files, set up your repository, and deploy from your Vercel account.
### Building "Layer Caker"

Throughout the courses in this track, you'll play the role of a developer tasked with beginning the construction of a web application for a cake-manufacturing superstore, Layer Caker.
By the end of this first course, you will have created and deployed a blog on Next.js using Tailwind CSS for styling and an embedded, configurable content management dashboard called Sanity Studio.
Future courses within this track will continue to expand on this with interactive live previews for Visual Editing and website specifics like page building and SEO. There will also be demonstrations of moving away from presentational thinking and towards structured content.
### About the author
My name is Simeon Griggs, and I've been building, deploying, and selling content-editable websites for over a decade. I wrote this course to help you make great websites for your end-users, collaborate confidently, and power the best content operations for creators.
Throughout this course, you'll work through lessons with the least friction possible to accelerate your momentum. I've worked with, on, and at Sanity to understand how it is best used. I have also done the research with Next.js to give you best-practice choices, not decision fatigue or burdensome homework.
I wrote this course to do things quickly and correctly. That means a little setup work on your first project, but once you've built a solid foundation, you'll fly through future projects.
You'll learn plenty.
### Why build a content-driven website?
As a developer, you should not be a bottleneck to the availability of accurate and valid content for end-users. Your content creators deserve the tools to perform content operations rapidly without developer intervention.
Content Management Systems (CMSes) have come a long way since monolithic platforms with click-and-play website builders. Sanity Studio—the configurable dashboard you will embed in your Next.js application—is just the CMS part of the Sanity platform which also includes features like a content delivery CDN, asset management and webhooks.
User expectations both to consume and create content are higher than ever. Thankfully, the technology for powering great experiences from content is also more sophisticated.
## Getting started
The first course in this track focuses on the **basics** of developing a Next.js web application. If you're more experienced and seeking concise guidance on topics like TypeScript and caching, the [`next-sanity` readme](https://github.com/sanity-io/next-sanity) might be a better place to start.
### Prerequisites
To complete this course, you will need the following:
- A free Sanity account to create new projects and initialize a new Sanity Studio. If you do not yet have an account, you'll be prompted later in this course to create one.
- Some familiarity with running commands from the terminal. Wes Bos' [Command Line Power User](https://commandlinepoweruser.com/) video course is free and can get you up to speed with the basics.
- [Node and npm installed](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) (or [an npm-compatible JavaScript runtime](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Understanding_client-side_tools/Package_management#what_exactly_is_a_package_manager)) to install and run the Next.js development server locally.
- [`pnpm` installed](https://pnpm.io), though you could swap out commands for `npm`
- Some familiarity with JavaScript and React. The code examples in this course can all be copied and pasted and are written in TypeScript, but you will not need advanced knowledge of TypeScript to proceed.
If you're stuck or have feedback on the lessons here on Sanity Learn, [join the Community Slack](https://slack.sanity.io/) or use the feedback form at the bottom of every lesson.
Ready? Let's start by creating a new Next.js application.
## [Create a new Next.js 16 application](/learn/course/content-driven-web-application-foundations/create-a-new-next-js-application)
Create a new, clean Next.js application with a few opinionated choices for TypeScript and Tailwind CSS.
There are many technology choices available to make a web application. So why was Next.js chosen for this course?
- JavaScript is the most popular programming language for writing server and client web applications.
- React is the most popular library for writing JavaScript-powered applications.
- By a large margin, Next.js is the most popular meta-framework for React.
- Next.js also has a large community following for extra support and useful utilities.
- It also has an excellent deployment developer experience with Vercel.
- Best of all, Next.js has a tight integration with Sanity.
In short, if your day job involves building web applications on a developer team, there's a good chance you're doing it with Next.js.
Next.js is not without its challenges. It typically operates at the leading edge of React, so you may interact with React features not yet considered stable. Some architectural decisions, such as caching, can cause confusion. However, this course aims to demystify some of these challenges.
## Create a new Next.js application
- [ ] **Run** the following command to create a new Next.js application:
```sh
pnpm dlx create-next-app@16 layer-caker --typescript --tailwind --eslint --app --src-dir --import-alias="@/*" --turbopack --react-compiler
```
The options in the command above configure your app to use:
- TypeScript
- [Tailwind CSS](https://tailwindcss.com/)
- [eslint](https://eslint.org/)
- The [App router](https://nextjs.org/docs/app)
- A `src` directory for your application's files
- The default import alias for your application's files
- Turbopack
- React Compiler
These are all the default settings for a new Next.js application. The flags in the command above save you from having to select these options.
You may modify the command above to make different choices, but the following lessons contain code snippets that assume these are the settings you used.
- [ ] **Run** the development server
```sh
pnpm run dev
```
Your app should start up in the terminal in development mode:
```
> layer-caker@0.1.0 dev
> next dev
▲ Next.js 16.0.1 (Turbopack)
- Local: http://localhost:3000
- Network: http://192.168.4.154:3000
✓ Starting...
✓ Ready in 591ms
```
Open [http://localhost:3000](http://localhost:3000). You should see the default home page for a new Next.js application like the one below:

As recommended, you can edit the `src/app/page.tsx` file and see updates instantly. In the following lessons, you'll be given code examples to update this home page route and create new pages.
## Update Tailwind CSS implementation
> [!WARNING]
> The video for this lesson shows Tailwind 3 configuration, but you now have Tailwind 4 installed. Follow the code examples below.
The Next.js starter has fonts and styles you don't need for this course, so you'll remove them for simplicity.
- [ ] **Update **`layout.tsx` to remove custom fonts
```tsx:src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
{children}
);
}
```
- [ ] **Update** `globals.css` to remove anything other than Tailwind's import
```css:src/app/globals.css
@import "tailwindcss";
```
The app in development should still look mostly the same. You'll add more content and styling in the following lessons.
You now have a Next.js application with Tailwind CSS for styling. However, it lacks content management, so the next step is to set up a Sanity account and initialize Sanity Studio inside your Next.js project.
## [Create a new Sanity project](/learn/course/content-driven-web-application-foundations/create-a-new-sanity-project)
Create a new free Sanity project from the command line and automatically install Sanity Studio configuration files into your Next.js project.
For your Next.js application, Sanity will play the role of content storage for documents and assets such as images. That content is cloud-hosted in what we call the Sanity [Store and query structured content](https://www.sanity.io/learn/content-lake).
In this lesson, you'll create a new project at Sanity and embed an editing interface—[Studio](https://www.sanity.io/learn/sanity-studio)—inside the Next.js application. An embedded Studio allows you to create, edit, and publish content hosted in the Content Lake from your Next.js application's development environment or wherever it is deployed.
The Sanity Content Lake also powers content operations workflows, such as firing fine-grained [GROQ-powered webhooks](https://www.sanity.io/learn/content-lake/webhooks) so your business can react to content changes as they happen. In time, your Next.js application may also *write* content – such as comments and likes – into the Content Lake from the front end.
While this course focuses on building a web application, Sanity is more than a website-focused CMS (content management system).
In a nutshell, Sanity is a *Content Operating System*, with a configurable, React-based administration panel, cloud-hosted data storage, and a worldwide CDN for content delivery.
## Create a new project
The Sanity CLI can initialize a new Sanity project within a Next.js application. It detects the framework during the process and prompts you to make appropriate choices.
If you do not yet have a Sanity account, follow the prompts to create one.
> [!NOTE]
> You can create new free Sanity projects at any time.
- [ ] **Run** the following command inside your Next.js application to create a new free project from the command line:
```sh
pnpm dlx sanity@latest init
```
When prompted, make the following selections. If you accidentally select the wrong option, you can cancel and re-run the command again.
- [ ] **Create** a new project, call it what you like, for example `layer-caker`
- [ ] **Create** a dataset with the default settings: public and named `production`
- [ ] **Add** configuration files to the Next.js folder
- [ ] **Use **TypeScript
- [ ] **Embed** Sanity Studio at `/studio`
- [ ] **Select** the `blog` template
- [ ] **Add** your project details to an `.env.local` file
### What just happened?
This command:
1. Created a new Sanity **project** and **dataset**, which are remotely configured and hosted on the Content Lake
1. A **dataset** is a collection of content (text and assets) within a project hosted in the Sanity [Store and query structured content](https://www.sanity.io/learn/content-lake).
2. A **project** can have many datasets and is also where you'd configure other project-level settings like members, webhooks, and API tokens.
2. Added relevant files to your local Next.js application and installed some dependencies that you'll need to get started.
Your Sanity Studio code in the Next.js application is like a "window" into the remotely hosted content. Your Studio configuration code determines which document types are available to create, update, and delete. All the content you author is hosted in the Content Lake.
In short, with Sanity:
- **Studio configuration** is performed locally with code.
- **Content **(text and assets) is hosted remotely.
- **Project configuration** is handled at [sanity.io/manage](https://www.sanity.io/manage).
### New project files
**In addition to** your Next.js files, you should have the following files in your project. These files configure:
- Sanity Studio for creating content
- Sanity Client for querying content
- A helper file to display images on the front end, `src/sanity/lib/image.ts`
```
.
├── .env.local
├── sanity.cli.ts
├── sanity.config.ts
├── (...and all your Next.js files)
└── src
├── app
│ └── studio
│ └── [[...tool]]
│ └── page.tsx
└── sanity
├── lib
│ ├── client.ts
│ ├── image.ts
│ ├── live.ts
├── schemaTypes
│ ├── authorType.ts
│ ├── blockContentType.ts
│ ├── categoryType.ts
│ ├── postType.ts
├── env.ts
└── schema.ts
```
### Hello, Sanity Studio
Browse your embedded Sanity Studio route at [http://localhost:3000/studio](http://localhost:3000/studio) to see your built-in content management system.
Make sure you log in with the same credentials you used to log in to the Sanity CLI in your terminal.
> [!WARNING]
> If you see the Studio but not these three document types (posts, categories, authors) on the left-hand side, you may have chosen the "clean" template instead. Re-run the `sanity init` command above to change.

You're embedding the Sanity Studio within the Next.js application for the convenience of managing everything in one repository**.** It's also convenient for authors to only need to know one URL for their front end and content administration. However, it can promote website-specific thinking.
> [!NOTE]
> Remember, content representing your business goes far beyond a few web pages. For now you only have blog content schema types in your Sanity Studio, but you can expand it to much more!
Fortunately, if you ever decide to separate your Sanity Studio into its repository—or both applications into a mono repo—it should be a straightforward process of moving the configuration files around. The data storage of your text and assets would remain unchanged in the Content Lake.
The `blog` template gave you three website-specific schema types: `post`, `category` and `author`. You can now create content of these types within your embedded Sanity Studio.
## Create and publish posts
Soon, you'll be querying for content on the front end. For this to work, you'll need to create some.
- [ ] **Create** and **Publish** at least one `post` document type

### Or use our seed data
We have prepared a dataset for you to speed up the process. You can optionally download and import this into your project.
> [!TIP]
> Download `production.tar.gz` – a pre-prepared dataset backup with assets, posts, categories, and authors.
Place this file in the root of your project and import it using the CLI.
```sh:Terminal
pnpm dlx sanity dataset import production.tar.gz production
```
Delete the backup file once the import successfully completes.
```sh:Terminal
rm production.tar.gz
```
You have content in your Studio, but your front-end is not yet configured to display it. In the next lesson, let's unpack the bridge between your Sanity content and front-end.
## [The next-sanity toolkit](/learn/course/content-driven-web-application-foundations/the-next-sanity-toolkit)
Unpack next-sanity, the all-in-one Sanity toolkit for "live by default," production-grade content-driven Next.js applications.
One of the dependencies automatically installed during `sanity init` in the last lesson was [`next-sanity`](https://github.com/sanity-io/next-sanity), a collection of utilities and conventions for data fetching, live updates, Visual Editing, and more. You could look through the readme for full details on what it provides.
For now, let's examine some of the files that were automatically created in the previous lesson and explain their purpose.
## Environment variables
A `.env.local` file should have been created with your Sanity project ID and dataset name. These are not considered sensitive, and so are prepended with `NEXT_PUBLIC_`.
> [!TIP]
> See the Next.js documentation about [public and private environment variables](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables).
In future lessons, you'll add secrets and tokens to this file. It is important that you **do not** check this file in your Git repository. Also, remember that values in this file will need to be recreated when deploying the application to hosting. We'll remind you of this when we get there.
- [ ] **Confirm** you have an `.env.local` file at the root of your application.
```scss:.env.local
NEXT_PUBLIC_SANITY_PROJECT_ID="your-project-id"
NEXT_PUBLIC_SANITY_DATASET="production"
```
Additionally, a file to retrieve, export, and confirm these values exist has been written to `src/sanity/env.ts`
> [!NOTE]
> You can use Sanity CLI to update these values with a new or existing Sanity project by running `sanity init` again with the `--env` flag
```sh
pnpm dlx sanity@latest init --env
```
## Sanity Client
The file `client.ts` contains a lightly configured instance of Sanity Client.
```typescript:src/sanity/lib/client.ts
import { createClient } from 'next-sanity'
import { apiVersion, dataset, projectId } from '../env'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
})
```
Sanity Client is a JavaScript library commonly used to interact with Sanity projects. Its most basic function is querying content, but once authenticated with a token, it can interact with almost every part of a Sanity project.
> [!TIP]
> See more about what [Sanity Client](https://www.sanity.io/docs/js-client) can do
You won't need to change the Sanity Client configuration now, but it is good to know where to make modifications later.
### sanityFetch and SanityLive
In the file `live.ts`, the preconfigured client is used to export a function `sanityFetch`, and the component `SanityLive`.
```typescript:src/sanity/lib/live.ts
import { defineLive } from "next-sanity/live";
import { client } from "@/sanity/lib/client";
export const { sanityFetch, SanityLive } = defineLive({client});
```
- `sanityFetch` is a helper function to perform queries, and under the hood it handles the integration with Next.js tag-based caching and revalidation, as well as Draft Mode.
- `SanityLive` is a component which creates a subscription to the [Live Content API](https://www.sanity.io/learn/content-lake/live-content-api) and will automatically revalidate content as it changes.
These two exports are the foundation of "Live by default" experiences in Next.js applications. In future lessons you'll implement these and learn how they work.
## Sanity Config and CLI
The two root files `sanity.cli.ts` and `sanity.config.ts` are important for interacting with your project:
- `sanity.cli.ts` allows you to run CLI commands (like `dataset import` from the previous lesson) that affect the project while targeting the correct project ID and dataset
- `sanity.config.ts` is used to configure the Sanity Studio, including schema types, plugins, and more.
- [ ] Run the following command to show project details:
```sh
pnpm dlx sanity@latest debug
```
## Schema Types
In the `src/sanity/schemaTypes` folder are files for the three document types and one custom type which you can see in the Studio.
You're able to create `category`, `post` and `author` type documents because these have been registered to the Studio configuration.
Datasets are schemaless, so data of any shape could be *written* into a dataset. But these are the only schema types currently configured in the *Studio*. In future lessons, you'll change and add to these schema types, but they give us enough to work with now.
> [!TIP]
> See [Improving the editorial experience](https://www.sanity.io/learn/course/studio-excellence/improving-the-editorial-experience) in [Day one content operations](https://www.sanity.io/learn/course/day-one-with-sanity-studio) to see how basic schema type configurations can be dramatically enhanced.
You now have a Next.js application with an embedded Sanity Studio for creating and publishing content. It's time to start integrating them.
Writing GROQ queries is the most common method of querying content from Sanity. In the next lesson, we'll set up conventions for this.
## [Query content with GROQ](/learn/course/content-driven-web-application-foundations/writing-groq-queries)
Organize and author queries for your content with best-practice conventions.
If you're new to Sanity, you're probably new to GROQ. It's an incredibly powerful way to query content, and thankfully, it's quick to get started with.
You'll only need to know the basics of writing queries for now. However, it is beneficial to learn GROQ when working with Sanity as it powers queries, [GROQ-powered webhooks](https://www.sanity.io/learn/content-lake/webhooks) and content permissions when configuring [Roles](https://www.sanity.io/learn/user-guides/roles).
This lesson is focused on writing basic GROQ queries to serve our Next.js application. Future lessons will expand on these queries.
> [!TIP]
> See [Between GROQ and a hard place](https://www.sanity.io/learn/course/between-groq-and-a-hard-place) for more thorough lessons on how to write expressive queries with GROQ.
> [!TIP]
> The [GROQ query cheat sheet](https://www.sanity.io/learn/content-lake/query-cheat-sheet) is the most popular resource for quickly finding useful query examples.
## What about GraphQL?
Sanity content is typically queried with GROQ queries from a configured Sanity Client. [Sanity also supports GraphQL](https://www.sanity.io/docs/graphql?utm_source=github&utm_medium=readme&utm_campaign=next-sanity). You may prefer to use GraphQL in your application, but these courses will focus on querying with Sanity Client and GROQ.
## GROQ basics
You can break up most GROQ queries into three key parts.
Consider this query:
```groq
*[_type == "post"]{title}
```
- `*`: returns **all documents** in a dataset as an array
- `[_type == "post"]` represents a **filter **where you narrow down the proceeding array
- `{ title }` represents a **projection** where you define which **attributes** in those array items you want to return in the response
## Organizing GROQ queries
`next-sanity` exports the `defineQuery` function which will give you syntax highlighting in VS Code with the Sanity extension installed.
- [ ] **Install** the [Sanity VS Code extension](https://marketplace.visualstudio.com/items?itemName=sanity-io.vscode-sanity) if this is the IDE you are using.
The `defineQuery` function also has another important role, [Sanity TypeGen](https://www.sanity.io/learn/apis-and-sdks/sanity-typegen) searches for variables that use it to generate Types for query results.
For convenience and organization, you'll write all queries inside a dedicated file in your project.
- [ ] **Create** a file to store two basic GROQ queries:
```typescript:src/sanity/lib/queries.ts
import {defineQuery} from 'next-sanity'
export const POSTS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)][0...12]{
_id, title, slug
}`)
export const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
title, body, mainImage
}`)
```
- `POSTS_QUERY` will return an array of up to 12 published documents of the type `post` that have a slug. From each document, it will return the `_id`, `title` and `slug` attributes.
- This can be used on a "posts index" page to show the latest posts.
- `POST_QUERY` filters down to `post` documents of the post type where the value the `slug` matches a passed-in variable `$slug`. Only one document is returned because of the `[0]` filter. From this one document, it will return the `title`, `body` and `mainImage` attributes.
### Testing GROQ queries
Before using these queries in your front end, it's possible to test them at any time from within your Sanity Studio using the Vision tool.
- [ ] **Open** [http://localhost:3000/studio/vision](http://localhost:3000/studio/vision), paste the `POSTS_QUERY` GROQ query string and click **Fetch**
```groq
*[_type == "post" && defined(slug.current)][0...12]{
_id, title, slug
}
```
You should see up to 12 items in the "result" panel.

Queries fetched in Vision use the same user authentication that the Studio does. So it will return private documents when using the [default perspective](https://www.sanity.io/learn/docs/content-lake/perspectives) – `raw`.
> [!NOTE]
> In a **public** dataset, a document is private if it has a period "`.`" in the `_id`, such as `{ _id: "drafts.asdf-1234" }` and can only be queried by an authenticated request. In a **private** dataset all documents are private.
The Sanity Client for your front end is not authenticated (unless you give it `token`) so it will only return publicly visible documents in a public dataset.
> [!TIP]
> See [Datasets](https://www.sanity.io/learn/content-lake/datasets) for more information about Public and Private datasets.
> [!TIP]
> [Perspectives for Content Lake](https://www.sanity.io/learn/content-lake/perspectives) determine whether published or draft documents are returned in the response.
Now that you've proven that your GROQ queries get results, let's automatically generate TypeScript types for these responses.
## [Generate TypeScript Types](/learn/course/content-driven-web-application-foundations/generate-typescript-types)
Add Type-safety to your project and reduce the likelihood that you will write code that produces errors.
In the case of working with [Sanity TypeGen](https://www.sanity.io/learn/apis-and-sdks/sanity-typegen), it can create Types for Sanity Studio schema types and GROQ query results. So, as you build out your front end, you only access values within documents that exist, as well as defensively code against values that could be `null`.
> [!TIP]
> The [Generating types](https://www.sanity.io/learn/course/day-one-with-sanity-studio/generating-types) Lesson has a more in-depth exploration of the `sanity typegen` command.
Sanity TypeGen will [create Types for queries](https://www.sanity.io/docs/sanity-typegen#c3ef15d8ad39) that are assigned to a variable and use the `defineQuery` function.
> [!NOTE]
> Note: The video in this lesson shows the older configuration method using sanity-typegen.json. As of Sanity CLI version 4.19.0, typegen configuration should be added to sanity.cli.ts instead. The instructions below reflect the current recommended approach.
## Extracting schema
You're able to use the Sanity CLI from inside the Next.js application because of the `sanity.cli.ts` file at the root of your project.
- [ ] **Run** the following command in your terminal
```sh
pnpm dlx sanity@latest schema extract --path=./src/sanity/extract.json
```
> [!NOTE]
> Re-run this every time you modify your schema types
The `--path` argument is provided so the schema file is written to the same folder as all our other Sanity utilities.
You should see a response like the one below and a newly generated `extract.json` file in your `src/sanity` directory
```sh
✅ Extracted schema
```
This file contains all the details about your Sanity Studio schema types, which TypeGen will need to create types from.
## Generating types
By default, TypeGen will create a file for types at the project's root. To keep Sanity-specific files colocated, you'll configure TypeGen in your `sanity.cli.ts` file to keep the project root tidy.
> [!WARNING]
> Without this configuration, Typegen will look for your schema in the default named `schema.json` file instead of the `extract.json` file we have created.
- [ ] **Update** the `sanity.cli.ts` file at the root of your project
```typescript:sanity.cli.ts
import {defineCliConfig} from 'sanity/cli'
export default defineCliConfig({
api: {
projectId: 'your-project-id',
dataset: 'your-dataset',
},
typegen: {
path: './src/**/*.{ts,tsx,js,jsx}',
schema: './src/sanity/extract.json',
generates: './src/sanity/types.ts'
},
})
```
The `typegen` configuration will:
1. Scan the `src` directory for GROQ queries to create Types.
2. Additionally, use the `extract.json` file created during the previous task.
3. Write a new `types.ts` file with our other Sanity utilities.
- [ ] **Run** the following command in your terminal
```sh
pnpm dlx sanity@latest typegen generate
```
> [!NOTE]
> Re-run this every time you modify your schema types or GROQ queries
You should see a response like the one below and a newly created `src/sanity/types.ts` file in your project.
```sh
✅ Generated TypeScript types for 15 schema types and 2 GROQ queries in 1 files into: ./src/sanity/types.ts
```
Success! You now have Types for your Sanity Studio schema types and GROQ queries.
## Automating TypeGen
The `extract.json` file will need to be updated every time you update your Sanity Studio schema types and TypeGen every time you do or update your GROQ queries.
Instead of doing these steps separately, you can include scripts in your `package.json` file to make running these automatic and more convenient.
- [ ] Update `package.json` scripts
```json:package.json
"scripts": {
// ...all your other scripts
"predev": "pnpm run typegen",
"prebuild": "pnpm run typegen",
"typegen": "sanity schema extract --enforce-required-fields --path=./src/sanity/extract.json && sanity typegen generate"
},
```
You can now run both the schema extraction and TypeGen commands with one line:
```sh
pnpm run typegen
```
You now have all the tools and configurations to author and query Sanity content with a Type-safe, excellent developer experience. Now it's finally time to query and display Sanity content.
## Automatic type inference
Sanity TypeGen contains a feature to map GROQ queries against their types automatically. However, this is done by extending the Sanity Client package, as you will see at the bottom of the automatically generated types file.
```typescript:src/sanity/types.ts
// Query TypeMap
import "@sanity/client";
declare module "@sanity/client" {
```
Since we are using the next Sanity package and have not installed Sanity Client directly, this automatic type inference may not work.
**Install** Sanity Client as a dependency to solve this before the next lesson.
```sh
pnpm add @sanity/client
```
## [Fetch Sanity Content](/learn/course/content-driven-web-application-foundations/fetch-sanity-content)
Query for your content using Sanity Client, a library compatible with the Next.js cache and React Server Components for modern, integrated data fetching.
Sanity content is typically queried with GROQ queries from a configured [Sanity Client](https://www.sanity.io/docs/js-client). Fortunately, one has already been created for you.
- [ ] **Open** `src/sanity/lib/client.ts` to confirm it exists in your project.
Sanity Client is built to run in any JavaScript run time and in any framework. It is also compatible with Next.js caching features, React Server Components, and the App Router.
It also provides ways to interact with Sanity projects and even write content back to the Content Lake with mutations. You'll use some of these features in later lessons.
It's time to put everything we've set up to work. In this lesson, you'll create a route to serve as a Post index page and a dynamic route to display an individual post.
## Next.js App Router
For now, you'll focus on data fetching at the top of each route. React Server Components allow you to perform fetches from inside individual components. Future lessons may address where this is beneficial. For now, our queries are simple enough – and GROQ is expressive enough – to get everything we need at the top of the tree.
> [!TIP]
> See the [Next.js App Router](https://nextjs.org/docs/app/building-your-application/routing) documentation for more details about file-based routing and how file and folder names impact URLs
The most significant change we'll make first is creating a separate "Route Group" for the entire application front end. This route group will separate the front end layout code from the Studio without affecting the URL. It is also useful when integrating Visual Editing and displaying the front end *inside* the Studio.
- [ ] **Create** a new `(frontend)` directory and **duplicate** `layout.tsx` into it
```sh
mkdir -p "src/app/(frontend)" && cp "src/app/layout.tsx" "src/app/(frontend)/"
```
You should now have **two** `layout.tsx` files inside the app folder at these locations:
```
src
└── app
├── // all other files
├── layout.tsx
└── (frontend)
└── layout.tsx
```
The `(frontend)/layout.tsx` file has duplicated `html` and `body` tags, but you'll update the file those later in the lesson.
- [ ] **Update **the root `layout.tsx` file to remove `globals.css`
## Update the home page
Later in this track, the home page will become fully featured. For now, it just needs a link to the posts index.
- [ ] **Move** `page.tsx` into the `(frontend)` folder
- [ ] **Update** your home page route to add basic navigation to the posts index.
```tsx:src/app/(frontend)/page.tsx
import Link from "next/link";
export default async function Page() {
return (
{/* Article content */}
);
}
```
## Progressive Enhancement
The component is built as progressive enhancement:
1. The anchor's `href` points directly to the `.md` URL, so it works as a normal link
2. JavaScript enhances the link with `onClick` to intercept navigation and copy to clipboard
3. If JavaScript is disabled, the browser follows the link and opens the markdown in a new tab
4. If the clipboard write or fetch fails, the component falls back to `window.open(markdownUrl, '_blank')`
Example fallback behavior:
```typescript
catch (error) {
console.error("Copy failed:", error);
setState("error");
// Fallback: open in new tab
window.open(markdownUrl, "_blank");
setTimeout(() => setState("idle"), 2000);
}
```
## Feedback States
The component cycles through four states:
- **idle** — Shows "Copy Markdown" in neutral gray
- **loading** — Shows "Loading..." while fetching markdown
- **copied** — Shows "Copied!" with green background when successful
- **error** — Shows "Error (opened in tab)" with red background when copy fails
The `copied` and `error` states automatically reset to `idle` after 2 seconds.
## Handling Stega Markers
If you're using Sanity's Visual Editing with Stega encoding, you may need to clean the markers from the markdown before copying:
```typescript
import { stegaClean } from "@sanity/client/stega";
const markdown = await response.text();
const cleanMarkdown = stegaClean(markdown);
await navigator.clipboard.writeText(cleanMarkdown);
```
This ensures users get clean markdown without invisible encoding markers.
## Styling Variations
The component uses Tailwind classes for styling. The design decisions:
- Neutral gray for idle and loading states (non-intrusive)
- Green background for success (clear positive feedback)
- Red background for errors (clear negative feedback)
- Rounded corners and padding for a button-like appearance
- Smooth transitions between states
Adjust the styling to match your design system.
## Verify It Works
Test the component:
- Click the button and verify markdown is copied to clipboard
- Check that the "Copied!" state appears briefly
- Paste the clipboard content to verify it's clean markdown
Test the fallback behavior:
- Disable JavaScript in your browser and verify the link still opens the markdown
- Test with an invalid path to verify the error state and fallback
## Next Up
For production, consider:
- Adding analytics to track how often users copy markdown
- Customizing the button label per page or section
- Adding keyboard shortcuts for power users
- Implementing rate limiting if you're concerned about abuse
## [Production-ready markdown routes](/learn/course/markdown-routes-with-nextjs/production-ready-markdown-routes)
Configure caching, headers, analytics, performance, and error handling so your markdown routes run efficiently and reliably in production.
## Production Considerations
Your markdown routes work locally — now let's make them production-ready.
## What you'll learn
- Caching strategies for markdown routes
- CDN configuration and the `Vary: Accept` header
- Tracking markdown consumption in analytics
- Performance optimization for large documents
## Caching Strategy
Markdown routes should be cached aggressively — the content doesn't change frequently, and regenerating it on every request wastes compute.
### Cache-Control Headers
Set appropriate headers in your Route Handlers:
```typescript
return new Response(markdown, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
},
})
```
This means:
- **`public`** — CDNs can cache this
- **`max-age=60`** — Fresh for 60 seconds
- **`stale-while-revalidate=300`** — Serve stale content for 5 minutes while revalidating
### Different Routes, Different Durations
- **Article** — `max-age=60`, `stale-while-revalidate=300` (content may update)
- **Section** — `max-age=300`, `stale-while-revalidate=600` (structure changes less often)
- **Sitemap** — `max-age=300`, `stale-while-revalidate=600` (structure changes less often)
```typescript
// Sitemap - longer cache
'Cache-Control': 'public, max-age=300, stale-while-revalidate=600'
// Article - shorter cache
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300'
```
## The Vary Header Problem
When you use Accept header content negotiation, CDNs need to know that different Accept headers should get different cached responses.
The `Vary` header tells CDNs which request headers affect the response:
```typescript
return new Response(markdown, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Vary': 'Accept', // Different Accept = different cache entry
},
})
```
### The Trade-Off
`Vary: Accept` reduces cache efficiency:
- Without it: CDN caches one version per URL
- With it: CDN caches multiple versions per URL (one per Accept value)
In practice, this isn't a big problem:
- Most requests don't set Accept headers (browsers send `*/*`)
- AI agents consistently send `text/markdown`
- You end up with ~2 cached versions per URL
### Our Approach
Since we use explicit `/markdown/` routes AND Accept header negotiation:
- `/docs/*` URLs don't need `Vary: Accept` — the rewrite handles it
- `/markdown/*` URLs always return markdown — no variation needed
The rewrite approach avoids the Vary header complexity entirely.
## Analytics
You want to know how much your markdown routes are used. Separate tracking for `/markdown/*` requests is straightforward.
### Option 1: URL-Based Tracking
Your analytics tool already tracks page views. The `/markdown/*` URLs appear as separate entries:
```
/docs/getting-started/quickstart → 1,234 views
/markdown/getting-started/quickstart → 89 views
```
### Option 2: Custom Events
For more detail, log custom events in your Route Handler:
```typescript
export async function GET(request: NextRequest, { params }: ...) {
// ... generate markdown ...
// Log the request (fire and forget)
logMarkdownRequest({
path: request.nextUrl.pathname,
userAgent: request.headers.get('user-agent'),
acceptHeader: request.headers.get('accept'),
}).catch(() => {}) // Don't fail the request if logging fails
return new Response(markdown, { ... })
}
```
Track interesting properties:
- **User-Agent** — Identify which AI tools are accessing your docs
- **Accept header** — See if they used content negotiation or direct URLs
- **Path** — Which articles are most requested
## Performance Optimization
### Large Documents
If you have articles with extensive content (long guides, API references), consider:
1. **Pagination** — Split into multiple articles instead of one giant document
2. **Summary routes** — Offer navigation-only versions for discovery
3. **Streaming** — For very large responses, stream the markdown
Streaming example:
```typescript
export async function GET() {
const sections = await client.fetch(SITEMAP_QUERY)
// Create a readable stream
const stream = new ReadableStream({
start(controller) {
controller.enqueue('# Sitemap\n\n')
for (const section of sections) {
controller.enqueue(`## ${section.title}\n\n`)
// ... more content
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
})
}
```
In practice, streaming is overkill for most documentation. An article would need to be massive (100KB+) before streaming provides meaningful benefit.
### Query Optimization
Ensure your GROQ queries only fetch what's needed:
```typescript
// Good: Sitemap only needs navigation data
*[_type == "section"] {
title,
slug,
"articles": *[_type == "article" && section._ref == ^._id] {
title,
slug,
summary // No content field!
}
}
// Bad: Fetching content you don't need
*[_type == "section"] {
...,
"articles": *[...] {
...,
content // Unnecessary for sitemap!
}
}
```
## Error Handling
Production routes need robust error handling:
```typescript
export async function GET(request: NextRequest, { params }: ...) {
try {
// ... main logic ...
} catch (error) {
console.error('Markdown route error:', error)
// Return a helpful error (but not too detailed in production)
return new Response(
process.env.NODE_ENV === 'development'
? `Error: ${error instanceof Error ? error.message : 'Unknown'}`
: 'Internal server error',
{ status: 500 }
)
}
}
```
Log errors to your monitoring service (Sentry, LogRocket, etc.) for visibility.
## Deployment Checklist
Before deploying:
- [ ] Environment variables set (`NEXT_PUBLIC_SANITY_PROJECT_ID`, etc.)
- [ ] Cache-Control headers configured for each route
- [ ] Error handling in place
- [ ] Analytics tracking working (if desired)
- [ ] Stega markers disabled for markdown routes
- [ ] `/sitemap.md` returns expected content
- [ ] Content negotiation works (`curl -H "Accept: text/markdown"`)
### Test on Vercel Preview
Deploy a preview and test:
```bash
# Test sitemap
curl https://your-preview-url.vercel.app/sitemap.md
# Test content negotiation
curl -H "Accept: text/markdown" https://your-preview-url.vercel.app/docs/getting-started/quickstart
# Check headers
curl -I https://your-preview-url.vercel.app/markdown/getting-started/quickstart
```
## Next Up
You've built production-ready markdown routes. Let's verify your knowledge with a quick quiz.
**Continue to Lesson 9: Course Quiz →**
## [Serve markdown for AI agents with Next.js route handlers](/learn/course/markdown-routes-with-nextjs/markdown-routes-ai-agents-quiz-summary)
Review the key concepts for building markdown-first routes in Next.js that serve both browsers and AI agents from a single source of truth, including rewrites, optional catch-all routes, content negotiation, stega markers, and markdown sitemaps.
## Course quiz
Let's verify what stuck.
> **Question:** Why use `next.config.ts` rewrites instead of checking headers in the page component?
>
> 1. Rewrites are processed before the page component runs, avoiding unnecessary rendering **[correct]**
> 2. Rewrites are faster than reading headers
> 3. Page components can't access request headers
> 4. Rewrites work better with TypeScript
> **Question:** What does the `[[...slug]]` pattern do in Next.js Route Handlers?
>
> 1. Creates a required single parameter
> 2. Creates an optional catch-all that matches zero or more segments **[correct]**
> 3. Creates a required catch-all that must have at least one segment
> 4. Escapes special characters in the URL
> **Question:** Why disable Stega markers for markdown output?
>
> 1. They slow down the response
> 2. They add invisible Unicode characters that break markdown rendering **[correct]**
> 3. They're only needed for HTML
> 4. They increase the response size significantly
> **Question:** What Content-Type header should markdown routes return?
>
> 1. text/plain
> 2. text/html
> 3. text/markdown; charset=utf-8 **[correct]**
> 4. application/markdown
> **Question:** Why normalize internal links to absolute URLs in markdown?
>
> 1. Relative URLs don't work in markdown syntax
> 2. AI agents consuming markdown outside a browser can't resolve relative paths **[correct]**
> 3. Absolute URLs are better for SEO
> 4. It's required by the Portable Text specification
> **Question:** What's the purpose of the sitemap.md route?
>
> 1. SEO optimization for search engines
> 2. Providing a structured index for AI agents to discover all available content **[correct]**
> 3. Generating an XML sitemap
> 4. Tracking which articles have markdown versions
> **Question:** How does `Vary: Accept` affect CDN caching?
>
> 1. It disables caching entirely
> 2. It tells the CDN to cache multiple versions based on the Accept header **[correct]**
> 3. It improves cache hit rates
> 4. It only affects HTML responses
## Your score
Count your correct answers:
- **7/7** — Excellent! You've mastered markdown routes.
- **5-6** — Good understanding. Review the topics you missed.
- **3-4** — Solid start. Re-read the lessons for missed questions.
- **0-2** — Take another pass through the course material.
## What you've built
Congratulations! You now have a documentation site that serves:
- **HTML articles** for human users in browsers
- **Markdown responses** for AI agents via content negotiation
- **Explicit .md suffix URLs** for direct access
- **A /sitemap.md** for content discovery
- **A CopyMarkdown button** for humans who want markdown
This pattern makes your content accessible to both audiences from a single source of truth.
## Going further
Ideas for extending what you've built:
1. **Add search** — Include a search endpoint that returns markdown results
2. **Version tracking** — Serve different content versions based on API version
3. **PDF export** — Add a Route Handler that generates PDFs
4. **Agent analytics** — Track which AI tools are using your docs most
*Course complete! You're ready to make your content accessible to AI agents.*
# [SEO optimized content with Next.js](/learn/course/seo-optimization)
SEO doesn't have to be complicated. It's a matter of taking content you've already responsibly structured with Sanity and rendering it in the format and places that search engines expect. Complete this course to improve how robots and humans interact with your content with Sanity and Next.js
## [An introduction to SEO and structured content](/learn/course/seo-optimization/an-introduction-to-seo-and-structured-content)
A few core principles, applied consistently, can form a solid foundation that benefits both search engines and editorial workflows.
## About this course
This course will guide you on the best practices of building SEO-optimized content in Next.js with Sanity.
Rather than getting bogged down in complex SEO configurations, the focus is on creating simple but effective schema types and queries that give content editors flexibility while maintaining SEO best practices.
This approach emphasizes pragmatic solutions that address essential SEO needs without adding unnecessary complexity.
It focuses on structuring content for both search engines and editorial teams, offering smart defaults along with optional granular controls. The aim is to simplify SEO-friendly content creation while adhering to Next.js best practices.
## About the author
I'm Jono, the founder of [Roboto Studio](https://robotostudio.com/?utm-source=sanity-learn).
We specialize in building the best editorial experiences on the web with Sanity and Next.js
I'm excited to share our opinionated but battle-tested approach to SEO with you. This isn't just theory - these are the same patterns we use successfully with our clients every day.
Also a special thanks to Sne and Hrithik for their help structuring this course.
## Simplifying SEO with structured content
SEO is often presented as a complex endeavor, but it is more straightforward than commonly assumed. A few core principles, applied consistently, can form a solid foundation that benefits both search engines and editorial workflows.
A well-structured content model handles most of the heavy lifting, removing the need for overly complex schemas or endless metadata fields. This approach facilitates agnostic SEO practices. This means you can incrementally adopt SEO best practices without having to always enter content from scratch.
Next.js includes opinionated API's that streamline SEO optimization. Aligning Sanity schema types and queries with these conventions creates an effective framework for building SEO-ready websites.
In the following lessons we will take a closer look at how we can leverage structured content and Next.js to help search engines understand and rank your content.
## [SEO schema types and metadata](/learn/course/seo-optimization/seo-schema-types-and-metadata)
Prepare useful, optional and reusable schema types specifically for SEO content and render them into page metadata the Next.js way.
For the benefit of content authors, fields relevant to SEO should not always be required. Instead, they should be used to override some existing content, when provided.
## Create an SEO schema type
No matter what document type you're applying SEO content to, the same data will be required. For example a title, description, image and more. So to easily re-use these fields you'll register a custom schema type to the Studio
- [ ] **Create** a new schema type for SEO fields
```typescript:src/sanity/schemaTypes/seoType.ts
import { defineField, defineType } from "sanity";
export const seoType = defineType({
name: "seo",
title: "SEO",
type: "object",
fields: [
defineField({
name: "title",
description: "If provided, this will override the title field",
type: "string",
}),
],
});
```
- [ ] **Update** your registered schema types to include `seoType`
```typescript:src/sanity/schemaTypes/index.ts
// ...all your other imports
import { seoType } from "./seoType";
export const schema: { types: SchemaTypeDefinition[] } = {
types: [
// ...all your other types
seoType,
],
};
```
- [ ] **Update** your `page` and `post` document types to include the SEO fields
```typescript:src/sanity/schemaTypes/pageType.ts
export const pageType = defineType({
// ...all other configuration
fields: [
// ...all other fields
defineField({
name: "seo",
type: "seo",
}),
],
});
```
> [!WARNING]
> Throughout the rest of this course you'll be expected to keep both the `page` and `post` document schema types, GROQ queries and Next.js routes updated—but code examples may only be shown for the `page` type.
You should now see the SEO object field at the bottom of `page` and `post` document types in the Studio.

## Queries with fallbacks
In the description field of the SEO title, we've informed the author that the title is not required, but that it will override the title field if provided.
The title field is likely to be sufficient for SEO the majority of the time, but if for some reason it needs to be different, the author now has an avenue to override it.
For the front-end to respect this, there are a few ways to do it. You *could* choose which field to render with logic like this:
```tsx:Example only
{seo?.title ?? title}
```
But then we'd need to duplicate that logic *everywhere* we optionally render the correct value. It's also annoying because the `seo` attribute may or may not exist.
Because we have GROQ, we can move all this logic into our query instead.
- [ ] **Update** the `PAGE_QUERY` to include an `seo` attribute with values and fallbacks
```typescript:src/sanity/lib/queries.ts
export const PAGE_QUERY =
defineQuery(`*[_type == "page" && slug.current == $slug][0]{
...,
"seo": {
"title": coalesce(seo.title, title, ""),
},
content[]{
...,
_type == "faqs" => {
...,
faqs[]->
}
}
}`);
```
Don't forget to update your POST_QUERY to include the same projection.
> [!NOTE]
> `coalesce()` is a GROQ [GROQ functions reference](https://www.sanity.io/learn/specifications/groq-functions) that returns the first value that is not null
Now `seo.title` will never be `null`, and contain either the optionally provided SEO title, or the page title, or an empty string.
- [ ] **Run** the following command to update your Types now that you've made schema and query changes
```sh
npm run typegen
```
> [!NOTE]
> This command was setup in the [Generate TypeScript Types](https://www.sanity.io/learn/course/content-driven-web-application-foundations/generate-typescript-types) lesson of the [Content-driven web application foundations](https://www.sanity.io/learn/course/content-driven-web-application-foundations) course.
Just to prove this works, update the dynamic route that renders your pages to include a `` tag. It is a [feature of React 19](https://react.dev/blog/2024/12/05/react-19#support-for-metadata-tags) to move meta tags into the `` tag. (But it's not how Next.js 15 recommends, you'll do that later).
- [ ] **Update** the dynamic page route to include the `` tag
```tsx:src/app/(frontend)/[slug]/page.tsx
import { PageBuilder } from "@/components/PageBuilder";
import { sanityFetch } from "@/sanity/lib/live";
import { PAGE_QUERY } from "@/sanity/lib/queries";
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { data: page } = await sanityFetch({
query: PAGE_QUERY,
params: await params,
});
return (
<>
{page.seo.title}
{page?.content ? (
) : null}
>
);
}
```
Your front end should now have rendered either the page title, or the SEO title field value into a `` tag inside the ``.
## Metadata, the Next.js way
The problem with relying on the previous method is deduplication. React will render multiple `` tags when it finds them, and your Next.js application may eventually use nested layouts where this is a possibility.
Instead, Next.js has an API to export a uniquely named function from a route to take the same dynamic data that is rendered into the page to generate those meta tags as required.
In the example below we have extracted the `sanityFetch` to its own function, because now it will be re-used multiple times. (Which Next.js should cache and only run once). Inside the `generateMetadata` function, the same `seo.title` value is used to generate a `` tag in the final markup.
- [ ] **Update** your `page` route to generate metadata and the rendered on-page content in separate functions.
```tsx:src/app/(frontend)/[slug]/page.tsx
import type { Metadata } from "next";
import { PageBuilder } from "@/components/PageBuilder";
import { sanityFetch } from "@/sanity/lib/live";
import { PAGE_QUERY } from "@/sanity/lib/queries";
type RouteProps = {
params: Promise<{ slug: string }>;
};
const getPage = async (params: RouteProps["params"]) =>
sanityFetch({
query: PAGE_QUERY,
params: await params,
});
export async function generateMetadata({
params,
}: RouteProps): Promise {
const { data: page } = await getPage(params);
return {
title: page.seo.title,
};
}
export default async function Page({ params }: RouteProps) {
const { data: page } = await getPage(params);
return page?.content ? (
) : null;
}
```
Following the same pattern, you can add SEO overrides for other important metadata tags. Such as a `` tag. This is what Google uses to display a description of your page in the search results.
Again, you can also add an override for the `seoImage` field, which will be used to populate the `` tag.
The most important takeaway from this, is that you always want to have an override, and a fallback. It keeps consistency in your content, and standardizes the way you query your SEO fields.
Don't forget to update your individual post route to use the same conventions.
> [!TIP]
> Take a look at the Next.js [metadata documentation](https://nextjs.org/docs/app/building-your-application/optimizing/metadata) for more information.
## Just getting started
At this point, you can see how the pattern works, and how this is easy to extend to other SEO fields.
In the next lesson, you will enhance your SEO functionality by adding more fields, including those for Open Graph data, and one to control search engine indexing
## [Extending the SEO schema types](/learn/course/seo-optimization/adding-seo-fields-to-your-project)
Now you're setup for success, extend the fields made available to your authors.
In the first lesson, you learned how to add some basic SEO fields to your schema. Now you're going to kick it up a notch with Open Graph fields and more granular controls over displaying documents in lists.
## Add more SEO fields
- [ ] **Update** your `seoType` schema type to include `description`, `image` and a `noIndex` field
```typescript
import { defineField, defineType } from "sanity";
export const seoType = defineType({
name: "seo",
title: "SEO",
type: "object",
fields: [
defineField({
name: "title",
description: "If provided, this will override the title field",
type: "string",
}),
defineField({
name: "description",
type: "text",
}),
defineField({
name: "image",
type: "image",
options: {hotspot: true}
}),
defineField({
name: "noIndex",
type: "boolean",
}),
],
});
```
You may wish to have separate title and description fields for Open Graph properties. But in this course you'll re-use these values.
- [ ] **Update** `PAGE_QUERY` and `POST_QUERY` to include these new attributes, along with default values
```typescript:src/sanity/lib/queries.ts
export const PAGE_QUERY =
defineQuery(`*[_type == "page" && slug.current == $slug][0]{
...,
"seo": {
"title": coalesce(seo.title, title, ""),
"description": coalesce(seo.description, ""),
"image": seo.image,
"noIndex": seo.noIndex == true
},
content[]{
...,
_type == "faqs" => {
...,
faqs[]->
}
}
}`);
```
- [ ] **Run** the following to regenerate Types now that you've made schema and query changes
```sh
npm run typegen
```
## Render more metadata
With these fields now present in your schema types and queries, you can now render even more metadata in your route.
Note in the code below how the Open Graph image reuses the `urlFor` helper function to generate an image the correct width and height–and will also respect crop and hotspot data.
The value for `noindex` we only include in the metadata if it is set to true.
```typescript:src/app/(frontend)/[slug]/page.tsx
// ...the rest of your route
export async function generateMetadata({
params,
}: RouteProps): Promise {
const { data: page } = await getPage(params);
if (!page) {
return {}
}
const metadata: Metadata = {
title: page.seo.title,
description: page.seo.description,
};
if (page.seo.image) {
metadata.openGraph = {
images: {
url: urlFor(page.seo.image).width(1200).height(630).url(),
width: 1200,
height: 630,
},
};
}
if (page.seo.noIndex) {
metadata.robots = "noindex";
}
return metadata;
}
```
Don't forget to update your individual post route as well.
### A note on `noIndex`
Having a page set to `noIndex` typically means that you want the published document to exist as a route in your application—but you don't want it included in search results. Either on search engine results or within your website.
Nothing needs to change now with your page type documents, but if you were to include these fields in your post type documents, you'd likely want to update any query that looks up and renders many posts to exclude results where `noIndex` is true. For example:
```groq:Example only
*[_type == "post" && seo.noIndex != true]
```
You'll see an example of this later in the lesson [Build a dynamic sitemap](https://www.sanity.io/learn/course/seo-optimization/building-a-dynamic-sitemap).
Now your Sanity Studio and application are capable of authoring, querying and rendering complex metadata for the most common SEO needs. You can continue to extend these fields for any other metadata requirements.
In the following lesson you'll take on another major SEO concern: redirects.
## [Implementing redirects](/learn/course/seo-optimization/implementing-redirects)
Redirects are a critical component of SEO and site maintenance. While they may appear straightforward at first, improper implementation can lead to complex redirect chains and degraded site performance.
Let's go through best practices for implementing redirects with Next.js and Sanity.
## Learning objectives
You will create a redirect system that:
- Is configured with documents in Sanity Studio
- Can be managed by your content team
- Won't create a maintenance headache later
## Creating the schema
Let's start with your redirect schema type first. You want to make this as editor friendly as possible. The goal is to build a non-technical solution that can be managed by your content team, and output to your Next.js configuration.
> [!TIP]
> See the Next.js [documentation about creating redirects](https://nextjs.org/docs/app/building-your-application/routing/redirecting#redirects-in-nextconfigjs)
Below is a simplified document schema, which you'll make much smarter with validation rules later in the lesson.
- [ ] **Create** a new document schema type for redirects
```typescript:src/sanity/schemaTypes/redirectType.ts
import { defineField, defineType } from "sanity";
import { LinkIcon } from "@sanity/icons";
export const redirectType = defineType({
name: "redirect",
title: "Redirect",
type: "document",
icon: LinkIcon,
fields: [
defineField({
name: "source",
type: "string",
}),
defineField({
name: "destination",
type: "string",
}),
defineField({
name: "permanent",
type: "boolean",
initialValue: true,
}),
defineField({
name: "isEnabled",
description: "Toggle this redirect on or off",
type: "boolean",
initialValue: true,
}),
],
});
```
Don't forget to register it to your Studio schema types
```typescript:src/sanity/schemaTypes/index.ts
// ...all other imports
import { redirectType } from "./redirectType";
export const schema: { types: SchemaTypeDefinition[] } = {
types: [
// ...all other schema types
redirectType,
],
};
```
- [ ] **Update** your structure builder configuration to add the redirect document type:
```typescript:src/sanity/structure.ts
// add this line
S.documentTypeListItem('redirect').title('Redirects')
```
## Fetching the redirects
The redirect documents created in Sanity Studio will need to be queried into our Next.js config file.
- [ ] **Update** `queries.ts` to include a GROQ query for redirect documents
```typescript:src/sanity/lib/queries.ts
// ...all other queries
export const REDIRECTS_QUERY = defineQuery(`
*[_type == "redirect" && isEnabled == true] {
source,
destination,
permanent
}
`);
```
- [ ] **Create** a new utility to fetch all redirect documents
```typescript:src/sanity/lib/fetchRedirects.ts
import { client } from "./client";
import { REDIRECTS_QUERY } from "./queries";
export async function fetchRedirects() {
return client.fetch(REDIRECTS_QUERY);
}
```
Since you've added schema types and a new query to the application, don't forget to generate Types.
```sh:Terminal
pnpm run typegen
```
### Things to note
- Vercel has a limit of 1,024 redirects in Next.js config.
- For large numbers of redirects (1000+), use a custom middleware solution instead. See Vercel's documentation on [managing redirects at scale](https://nextjs.org/docs/app/building-your-application/routing/redirecting#managing-redirects-at-scale-advanced) for more details.
## Add redirects in your Next.js config
Now we can use Next.js's built-in redirects configuration in `next.config.ts`. This allows us to define redirects that will be applied at build time. Note that redirects defined in `next.config.ts` run **before** any middleware, should you use it in the future.
- [ ] **Update** your `next.config.ts` file to include redirects
```typescript:next.config.ts
// ...other imports
import { fetchRedirects } from "@/sanity/lib/fetchRedirects";
const nextConfig: NextConfig = {
// ...other config
async redirects() {
return await fetchRedirects();
},
};
export default nextConfig;
```
## Validation rules
Validation is critical as invalid redirects can break future builds. Without validation, authors could publish a redirect that prevents your application from deploying—or create a deployment with circular redirects.
- Source paths must start with `/`
- Never create circular redirects like `A` -> `B` -> `A`
Here's the validation logic, yes it's a bit complex but it's worth it to avoid hours of debugging, when your build breaks because of a missing slash.
- [ ] **Update** the `source` field in the `redirectType` schema
```typescript:src/sanity/schemaTypes/redirectType.ts
import { defineField, defineType, SanityDocumentLike } from "sanity";
import { LinkIcon } from "@sanity/icons";
function isValidInternalPath(value: string | undefined) {
if (!value) {
return "Value is required";
} else if (!value.startsWith("/")) {
return "Internal paths must start with /";
} else if (/[^a-zA-Z0-9\-_/:]/.test(value)) {
return "Source path contains invalid characters";
} else if (/:[^/]+:/.test(value)) {
return "Parameters can only contain one : directly after /";
} else if (
value.split("/").some((part) => part.includes(":") && !part.startsWith(":"))
) {
return "The : character can only appear directly after /";
}
return true;
}
function isValidUrl(value: string | undefined) {
try {
new URL(value || "");
return true;
} catch {
return "Invalid URL";
}
}
export const redirectType = defineType({
name: "redirect",
title: "Redirect",
type: "document",
icon: LinkIcon,
validation: (Rule) =>
Rule.custom((doc: SanityDocumentLike | undefined) => {
if (doc && doc.source === doc.destination) {
return ["source", "destination"].map((field) => ({
message: "Source and destination cannot be the same",
path: [field],
}));
}
return true;
}),
fields: [
defineField({
name: "source",
type: "string",
validation: (Rule) => Rule.required().custom(isValidInternalPath),
}),
defineField({
name: "destination",
type: "string",
validation: (Rule) =>
Rule.required().custom((value: string | undefined) => {
const urlValidation = isValidUrl(value);
const pathValidation = isValidInternalPath(value);
if (urlValidation === true || pathValidation === true) {
return true;
}
return typeof urlValidation === "boolean"
? urlValidation
: pathValidation;
}),
}),
defineField({
name: "permanent",
description: "Should the redirect be permanent (301) or temporary (302)",
type: "boolean",
initialValue: true,
}),
defineField({
name: "isEnabled",
description: "Toggle this redirect on or off",
type: "boolean",
initialValue: true,
}),
],
});
```
The additional validation logic now thoroughly checks:
- If the `source` is a valid internal path
- If the `destination` is a valid URL, or valid internal path
- If the `source` and `destination` values are different
## Pro tips from experience
- Keep an eye on redirect chains, they can cause "too many redirects" errors
- Clean up old redirects periodically
- Consider logging redirects if you need to track usage
- Adjust the cache duration based on how often you update redirects
- You may need to redeploy your site as new redirects are added or existing redirects are modified
Next up, you'll learn how to generate Open Graph images using Tailwind CSS and Vercel edge functions.
## [Creating dynamic Open Graph images](/learn/course/seo-optimization/creating-dynamic-open-graph-images-with-vercel-og)
Generate dynamic Open Graph images that pull your data directly from Sanity, saving you hours of design work and ensuring your social previews are always up to date with your content.
Open Graph images (or social cards) are the preview images that appear when your content is shared on social media platforms. It is proven that having these images included with your social shares increases click through rates.
Dependent on which platform you're sharing to, you may want to create a range of different aspect ratios. For this tutorial, you'll create the most common size, `1200x630` pixels.
As always, you'll set this up in such a way that if you do upload a bespoke image to the `seo.image` field, it will override the automatically generated one.
## Learning objectives
By the end of this lesson, you'll be able to:
- Generate dynamic Open Graph images using Next.js Edge Runtime
- Extract and use dominant colors from featured images
- Create professional, branded social previews
### Setting up the edge route
Let's create a new API route using Next.js Edge Runtime. This route will:
- Accept a parameter to dynamically fetch data
- Return an image response using Next.js `ImageResponse`
Make sure before you proceed any further, you have read the [limitations](https://vercel.com/docs/functions/og-image-generation#limitations) of Open Graph image generation on Vercel.
- [ ] **Create** a new route in your Next.js application
```tsx:src/app/api/og/route.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
const dimensions = {
width: 1200,
height: 630,
};
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get("title");
return new ImageResponse(
(
{title || "Missing title parameter"}
),
dimensions
);
}
```
Visit [http://localhost:3000/api/og?title=hello](http://localhost:3000/api/og?title=hello) and you should see an image rendered of a blue rectangle with the word "hello" in the top right.

This creates a route that generates an image which will render whatever was passed into the title parameter.
We're using Tailwind CSS utility classes in a `tw` prop for styling. If you use `className` you will get an error. This is all part of how the `ImageResponse` function works.
This works, but isn't much to look at. And at present any user could enter *any* value for the `title` parameter and have it render a custom image. Not safe! If we're going to render content, it's better to do so from a single source of truth. Your Content Lake.
### Creating the Sanity query
We'll need to fetch specific data for our OG images:
- Page title
- Featured image URL
- Color palette information
[There's lots of neat metadata you can pull from Sanity images](https://www.sanity.io/docs/image-metadata), such as the dominant colors within an image. We'll use these as part of the design.
- [ ] **Update** queries with a GROQ query for the data needed to generate an image
```typescript:src/sanity/lib/queries.ts
// ...all other queries
export const OG_IMAGE_QUERY = defineQuery(`
*[_id == $id][0]{
title,
"image": mainImage.asset->{
url,
metadata {
palette
}
}
}
`);
```
- [ ] **Update** the route that generates the OG image to fetch data based on the search parameter `id`
```tsx:src/app/api/og/route.tsx
import { client } from "@/sanity/lib/client";
import { urlFor } from "@/sanity/lib/image";
import { OG_IMAGE_QUERY } from "@/sanity/lib/queries";
import { notFound } from "next/navigation";
import { ImageResponse } from "next/og";
export const runtime = "edge";
async function loadGoogleFont(font: string, text: string) {
const url = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(text)}`;
const css = await (await fetch(url)).text();
const resource = css.match(
/src: url\((.+)\) format\('(opentype|truetype)'\)/
);
if (resource) {
const response = await fetch(resource[1]);
if (response.status == 200) {
return await response.arrayBuffer();
}
}
throw new Error("failed to load font data");
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
notFound();
}
const data = await client.fetch(OG_IMAGE_QUERY, { id });
if (!data) {
notFound();
}
const vibrantBackground =
data?.image?.asset?.metadata?.palette?.vibrant?.background ?? "#3B82F6";
const darkVibrantBackground =
data?.image?.asset?.metadata?.palette?.darkVibrant?.background ?? "#3B82F6";
const text = data.title || "";
return new ImageResponse(
(
),
{
width: 1200,
height: 630,
fonts: [
{
name: "Inter",
data: await loadGoogleFont("Inter", text),
weight: 400,
style: "normal",
},
],
}
);
}
```
This query fetches the page title, image, and its color palette information—all based on the value of an ID passed to the route. It uses this data to create a dynamic background color based on the image.
The route now also uses the font Inter, [fetched from Google Fonts](https://fonts.google.com/specimen/Inter).
You can test this route by visiting `/api/og?id=your-document-id` in your browser, replacing `your-document-id` with an actual Sanity document ID.

The image template includes:
- A dynamic background color based on the featured image
- The page title
- The featured image, respecting its crop and hotspot settings
What we have now is a basic—but working—prototype for the future. You could extend this design or even explore creating different layouts depending on the value of the document's `_type`.
### Implementing metadata
Now that you have your Open Graph image generation set up, it will need to be added to each route's metadata so that it renders when that URL is shared.
- [ ] **Update** the `generateMetadata` function in your `page` and `post` routes to use the dynamically generated Open Graph image, if an image is not specified in the document
```typescript:src/app/(frontend)/[slug]/page.tsx
// ...all your imports
export async function generateMetadata({
params,
}: RouteProps): Promise {
const { data: page } = await getPage(params);
if (!page) {
return {};
}
const metadata: Metadata = {
metadataBase: new URL('https://acme.com'),
title: page.seo.title,
description: page.seo.description,
};
metadata.openGraph = {
images: {
url: page.seo.image
? urlFor(page.seo.image).width(1200).height(630).url()
: `/api/og?id=${page._id}`,
width: 1200,
height: 630,
},
};
if (page.seo.noIndex) {
metadata.robots = "noindex";
}
return metadata;
}
```
Be sure to copy this logic over to your individual post route as well.
This setup generates metadata dynamically for each page, uses the page's Sanity ID to generate the correct Open Graph image, and maintains consistent dimensions across platforms.
## Testing your implementation
There are a few ways to test your implementation.
If you have a service like [ngrok](https://ngrok.com/) setup locally you can pipe your local development environment to an external URL, and then run that URL through an Open Graph previewing service.
> [!TIP]
> [opengraph.ing](https://opengraph.ing/) is a simple service for validating your social previews in multiple applications and services

Once you're ready to deploy, you can check the implementation from your preview environment. Once deployed you can use the Vercel toolbar to preview your site and see the Open Graph image.

Other alternatives include using platform-specific debugging tools:
- [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/)
These tools allow you to test your Open Graph image on the platform you are sharing to, and see the preview image. There is an added benefit of these tools, that some of them force the cache to be invalidated, which means you can see the latest version of your Open Graph image across that social platform.
The next lesson will cover remixing content for social platforms using the Sanity AI Assistant.
## [Generate social posts from your content](/learn/course/seo-optimization/ai-generate-social-posts-from-your-content)
Speed up ideation of social media posts. And as a result, boost your SEO from sharing your content to a wider audience across different social platforms.
> [!NOTE]
> This lesson uses features only available in paid plans. If you started a new project for this course, you can test these features during the free trial period. You can also start a new free project at any time.
Summarizing existing content and making it more useful in different forms is one of the best features of AI tooling. Sanity AI Assist makes it possible for authors to automatically generate new content, using existing fields along with prompts that they can save and share.
In this lesson you'll use Sanity AI Assist to generate text to post on social networks while sharing a link to the content.
> [!TIP]
> Read more about [Sanity AI Assist](https://www.sanity.io/docs/install-and-configure-sanity-ai-assist) in the documentation
## Create new schema types
First you'll need new fields to write content to. Just like you made a new custom object schema type for SEO fields, create another for social networks.
In the example below we've chosen only LinkedIn and X (formerly known as Twitter) for now, feel free to include any of the countless others.
- [ ] **Create** a new `social` object schema type
```typescript:src/sanity/schemaTypes/socialType.ts
import { defineField, defineType } from "sanity";
export const socialType = defineType({
name: "social",
title: "Social",
type: "object",
fields: [
defineField({
name: "linkedIn",
title: "LinkedIn",
type: "text",
rows: 3,
}),
defineField({
name: "x",
description: "Formerly known as Twitter",
type: "text",
rows: 2,
}),
],
});
```
Don't forget to register this type to your Sanity Studio schema types
```typescript:src/sanity/schemaTypes/index.ts
// ...all other imports
import { socialType } from "./socialType";
export const schema: { types: SchemaTypeDefinition[] } = {
types: [
// ...all other types
socialType,
],
};
```
- [ ] **Update** your `page` and `post` schema types to include the `social` field.
```typescript:src/sanity/schemaTypes/pageType.ts
export const pageType = defineType({
// ...all other settings
fields: [
// ...all other fields
defineField({
name: "social",
type: "social",
}),
],
});
```
## Install Sanity AI Assist
To automatically generate content for these fields, you'll now install and configure the Sanity AI assistant.
- [ ] **Run** the following in your terminal
```sh:Terminal
npm install @sanity/assist
```
- [ ] **Update** your Sanity Studio config file to include the `assist` plugin
```typescript:sanity.config.ts
// ...all other imports
import { assist } from "@sanity/assist";
export default defineConfig({
// ...all other config
plugins: [
// ...all other plugins
assist(),
],
});
```
With this installed you can create a prompt to help Sanity AI Assist generate content from your existing fields.
Sanity AI Assist works at both field level and document level, however, for this example, you will be using the document level. Look at the top right of the Studio with any document open and you should see a sparkly new icon.

The first time you click this button you may be asked to Enable AI Assist
- [ ] Click **Enable AI Assist**
You can see there are currently no instructions.
- [ ] Click **Add item** to create your first instruction.
What we want AI Assist to do is to summarize the main body of the document
You're going to create a new prompt, use the example below for guidance.
Where you see the boxes like `[Title]`, replace these with references to the fields in your document.
Make sure the **Allowed fields** is set to only write to the **Social** field object.
- [ ] **Create** your new Instruction and run it
```
Take the content from [title] and [body] to generate text that will encourage people to click the link and find out more when this content is shared on social networks.
```

> [!TIP]
> Writing prompts for AI is a bit of an art form! Take a look at the [instructions cheat sheet](https://www.sanity.io/docs/ai-assist-cheat-sheet) in the documentation for inspiration.
### Posting to social networks
In this lesson you're only using Sanity to **generate** text for posting to social networks. So for now your authors would need to copy and paste them from Sanity. There are a variety of third-party tools available to automate this process.
Please use the feedback form below to let us know if you have preferred ways to automate posting to social networks.
### Adapt to your tone of voice
By default, AI-generated copy can be generic. Consider adding some **AI context** documents (now visible in your Studio structure) to inform your preferred writing style and tone of voice. You can then add this context to your instruction, so that copy generated in future will be consistently informed.
In the following lesson, you'll create a dynamic sitemap that automatically updates when content changes. Helping search engines discover and index your content more effectively.
## [Build a dynamic sitemap](/learn/course/seo-optimization/building-a-dynamic-sitemap)
A sitemap helps search engines understand and index your website more effectively. Generate a dynamic sitemap to guide search crawlers through your content, showing them what pages exist and how often they change.
A well-structured sitemap gives search engines clear guidance about your content hierarchy and update frequency.
## Why this approach?
Search engines like Google use sitemaps as a primary method to discover and understand your content. While they can crawl your site naturally through links, a sitemap:
1. Ensures all your content is discoverable, even pages that might be deep in your site structure
2. Helps search engines understand when content was last updated
3. Allows you to indicate content priority
4. Speeds up the indexing process for new content
This is especially important for dynamic content managed through Sanity, where pages might be added or updated frequently.
## Learning objectives
By the end of this lesson, you will:
- Create a dynamic sitemap from Sanity content
- Implement graceful validation error handling
### Understanding sitemaps
Before diving into the code, let's understand what makes a good sitemap from a technical perspective:
- **XML Format**: Search engines expect a specific XML format
- **Last Modified Dates**: Helps search engines know when content was updated
- **Change Frequency**: Indicates how often content changes
- **Priority**: Suggests the importance of pages
### Building the sitemap
Let's start with a GROQ query to fetch all `page` and `post` type documents.
- [ ] **Update** `queries.ts` to include `SITEMAP_QUERY`
```typescript:src/sanity/lib/queries.ts
// ...all other queries
export const SITEMAP_QUERY = defineQuery(`
*[_type in ["page", "post"] && defined(slug.current)] {
"href": select(
_type == "page" => "/" + slug.current,
_type == "post" => "/posts/" + slug.current,
slug.current
),
_updatedAt
}
`)
```
This query:
- Gets all documents of type `page` and `post`
- Dynamically creates a complete path depending on the value of `_type`
- Returns that path as `href`, and the last updated date of the document
You've created a new query, so you'll need to create new types.
```sh:Terminal
pnpm run typegen
```
The Next.js app router has a special, reserved route for generating an XML sitemap response from an array of objects in JavaScript.
> [!TIP]
> See the Next.js documentation for more details on the [sitemap route](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap)
The route below fetches content from Sanity using the query above, and generates the shape of content response that Next.js requires.
- [ ] **Create** a new route to generate the sitemap
```typescript:src/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 {
try {
const paths = await client.fetch(SITEMAP_QUERY);
if (!paths) return [];
const baseUrl = process.env.VERCEL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
return paths.map((path) => ({
url: new URL(path.href!, baseUrl).toString(),
lastModified: new Date(path._updatedAt),
changeFrequency: "weekly",
priority: 1,
}));
} catch (error) {
console.error("Failed to generate sitemap:", error);
return [];
}
}
```
### Testing your sitemap
After deploying your changes, you can test your sitemap by visiting [http://localhost:3000/sitemap.xml](http://localhost:3000/sitemap.xml) on your site.
You should see something like this:
```xml
http://localhost:3000/welcome-to-layer-caker2025-01-10T14:13:34.000Zweekly1
```
Even if your sitemap looks correct, checking with a sitemap validator tool is recommended. Especially as your website grows. It's very easy to miss validation errors. A solid option is [XML Sitemaps](https://www.xml-sitemaps.com/validate-xml-sitemap.html) for a free and quick check.
### Best practices
To ensure your sitemap is doing what it's meant to, keep these points in mind:
- **Regular Updates**: Your sitemap should update when content changes
- **Size Limits**: Keep under 50,000 URLs **per sitemap file**
- **Valid URLs**: Ensure all URLs are properly formatted
At this stage, your sitemap will now automatically update whenever you publish new content in Sanity, helping search engines discover and index your content.
As you continue to enhance your sitemap implementation and expand out through other document types, you may want to consider adding different priorities for different page types to help search engines understand the relative importance of your content.
Next, you'll explore structured data and JSON-LD, a clever way of reusing your documents for set-and-forget SEO benefits.
## [Generating JSON-LD dynamically](/learn/course/seo-optimization/generating-json-ld-dynamically)
JSON-LD is a powerful way to provide structured data to search engines—fortunately structured data is what Sanity does best.
JSON-LD data follows structured conventions for many different types of content. All you'll need to do is take content already authored in your documents, and render it into the DOM in the expected format. We already have FAQs as a document type, so it makes sense to start there.
When it comes to FAQs, proper JSON-LD implementation can help your content appear in rich snippets and potentially even surface you near the top of search results just by providing useful information.
## Learning objectives
By the end of this lesson, you will:
- Generate JSON-LD for FAQs programmatically
- Implement type-safe JSON-LD using Google's `schema-dts` package
- Improve your FAQ block from the page builder course
### Understanding JSON-LD generation
JSON-LD generation can be challenging to get right as it follows a strict structure. Fortunately, [Google provides a TypeScript package](https://github.com/google/schema-dts) called `schema-dts` that gives you type safety for your structured content.
Let's start by creating a function that transforms your FAQ data into a JSON-LD friendly structure. Back in [Create page builder schema types](https://www.sanity.io/learn/course/page-building/create-page-builder-schema-types) you created a document type schema for FAQs.
- [ ] **Run** the following to install the `schema-dts` package
```sh:Terminal
pnpm add schema-dts
```
Currently in the GROQ query for pages the FAQ block is returning the full document for every reference.
Let's update this to only extract specific fields from the document, as well as the Portable Text in the `body` field as a string using the GROQ function `pt::text()`
- [ ] **Update** your GROQ query for pages, to return the answer in plain text
```groq:src/sanity/lib/queries.ts
// replace this
faqs[]->
// with this
faqs[]->{
_id,
title,
body,
"text": pt::text(body)
}
```
You've updated your queries, so update your types
```sh:Terminal
pnpm run typegen
```
### Implementing FAQ JSON-LD in components
The JSON-LD markup can be rendered anywhere in the page—it doesn't need to be stored inside the ``. So you can add it directly into components where you already have access to the correct data.
In this instance, you're rendering the FAQ content into an accordion in this block, so you can also have it process that same content into the JSON-LD format and add it to the component output.
- [ ] **Update** your `FAQs` block to render JSON-LD content in a script tag
```tsx:src/components/blocks/faqs.tsx
// ...all your imports and types
import { FAQPage, WithContext } from "schema-dts";
const generateFaqData = (faqs: FAQsProps["faqs"]): WithContext => ({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs?.map((faq) => ({
"@type": "Question",
name: faq.title!,
acceptedAnswer: {
"@type": "Answer",
text: faq.text!,
},
})),
});
export function FAQs({ _key, title, faqs }: FAQsProps) {
const faqData = generateFaqData(faqs);
return (
{/* ...the rest of the component */}
);
}
```
Notice the most important part of this block, the `