← Back to DevBytes

Server-Side Rendering with Yup: SSR, SSG, ISR

Server-Side Rendering with Yup: SSR, SSG, ISR

Form validation is a critical part of any web application, and when you're working with modern rendering strategies like Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR), validation needs to happen seamlessly on both the server and the client. Yup is a schema builder for runtime value parsing and validation that pairs beautifully with these rendering approaches. In this tutorial, we'll explore how to integrate Yup into a Next.js application that leverages SSR, SSG, and ISR.

What Is Yup?

Yup is a JavaScript schema validation library inspired by Joi but designed to work in both browser and Node.js environments. It lets you define schemas declaratively, validate objects against those schemas, and transform values along the way. Because Yup is isomorphic — meaning it runs identically on the server and the client — it's an ideal choice for applications that render on the server.

Why Yup Matters in Server-Rendered Apps

In server-rendered applications, validation serves two distinct purposes. First, you validate data before rendering to ensure the page receives well-formed props. Second, you validate user input after the page loads, typically in forms. Without a unified validation library, you end up duplicating logic across server and client code. Yup eliminates that duplication by letting you share a single schema definition everywhere.

This matters even more when you consider the three rendering strategies:

Setting Up the Project

Let's start by creating a Next.js project and installing Yup. We'll use the Pages Router for clarity, though the same patterns apply to the App Router.

npx create-next-app@latest yup-ssr-demo
cd yup-ssr-demo
npm install yup

Next, create a shared schemas directory. Keeping schemas in one place ensures both server and client code import the same definitions.

// schemas/user.js
import * as yup from 'yup';

export const userSchema = yup.object({
  id: yup.number().required(),
  name: yup.string().required().min(2).max(50),
  email: yup.string().email().required(),
  role: yup.string().oneOf(['admin', 'editor', 'viewer']).default('viewer'),
  bio: yup.string().max(280).default(''),
});

export const createUserSchema = userSchema.omit(['id']);

Notice how we export both a full userSchema and a createUserSchema that omits the id field. This pattern lets you reuse the same base schema for reading and writing, which keeps your validation logic DRY.

Server-Side Rendering (SSR) with Yup

SSR renders a page on every request. This is the right choice when data changes frequently or depends on the requesting user. With Yup, you can validate the data fetched from an API or database before passing it as props. If validation fails, you can fall back to safe defaults or return a 404.

Validating Props in getServerSideProps

// pages/profile.js
import { userSchema } from '../schemas/user';

export async function getServerSideProps(context) {
  const { id } = context.params;
  const res = await fetch(`https://api.example.com/users/${id}`);
  const rawData = await res.json();

  try {
    const user = await userSchema.validate(rawData, {
      stripUnknown: true,
      abortEarly: false,
    });

    return {
      props: { user },
    };
  } catch (err) {
    console.error('Validation failed:', err.errors);
    return {
      notFound: true,
    };
  }
}

export default function Profile({ user }) {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <p>{user.bio}</p>
    </div>
  );
}

The stripUnknown option removes any fields not defined in the schema, which prevents unexpected data from leaking into your rendered HTML. The abortEarly: false option collects all validation errors instead of stopping at the first one, useful for logging and debugging.

Handling Form Submissions in SSR

When a user submits a form on an SSR page, you typically send the data to an API route. You can validate the submission on the server using the same schema.

// pages/api/users.js
import { createUserSchema } from '../../schemas/user';

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    const validData = await createUserSchema.validate(req.body, {
      stripUnknown: true,
      abortEarly: false,
    });

    // Save to database here
    const savedUser = await saveUser(validData);

    return res.status(201).json(savedUser);
  } catch (err) {
    if (err instanceof yup.ValidationError) {
      return res.status(400).json({
        errors: err.inner.map((e) => ({
          field: e.path,
          message: e.message,
        })),
      });
    }
    return res.status(500).json({ error: 'Internal server error' });
  }
}

Notice how err.inner gives you access to every individual validation error when abortEarly is false. This lets you return field-specific error messages to the client, which you can then display next to the corresponding form inputs.

Static Site Generation (SSG) with Yup

SSG generates pages at build time. This produces fast, cacheable HTML but requires that all data be available when you run the build. Yup plays a crucial role here: by validating data at build time, you catch malformed content before it ships to production.

Validating Build-Time Data in getStaticProps

// pages/blog/[slug].js
import * as yup from 'yup';

const articleSchema = yup.object({
  slug: yup.string().required(),
  title: yup.string().required().max(120),
  content: yup.string().required(),
  author: yup.string().required(),
  publishedAt: yup.date().required(),
  tags: yup.array().of(yup.string()).default([]),
});

export async function getStaticPaths() {
  const res = await fetch('https://api.example.com/articles');
  const articles = await res.json();

  const paths = articles.map((article) => ({
    params: { slug: article.slug },
  }));

  return { paths, fallback: false };
}

