Skip to content

Add a more straightforward method of opting dynamic explicit environment variables out of build-time validation #16195

Closed as not planned

Description

@cofl

Describe the problem

With the current method of defining environment variables, dynamic variables are not validated at build time. This is common and expected behavior across a wide variety of frameworks going back decades.

The new "explicit environment variables" has gone a different route, stating that variables must have a value at build time. The logic here seems to be that if a dynamic variable is used in a static capacity, it will need a value. This is not unintuitive -- what is the point of differentiating static and dynamic variables if dynamic variables will be treated just like static variables apart from inlining?

The official workaround is to dynamically make your variables optional just at build time, but this requires sometimes substantial extra code or that potentially undesired dependencies be installed.

Describe the proposed solution

Add a flag prender to EnvVarConfig that controls if variables are validated for prerendering, defaulting to true. Setting this flag to false would prevent a variable with value not known until deploy time from being built.

Alternatives considered

  1. One solution could be writing your own optional validators that only take effect at build time. This seems very verbose and counter-intuitive.

    import { defineEnvVars } from '@sveltejs/kit/hooks'
    import { building } from '$app/env'
    
    export const variables = defineEnvVars({
        SECRET: /** @type {import("@sveltejs/kit").EnvVarConfig<string>} */ {
            schema: building
                ? {
                      ["~standard"]: {
                          version: 1,
                          vendor: "",
                          validate: (value) =>
                              typeof value === "string" ? { value } :
                              typeof value === "undefined" ? { value: "" } :
                            { issues: [{ message: "Value was not string or undefined." }]}
                      },
                  }
                : undefined,
        },
    })
  2. Another solution would be to not use explicit environment variables for dynamic variables that may not be available at build time. "Don't use the feature" is not a good way to use the feature, though...

    import { building } from '$app/env'
    function runtimeOnly(fn){
        if(building) return undefined
        return fn()
    }
    const SECRET = runtimeOnly(() => process.env.SECRET)
  3. Another solution would be to require schema validators, thus leading users to install libraries where the official workaround is less verbose. A pure-SvelteKit solution would still be desireable.

    import { defineEnvVars } from '@sveltejs/kit/hooks'
    import { building } from '$app/env'
    import * as v from "valibot"
    
    function runtimeOnly(schema, dummyValue){
        if(building) return v.optional(schema, dummyValue)
        return schema
    }
    
    const variables = defineEnvVars({
        SECRET: { schema: runtimeOnly(v.string()) }
    })
  4. A fourth solution would be to provide a wrapper schema that does the above automatically.

    import { defineEnvVars, runtimeOnlyEnvVar } from '@sveltejs/kit/hooks'
    import * as v from "valibot"
    
    const variables = defineEnvVars({
        SECRET: { schema: runtimeOnlyEnvVar() },
        OTHER: { schema: runtimeOnlyEnvVar(v.pipe(v.string(), v.url())) },
    })
  5. A fifth solution would be to change the default behavior to not validate dynamic variables at build time, and instead require users to use static variables instead.

  6. Use dummy values. This is unfriendly to new developers on projects, who will see unexpected errors due to incorrect values that have not been overriden.

Some less serious alternatives:

  1. Commit your secrets to git so they're available in CI pipelines.

Importance

would make my life easier

Additional Information

No response