export async function getStaticProps({ params }) {
  const res = await fetch(`https://api.example.com/articles/${params.slug}`);
  const rawData = await res.json();

  try {
    const article = await articleSchema.validate(rawData, {
      stripUnknown: true,
    });

    return {
      props: {
        article: {
          ...article,
          publishedAt: article.publishedAt.toISOString(),
        },
      },
    };
  } catch (err) {
    console.error(`Build failed for slug ${params.slug}:`, err.message);
    throw err; // Fail the build so bad data never ships
  }
}

export default function Article({ article }) {
  return (
    <article>
      <h1>{article.title}</h1>
      <time>{new Date(article.publishedAt).toLocaleDateString()}</time>
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  );
}

Here, we deliberately throw the error when validation fails during the build. This is a best practice for SSG: if your content source returns malformed data, you want the build to fail loudly rather than shipping a broken page.

Validating Dynamic Path Parameters

You can also use Yup to validate the path parameters themselves in getStaticPaths. This is useful when slugs come from an external source with unpredictable formats.

const slugSchema = yup.string().matches(/^[a-z0-9-]+$/);

export async function getStaticPaths() {
  const res = await fetch('https://api.example.com/articles/slugs');
  const slugs = await res.json();

  const validSlugs = slugs.filter((slug) => slugSchema.isValidSync(slug));

  const paths = validSlugs.map((slug) => ({
    params: { slug },
  }));

  return { paths, fallback: false };
}

Using isValidSync is appropriate here because we're filtering, not transforming. The synchronous variant avoids unnecessary await calls in a simple filter operation.

Incremental Static Regeneration (ISR) with Yup

ISR combines the speed of SSG with the freshness of SSR. Pages are generated statically, but Next.js regenerates them in the background at a configurable interval. This introduces a subtle validation challenge: the data source might change between the initial build and a regeneration, so your Yup schema must handle both old and new data shapes gracefully.

Adding Revalidation to Static Pages

// pages/products/[id].js
import * as yup from 'yup';

const productSchema = yup.object({
  id: yup.number().required(),
  name: yup.string().required(),
  price: yup.number().positive().required(),
  description: yup.string().default(''),
  inStock: yup.boolean().default(true),
  // New fields added over time should be optional
  discount: yup.number().min(0).max(100).default(0),
  rating: yup.number().min(0).max(5).default(0),
});

export async function getStaticPaths() {
  const res = await fetch('https://api.example.com/products/ids');
  const ids = await res.json();

  return {
    paths: ids.map((id) => ({ params: { id: String(id) } })),
    fallback: 'blocking',
  };
}

export async function getStaticProps({ params }) {
  const res = await fetch(`https://api.example.com/products/${params.id}`);
  const rawData = await res.json();

  try {
    const product = await productSchema.validate(rawData, {
      stripUnknown: true,
      noUnknown: false,
    });

    return {
      props: { product },
      revalidate: 60, // Regenerate at most once per 60 seconds
    };
  } catch (err) {
    console.error('Product validation failed:', err.message);
    return {
      props: {
        product: null,
        error: 'Product data is temporarily unavailable',
      },
      revalidate: 10, // Try again sooner if data was bad
    };
  }
}

export default function ProductPage({ product, error }) {
  if (error || !product) {
    return <p>{error || 'Product not found'}</p>;
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>${product.price.toFixed(2)}</p>
      {product.discount > 0 && (
        <p>{product.discount}% off!</p>
      )}
      <p>{product.description}</p>
      {product.inStock ? (
        <button>Add to Cart</button>
      ) : (
        <p>Out of stock</p>
      )}
    </div>
  );
}

Notice the fallback: 'blocking' setting. This tells Next.js to generate new pages on-demand at request time (using SSR-like behavior) and then cache them as static pages. Combined with Yup validation, this ensures that even first-time visits to a page receive validated data.

Handling Schema Evolution in ISR

Because ISR pages can live in the cache for a long time, your schema needs to be forward-compatible. If your API adds a new field, old cached pages won't have it, and new regenerations will. Make new fields optional with sensible defaults, as shown with discount and rating above.

// Versioned schema approach
const productSchemaV1 = yup.object({
  id: yup.number().required(),
  name: yup.string().required(),
  price: yup.number().positive().required(),
});

const productSchemaV2 = productSchemaV1.shape({
  description: yup.string().default(''),
  inStock: yup.boolean().default(true),
  discount: yup.number().min(0).max(100).default(0),
});

// Use the latest version for validation
export const productSchema = productSchemaV2;

Using .shape() to extend previous versions makes it easy to track schema evolution and ensures backward compatibility.

Client-Side Validation with Shared Schemas

One of the biggest advantages of Yup in a server-rendered app is sharing schemas with the client. Let's build a form component that uses the same createUserSchema we defined earlier.

// components/UserForm.js
import { useState } from 'react';
import { createUserSchema } from '../schemas/user';

export default function UserForm() {
  const [values, setValues] = useState({
    name: '',
    email: '',
    role: 'viewer',
    bio: '',
  });
  const [errors, setErrors] = useState({});
  const [submitting, setSubmitting] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues((prev) => ({ ...prev, [name]: value }));
  };

  const validate = async () => {
    try {
      await createUserSchema.validate(values, { abortEarly: false });
      setErrors({});
      return true;
    } catch (err) {
      const newErrors = {};
      err.inner.forEach((e) => {
        newErrors[e.path] = e.message;
      });
      setErrors(newErrors);
      return false;
    }
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setSubmitting(true);

    const isValid = await validate();
    if (!isValid) {
      setSubmitting(false);
      return;
    }

    const res = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values),
    });

    if (res.ok) {
      alert('User created successfully!');
      setValues({ name: '', email: '', role: 'viewer', bio: '' });
    } else {
      const data = await res.json();
      if (data.errors) {
        const serverErrors = {};
        data.errors.forEach((e) => {
          serverErrors[e.field] = e.message;
        });
        setErrors(serverErrors);
      }
    }

    setSubmitting(false);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Name</label>
        <input
          name="name"
          value={values.name}
          onChange={handleChange}
        />
        {errors.name && <span>{errors.name}</span>}
      </div>

      <div>
        <label>Email</label>
        <input
          name="email"
          value={values.email}
          onChange={handleChange}
        />
        {errors.email && <span>{errors.email}</span>}
      </div>

      <div>
        <label>Role</label>
        <select name="role" value={values.role} onChange={handleChange}>
          <option value="viewer">Viewer</option>
          <option value="editor">Editor</option>
          <option value="admin">Admin</option>
        </select>
        {errors.role && <span>{errors.role}</span>}
      </div>

      <div>
        <label>Bio</label>
        <textarea
          name="bio"
          value={values.bio}
          onChange={handleChange}
        />
        {errors.bio && <span>{errors.bio}</span>}
      </div>

      <button type="submit" disabled={submitting}>
        {submitting ? 'Saving...' : 'Create User'}
      </button>
    </form>
  );
}

This form validates on the client before submission and then again on the server when the API route receives the request. Both use the exact same createUserSchema, so there's no chance of validation rules drifting out of sync.

Best Practices

1. Keep Schemas in a Shared Directory

Place all Yup schemas in a top-level schemas/ directory. This makes them easy to import from both server-side functions (getServerSideProps, getStaticProps, API routes) and client-side components.

2. Use stripUnknown on the Server

Always pass stripUnknown: true when validating untrusted external data on the server. This prevents unexpected fields from reaching your components or database.

3. Fail Builds on Invalid SSG Data

In getStaticProps, throw validation errors to fail the build. Shipping a broken static page is worse than delaying a deployment.

4. Gracefully Handle ISR Validation Failures

In ISR, don't throw — instead, return a fallback prop with a short revalidation interval. This lets the page recover automatically when the data source fixes the issue.

5. Make New Schema Fields Optional

When your API evolves, add new fields as optional with defaults. This keeps cached ISR pages valid even after you deploy a schema update.

6. Validate on Both Sides

Client-side validation improves UX by giving instant feedback. Server-side validation is essential for security. Never skip the latter, even if you implement the former.

7. Use Conditional Schemas for Complex Logic

Yup supports conditional validation with when. Use this for fields that are required only under certain conditions.

const orderSchema = yup.object({
  deliveryMethod: yup.string().oneOf(['pickup', 'delivery']).required(),
  address: yup.string().when('deliveryMethod', {
    is: 'delivery',
    then: (schema) => schema.required('Address is required for delivery'),
    otherwise: (schema) => schema.notRequired(),
  }),
});

8. Cache Validated Data When Possible

If you're validating the same data repeatedly in SSR, consider caching the validated result. Validation is fast, but avoiding redundant API calls and validation passes can meaningfully reduce response times.

Conclusion

Yup is a powerful ally in server-rendered applications. By defining schemas once and reusing them across SSR, SSG, ISR, and client-side code, you eliminate validation duplication and ensure data integrity at every layer of your stack. SSR benefits from request-time validation that guards against malformed API responses. SSG gains build-time safety that prevents broken pages from ever reaching production. ISR gets the best of both worlds with background revalidation that gracefully handles schema evolution. By following the best practices outlined in this tutorial — shared schema directories, stripUnknown on the server, build failures for invalid static data, and graceful fallbacks for ISR — you'll build applications that are both fast and resilient, with validation logic that stays consistent no matter where the code runs.

— Ad —

Google AdSense will appear here after approval

← Back to all articles