Activity

  1. cofl commented on Jun 29, 2026

    @cofl
    Author

    Another possible alternative could be adding a field for a "fallback" or "building" value that provides a default for when the variable is missing

    import { defineEnvVars } from '@sveltejs/kit/hooks'
    import { building } from '$app/env'
    
    const variables = defineEnvVars({
        SECRET: { fallback: "" }
    })

    Internally, the \0virtual:__sveltekit/env module could then use a simple wrapper function:

    function get_source(from_env, fallback){
        if(from_env !== undefined) return from_env;
        if(typeof fallback === "function") return fallback();
        return fallback;
    }
    
    function set_env(env){
        const issues = {}
        dynamic_private_env.SECRET = validate(
            variables,
            get_source(env.SECRET, variables.SECRET.fallback),
            "SECRET",
            issues
        );
        handle_issues(issues);
    }

    Or a simple change to validate() itself:

    export function validate(variables, value_from_env, name, issues){
        const config = variables[name] ?? {};
        const validator = config.schema;
        const value = get_source(value_from_env, config.fallback);
        // ...
  2. added this to the 3.0 milestone on Jun 29, 2026
  3. dummdidumm commented on Jun 29, 2026

    @dummdidumm
    Member

    On idea by @teemingc is to rename static to something like scope and it's one of buildtime/runtime/inline/dynamic (default dynamic)

  4. added a commit that references this issue on Jul 13, 2026
    d5ffa2d
  5. teemingc commented on Jul 13, 2026

    @teemingc
    Member

    Thanks again for the feedback. Closing this as resolved by #16267

  6. Rich-Harris commented on Jul 14, 2026

    @Rich-Harris
    Member

    #16267 was reverted due to outstanding questions about the API. We might unrevert it, but there's also #16336. Reopening in the meantime

  7. elliott-with-the-longest-name-on-github commented on Jul 16, 2026

    @elliott-with-the-longest-name-on-github
    Contributor

    We've explored a number of possible solutions for this, and... basically I don't think there's anything we can provide that's actually better than just Alternative 1:

    // undefined at buildtime
    schema: building ? v.undefined() : v.string()
    
    // undefined at buildtime, fall back to string (type will be `string` instead of `string | undefined`
    schema: building ? v.undefined('') : v.string()

    Even if you're not using a schema library, this is trivial, as you can pass a function instead of a standard schema:

    schema: building 
      ? (v): asserts v is undefined => { 
        if (v === undefined) {
          throw new Error()
        }
      }
      : (v): asserts v is string => {
        if (typeof v !== 'string') {
          throw new Error()
        }
      } 

    And this is trivially easy to declare as a function, or as a higher order schema, or some other pattern that works for you in your codebase.

  8. cofl commented on Jul 17, 2026

    @cofl
    Author

    That's disappointing. I hope this can be re-visited in the future, along with general improvements to the separation between buildtime and runtime.

    For now, we'll use this ugly hack that blows a hole in type safety so all our use sites (which we know aren't active at build time) aren't littered with pointless and tedious chaining and coalescing:

    function runtime<TSchema extends v.GenericSchema<string, unknown>>(
        schema: TSchema
    ) {
        return building ? v.fallback(schema, "") : schema
    }
    
    export const variables = defineEnvVars({
      RUNTIME_ONLY: {
        schema: runtime(v.string())
      }
    })
  9. cofl commented on Jul 17, 2026

    @cofl
    Author

    Even if you're not using a schema library, this is trivial, as you can pass a function instead of a standard schema:

    I take it this isn't implemented yet? I'm not seeing types for it in the version-3 branch and this check blocks anything that isn't a StandardSchema.

  10. cofl commented on Jul 17, 2026

    @cofl
    Author

    Another way this could be solved, without requiring hacks for the basic case of "I have a SvelteKit app and I would like to build it without risking my secrets in CI", is not shoving all the variables into a single export.

    i.e.:

    export const variables = defineEnvVars({
        SOME_VAR: {}
    })
    
    export const runtimeVariables = defineEnvVars({
        SOME_RUNTIME_ONLY_SECRET: {}
    })

    This multiple-declaration approach could be used by adapters to add special handling for certain sets of variables, or loading directly from alternate sources like secret vaults or runtime secret files (e.g. docker secrets).

    Would it be worth making an issue for that? Is that something adapters could support today through other means?

  11. elliott-with-the-longest-name-on-github commented on Jul 17, 2026

    @elliott-with-the-longest-name-on-github
    Contributor

    we'll use this ugly hack that blows a hole in type safety

    I'm not sure what's particularly ugly about that; it's basically what we'd be exporting if we provided a helper for it. It just looks like code to me. As for type safety... either the thing can be string | undefined (and if it's not available at both runtime and buildtime, it can be undefined), or it has to always be a string. This doesn't really "blow a hole in type safety", it's just falling back to an empty string instead of undefined. Which, again, if we were to ship something, is what we'd have to do internally.

    I'm not sure if the bite is worth the chew on separate exports, nor am I sure it's something adapters could do on their own (as we have to actually generate code within the kit core build process). That being said, depending on use cases, having additional loaders for different kinds of environments is definitely an interesting proposition.

  12. cofl commented on Jul 17, 2026

    @cofl
    Author

    Thanks for the feedback.

    That being said, depending on use cases, having additional loaders for different kinds of environments is definitely an interesting proposition.

    I'll sit on that for a bit and see if I can come up with a good way to ask for it in an issue later this week.

  13. Rich-Harris commented on Jul 17, 2026

    @Rich-Harris
    Member

    It's very far from clear to me what multiple exports would achieve. There is a very simple way to avoid 'risking my secrets in CI' and that is don't put your secrets in CI. If they're not needed at build time for prerendering or whatever then you have perfectly reasonable options for handling it as shown above.

    I take it this isn't implemented yet?

    Err... yes. I think this got mixed up with defineParams, which does let you use functions and schemas interchangeably

  14. cofl commented on Jul 17, 2026

    @cofl
    Author

    It's very far from clear to me what multiple exports would achieve.

    I was thinking it would allow for a whole block of variables to be set aside and handled differently. I agree that the alternatives in the thread above are sufficient in the current implementation of EEV, they just feel very strange (like if you had to free() stack memory).

  15. Rich-Harris commented on Jul 17, 2026

    @Rich-Harris
    Member

    Honestly I would check in an .env file that looked like this...

    # Copy this file to .env.local and populate it with values for local development
    THIS_THING=""
    THAT_THING=""
    OTHER_THING=""

    ...make sure it was present at build time, and call it a day (unless you're not using .env files of course)

  16. cofl commented on Jul 17, 2026

    @cofl
    Author

    That approach does not work. Empty values (either VAR= or VAR="") behave the same as missing values. It would be great if it did, though.

    demo: https://stackblitz.com/edit/sveltekit-empty-env-var-example?file=.env

    Edit: d'Oh. Stackblitz lost the .env file. Here's one that exhibits the problem:

    DEMO=""
  17. Nic-Polumeyv commented on Jul 18, 2026

    @Nic-Polumeyv
    Member

    The empty-value behavior comes from validate in packages/kit/src/exports/internal/env.js, which checks if (!value), so "" is indistinguishable from unset (reproduces on current main). A schema still receives "" as a value, which is why v.string() accepts an empty variable that would otherwise be reported missing.

    The old $env/static/* modules export VAR="" as an empty string, so this is a change relative to the modules EEV replaces. The spec added in #16336 had a test asserting "" for an optional variable is treated the same as undefined.

    Happy to PR either way, value === undefined if empty should count as present, or clearer wording in the error and docs if empty is meant to be unset.

  18. elliott-with-the-longest-name-on-github commented on Jul 18, 2026

    @elliott-with-the-longest-name-on-github
    Contributor

    Yeah, should be value === undefined. Feel free to open a PR. If you're feeling ambitious, Rich was right about the function thing -- we all thought we'd implemented that already but clearly haven't 😆

  19. sacrosanctic commented on Jul 19, 2026

    @sacrosanctic
    Contributor

    You know how we get an error if we import when importing private variables in public places, can we get and error when importing runtime only variables during build?

  20. Rich-Harris commented on Jul 19, 2026

    @Rich-Harris
    Member

    How could such a thing work?

  21. sacrosanctic commented on Jul 20, 2026

    @sacrosanctic
    Contributor

    No idea, I guess I'm just in fantasy land.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions