This document describes the breaking changes you need to be aware of when upgrading between major versions of the Supabase Flutter SDK, together with the steps required to migrate your code.
All packages in this repository are released together for a major version, so a single section
covers supabase_flutter, supabase, supabase_auth, postgrest, supabase_realtime,
supabase_storage, iceberg and supabase_functions. Every symbol mentioned here is re-exported from
supabase_flutter, so the snippets apply whether you depend on the individual package or on the
Flutter one.
Note
v3 has not been released yet. This section is updated as breaking changes land on main, so
treat it as the running list rather than the final one.
The service this package talks to has been called Supabase Auth for years, and gotrue is a name
users no longer recognize. The package is published as supabase_auth from v3 onwards, and the
gotrue package is discontinued on pub.dev when v3 ships.
If you depend on supabase_flutter or supabase you do not need to change your dependencies,
both pull in supabase_auth for you and re-export it. You do need to rename the types below.
If you depend on the auth client directly, rename the dependency and the import:
# Before
dependencies:
gotrue: ^2.27.1
# After
dependencies:
supabase_auth: ^3.0.0// Before
import 'package:gotrue/gotrue.dart';
// After
import 'package:supabase_auth/supabase_auth.dart';The types that carried the old name in their own name are renamed to the Auth prefix the rest of
the package already uses:
| Before | After |
|---|---|
GoTrueClient |
AuthClient |
GoTrueAdminApi |
AuthAdminApi |
GoTrueAdminCustomProvidersApi |
AuthAdminCustomProvidersApi |
GoTrueAdminMFAApi |
AuthAdminMFAApi |
GoTrueAdminOAuthApi |
AuthAdminOAuthApi |
GoTrueAdminPasskeyApi |
AuthAdminPasskeyApi |
GoTrueMFAApi |
AuthMFAApi |
GoTrueOAuthApi |
AuthOAuthApi |
GoTruePasskeyApi |
AuthPasskeyApi |
GotrueAsyncStorage |
AuthAsyncStorage |
SharedPreferencesGotrueAsyncStorage |
SharedPreferencesAuthAsyncStorage |
// Before
final GoTrueClient auth = supabase.auth;
// After
final AuthClient auth = supabase.auth;Two extensions supabase_flutter adds to the auth client follow the same rename:
GoTrueClientSignInProvider becomes AuthClientSignInProvider and GoTrueClientPasskey becomes
AuthClientPasskey. You only name these if you were referring to the extension explicitly, for
example to hide it in an import.
Nothing about the wire format changes. The X-Client-Info header still identifies this client as
gotrue-dart, and the gotrue_meta_security field in captcha payloads is unchanged.
getSSOSignInUrl() now returns a Future<Uri> rather than a Future<String>.
// Before
final String ssoUrl = await supabase.auth.getSSOSignInUrl(domain: 'company.com');
// After
final Uri ssoUrl = await supabase.auth.getSSOSignInUrl(domain: 'company.com');If you need the URL string, use ssoUrl.toString().
OAuthResponse.url is a Uri rather than a String, so getOAuthSignInUrl() and
getLinkIdentityUrl() now hand back the same type getSSOSignInUrl() does.
// Before
final response = await supabase.auth.getOAuthSignInUrl(
provider: OAuthProvider.google,
);
final String url = response.url;
// After
final response = await supabase.auth.getOAuthSignInUrl(
provider: OAuthProvider.google,
);
final Uri url = response.url;If you need the URL string, use response.url.toString(). Callers that were parsing it
themselves can drop the Uri.parse().
The URL the server receives is unchanged, only the Dart type differs.
functions_client says nothing about Supabase and does not match how the rest of the packages are
named. The package is published as supabase_functions from v3 onwards, and the functions_client
package is discontinued on pub.dev when v3 ships.
If you depend on supabase_flutter or supabase you do not need to change anything, both pull in
supabase_functions for you and re-export it.
If you depend on the functions client directly, rename the dependency and the import:
# Before
dependencies:
functions_client: ^2.7.1
# After
dependencies:
supabase_functions: ^3.0.0// Before
import 'package:functions_client/functions_client.dart';
// After
import 'package:supabase_functions/supabase_functions.dart';No types are renamed, FunctionsClient and everything around it keep their names. Nothing about
the wire format changes either: the X-Client-Info header still identifies this client as
functions-dart.
realtime_client says nothing about Supabase and does not match how the rest of the packages are
named. The package is published as supabase_realtime from v3 onwards, and the realtime_client
package is discontinued on pub.dev when v3 ships.
If you depend on supabase_flutter or supabase you do not need to change anything, both pull in
supabase_realtime for you and re-export it.
If you depend on the realtime client directly, rename the dependency and the import:
# Before
dependencies:
realtime_client: ^2.13.0
# After
dependencies:
supabase_realtime: ^3.0.0// Before
import 'package:realtime_client/realtime_client.dart';
// After
import 'package:supabase_realtime/supabase_realtime.dart';The rename does not touch any type names. RealtimeClient, RealtimeChannel and the rest keep
their names, and the X-Client-Info header still identifies this client as realtime-dart.
storage_client says nothing about Supabase and does not match how the rest of the packages are
named. The package is published as supabase_storage from v3 onwards, and the storage_client
package is discontinued on pub.dev when v3 ships.
If you depend on supabase_flutter or supabase you do not need to change anything, both pull in
supabase_storage for you and re-export it.
If you depend on the storage client directly, rename the dependency and the import:
# Before
dependencies:
storage_client: ^2.8.0
# After
dependencies:
supabase_storage: ^3.0.0// Before
import 'package:storage_client/storage_client.dart';
// After
import 'package:supabase_storage/supabase_storage.dart';The rename does not touch any type names. SupabaseStorageClient, StorageFileApi and the rest
keep their names, and the X-Client-Info header still identifies this client as storage-dart.
RealtimeClient used to expose the socket state twice: a typed connState field and a stringly
typed connectionState getter derived from it. The getter is gone and the typed field has taken
its name, so there is now one way to read the socket state.
This one does not produce a compile error if you were only reading the string, so it is worth
checking every use site: the name stayed the same and the type changed from String to
SocketState?.
// Before
if (client.connectionState == 'open') {
// ...
}
// After
if (client.connectionState == SocketState.open) {
// ...
}If you need the string form, use connectionState?.name. For the common case of checking whether
the socket is up, RealtimeClient.isConnected is unchanged and is the better choice.
If you were using the typed field under its old name, rename it:
// Before
final SocketStates? state = client.connState;
// After
final SocketState? state = client.connectionState;The conn shortening on RealtimeClient is spelled out. The behaviour is unchanged, only the
names are different.
| Before | After |
|---|---|
RealtimeClient.conn |
RealtimeClient.connection |
RealtimeClient.connState |
RealtimeClient.connectionState |
RealtimeClient.onConnMessage |
RealtimeClient.onConnectionMessage |
// Before
final WebSocketChannel? socket = client.conn;
client.onConnMessage(rawMessage);
// After
final WebSocketChannel? socket = client.connection;
client.onConnectionMessage(rawMessage);RealtimeClient used to expose its internal state machine as public mutable fields, so external
code could reset the message counter, cancel the heartbeat timer, or add channels to the internal
list, bypassing all lifecycle management. The internals are now private and the remaining public
surface is read-only:
| Before | After |
|---|---|
client.channels (mutable list) |
client.channels (unmodifiable view) |
client.getChannels() |
client.channels |
supabase.getChannels() |
supabase.channels |
client.accessToken = token |
await client.setAccessToken(token) |
client.heartbeatInterval = interval |
RealtimeClient(heartbeatInterval: interval) |
client.customAccessToken = getter |
RealtimeClient(customAccessToken: getter) |
client.reconnectAfter = calculation |
RealtimeClient(reconnectAfter: calculation) |
client.connection = channel |
removed, the getter remains |
client.connectionState = state |
removed, the getter remains |
client.headers['key'] = value |
RealtimeClient(headers: headers) |
client.heartbeatTimer, client.reconnectTimer |
removed |
client.ref, client.pendingHeartbeatRef, client.sendBuffer |
internal and test-only |
channels, accessToken, connection and connectionState are still readable, and everything
that used to be mutated after construction is either a constructor parameter or has a dedicated
method. Channels are added with channel() and removed with removeChannel() or
removeAllChannels(); the returned channels list is an unmodifiable snapshot, so mutating it
throws an UnsupportedError. The headers and parameters maps are also unmodifiable now; pass
them to the constructor instead, or assign SupabaseClient.headers when the client is managed by
a SupabaseClient.
When the client is managed by a SupabaseClient, pass the heartbeat interval and the reconnect
backoff through RealtimeClientOptions instead:
final supabase = SupabaseClient(
supabaseUrl,
supabaseKey,
realtimeClientOptions: const RealtimeClientOptions(
heartbeatInterval: Duration(seconds: 60),
),
);// Before
final client = RealtimeClient(realtimeUrl);
client.heartbeatInterval = const Duration(seconds: 60);
client.accessToken = newToken;
final channels = client.getChannels();
// After
final client = RealtimeClient(
realtimeUrl,
heartbeatInterval: const Duration(seconds: 60),
);
await client.setAccessToken(newToken);
final channels = client.channels;setAccessToken() is the intentional path for token rotation: unlike the removed field write, it
also propagates the new token to every joined channel.
RealtimeChannel received the same treatment as RealtimeClient. Its join bookkeeping used to be
public mutable fields that were only marked @internal, so external code could reassign the join
push, replace the presence tracker, or mutate the join parameters underneath the channel:
| Before | After |
|---|---|
channel.joinedOnce = value |
removed, the getter remains (internal) |
channel.joinPush = push |
removed, the getter remains (internal and test-only) |
channel.presence |
removed |
channel.parameters (mutable map) |
channel.parameters (unmodifiable view, internal) |
These members were never part of the supported API surface, but they were reachable. If you read
presence state through channel.presence, use channel.presenceState() and the
onPresenceSync, onPresenceJoin, and onPresenceLeave streams instead. The join payload is
only updated internally; there is no supported way to mutate parameters after the channel is
created, so pass the configuration through RealtimeChannelConfig when creating the channel.
sendBroadcastMessage() used to silently post to the REST broadcast endpoint whenever the channel
could not push over the WebSocket, logging a warning that the fallback would go away. It has gone
away: the message is only ever sent over the WebSocket, and calling it on a channel that was never
subscribed throws instead.
The fallback made delivery depend on socket timing, so the same call could take two different
transports with two different sets of failure modes. httpSend() is the explicit REST path, and it
works without subscribing at all.
// Before
final channel = supabase.channel('room');
// Delivered over REST because the channel was never subscribed.
await channel.sendBroadcastMessage(
event: 'cursor-pos',
payload: {'x': 12, 'y': 34},
);
// After, over REST
final channel = supabase.channel('room');
await channel.httpSend(
event: 'cursor-pos',
payload: {'x': 12, 'y': 34},
);
// After, over the WebSocket
final channel = supabase.channel('room')..subscribe();
await channel.sendBroadcastMessage(
event: 'cursor-pos',
payload: {'x': 12, 'y': 34},
);Messages sent between subscribe() and the channel actually joining are still buffered and
flushed once the join succeeds, so only channels that were never subscribed throw.
httpSend() requires a Realtime server running v2.97.0 or newer.
Every recurring-event listener in realtime_client is now a Dart Stream instead of a callback,
following the shape RealtimeClient.onHeartbeat already had. Streams compose (map, where,
firstWhere, timeout), support multiple listeners, and removing a listener is a
StreamSubscription.cancel(), which the callback API had no public equivalent for.
On RealtimeClient, the four connection callbacks are replaced by two broadcast streams:
onStatusChange for the connection lifecycle and onMessage for every decoded frame. Connection
errors are emitted as stream errors on onStatusChange, so they arrive through the onError
handler of listen:
// Before
client.onOpen(() => print('open'));
client.onClose((event) => print('closed: $event'));
client.onError((error) => print('error: $error'));
client.onMessage((message) => print('message: $message'));
// After
client.onStatusChange.listen(
(change) => switch (change.status) {
RealtimeConnectionStatus.open => print('open'),
RealtimeConnectionStatus.closed => print('closed: ${change.closeEvent}'),
},
onError: (error) => print('error: $error'),
);
client.onMessage.listen((message) => print('message: $message'));Four separate streams for one connection was a different shape than the channel, where all of
open, closed and error already arrive on a single RealtimeChannel.onStatusChange. Since a stream
needs listen and a cancelled subscription to clean up, one status stream is also less
bookkeeping than three.
On RealtimeChannel, onPostgresChanges and onBroadcast no longer take a callback parameter
and return a typed stream instead of the channel, so they can no longer be chained. Repeated calls
with the same arguments return the same stream. For postgres_changes the stream still has to be
created before subscribe(), because the requested changes are part of the join payload, but it
can be listened to at any point:
// Before
supabase
.channel('room')
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'messages',
callback: (payload) => print(payload),
)
.onBroadcast(
event: 'cursor-pos',
callback: (payload) => print(payload),
)
.subscribe();
// After
final channel = supabase.channel('room');
channel
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'messages',
)
.listen(print);
channel.onBroadcast(event: 'cursor-pos').listen(print);
channel.subscribe();The presence and system listeners are stream getters, and onSystemEvents emits a typed
RealtimeSystemPayload instead of a raw payload:
// Before
channel.onPresenceSync((payload) { /* ... */ });
channel.onPresenceJoin((payload) { /* ... */ });
channel.onPresenceLeave((payload) { /* ... */ });
channel.onSystemEvents((payload) {
final system = RealtimeSystemPayload.fromJson(
Map<String, dynamic>.from(payload as Map),
);
});
// After
channel.onPresenceSync.listen((payload) { /* ... */ });
channel.onPresenceJoin.listen((payload) { /* ... */ });
channel.onPresenceLeave.listen((payload) { /* ... */ });
channel.onSystemEvents.listen((system) { /* ... */ });subscribe() no longer takes a status callback. Status changes are emitted on the new
RealtimeChannel.onStatusChange stream as RealtimeSubscribeStatusChange values, which carry the
RealtimeSubscribeStatus and, for channelError, the error that caused it. The optional timeout
moved up to be the first positional parameter:
// Before
channel.subscribe((status, [error]) {
if (status == RealtimeSubscribeStatus.subscribed) {
// ...
} else if (status == RealtimeSubscribeStatus.channelError) {
print('error: $error');
}
}, const Duration(seconds: 10));
// After
channel.onStatusChange.listen((change) {
if (change.status == RealtimeSubscribeStatus.subscribed) {
// ...
} else if (change.status == RealtimeSubscribeStatus.channelError) {
print('error: ${change.error}');
}
});
channel.subscribe(const Duration(seconds: 10));All channel streams complete when the channel closes, so await for loops and onDone handlers
end on their own once the channel is gone.
Binding and BindingCallback were the raw registration primitives underneath the channel
listeners, exported by accident: their only consumers, RealtimeChannel.onEvents and
RealtimeChannel.off, have always been internal. They are no longer exported. Use the typed
channel streams (onPostgresChanges, onBroadcast, onPresenceSync, onPresenceJoin,
onPresenceLeave, onSystemEvents) instead.
RealtimePresence and its helper types (PresenceOpts, PresenceEvents, PresenceChooser,
PresenceOnJoinCallback, PresenceOnLeaveCallback) are now @internal, along with the
RealtimeChannel.presence field. They were presence bookkeeping that leaked into the public API,
and registering a callback through channel.presence.onJoin(...) silently disabled the channel's
own presence events, because the channel's forwarders occupied the same single callback slot.
Everything the class offered is available on the channel:
// Before
channel.presence.onJoin((key, current, joined) { /* ... */ });
channel.presence.onLeave((key, current, left) { /* ... */ });
channel.presence.onSync(() { /* ... */ });
final Map<String, List<Presence>> state = channel.presence.state;
// After
channel.onPresenceJoin.listen((payload) { /* ... */ });
channel.onPresenceLeave.listen((payload) { /* ... */ });
channel.onPresenceSync.listen((payload) { /* ... */ });
final List<SinglePresenceState> state = channel.presenceState();presenceState() is not a drop-in replacement for presence.state: it returns a
List<SinglePresenceState> rather than a map, so a presence key is read from
SinglePresenceState.key and its payloads from SinglePresenceState.presences. When code depended
on the map, rebuild it from the list:
final byKey = {
for (final state in channel.presenceState()) state.key: state.presences,
};The Presence payload class is unchanged and stays public.
The codec overrides on RealtimeClient were synchronous and worked on raw maps, so the JSON work
always ran on the main isolate and a large payload could block the event loop. Both typedefs now
work on a RealtimeMessage and return a Future, which lets you hand the work to a background
isolate:
| Before | After |
|---|---|
Object Function(Map<String, dynamic>) |
Future<Object> Function(RealtimeMessage) |
Map<String, dynamic> Function(Object) |
Future<RealtimeMessage> Function(Object) |
RealtimeMessage carries the joinRef, ref, topic, event and payload of a message, and
converts to and from the shape a protocol version puts on the wire, so a codec only has to turn that
shape into bytes and back. Outgoing frames are written in push order and incoming messages are
dispatched in receive order, even when a later payload finishes encoding or decoding first. A codec
call that never completes fails after RealtimeClient.timeout instead of stalling every write or
dispatch queued behind it:
final isolate = YAJsonIsolate();
final client = RealtimeClient(
'wss://project.supabase.co/realtime/v1',
encode: (message) => isolate.encode(message.toJson()),
decode: (frame) async =>
RealtimeMessage.fromJson(await isolate.decode(frame as String)),
);RealtimeClient.onMessage now emits RealtimeMessage instead of Map<String, dynamic>, so a
listener that reads fields off the map needs to switch to properties:
// Before
client.onMessage.listen((message) => print(message['event']));
// After
client.onMessage.listen((message) => print(message.event));toJson and RealtimeMessage.fromJson default to protocol 2.0.0; pass
RealtimeProtocolVersion.v1 to either when the client runs on the legacy protocol.
RealtimeClientOptions takes the same two callbacks, so a codec can be set on SupabaseClient and
on Supabase.initialize without constructing a RealtimeClient yourself:
await Supabase.initialize(
url: url,
anonKey: anonKey,
realtimeClientOptions: RealtimeClientOptions(
encode: (message) => isolate.encode(message.toJson()),
decode: (frame) async =>
RealtimeMessage.fromJson(await isolate.decode(frame as String)),
),
);encode and decode are now null unless you pass one; RealtimeClient uses the built-in codec
for whichever of the two is null. That codec is synchronous, so a client that overrides neither
still writes and dispatches without a microtask hop. Reading a custom codec back off the client
changed accordingly; the built-in codec has no public accessor, so this only applies when you
passed your own:
// Before
final Map<String, dynamic> message = client.decode(frame);
// After
final RealtimeMessage message = await client.decode!(frame);A codec replaces the built-in one completely, so the one above handles text frames only. Handle a
Uint8List frame as well if you send or receive binary broadcasts.
A Dart enum type names one value rather than the set, so its name should be singular. Five enums
carried plural names instead, and one more turned up in storage.
Three of them were never reachable from outside the package and are now marked @internal; the
ones below are the renames you can actually hit.
| Before | After | Package |
|---|---|---|
SocketStates |
SocketState |
supabase_realtime |
PostgresTypes |
PostgresType |
supabase_realtime |
AuthenticatorAssuranceLevels |
AuthenticatorAssuranceLevel |
supabase_auth |
LoadTableSnapshots |
TableSnapshotScope |
supabase_storage |
No enum values changed, so the only work is renaming the type where you name it explicitly.
// Before
final AuthenticatorAssuranceLevels? level =
supabase.auth.mfa.getAuthenticatorAssuranceLevel().currentLevel;
// After
final AuthenticatorAssuranceLevel? level =
supabase.auth.mfa.getAuthenticatorAssuranceLevel().currentLevel;LoadTableSnapshots was additionally renamed to describe what it is rather than where it is used,
since it selects which snapshots a table load returns:
// Before
await catalog.loadTableResult(
id,
const LoadTableOptions(snapshots: LoadTableSnapshots.refs),
);
// After
await catalog.loadTableResult(
id,
const LoadTableOptions(snapshots: TableSnapshotScope.refs),
);Every timestamp the SDK returns is now a DateTime in UTC, parsed once when the response is
decoded, instead of a raw ISO 8601 String or a Unix timestamp int. Comparing, formatting and
doing arithmetic on them no longer requires parsing them yourself.
| Type | Fields | Before | After |
|---|---|---|---|
Session |
expiresAt |
int? (Unix seconds) |
DateTime? |
User |
createdAt |
String |
DateTime |
User |
confirmationSentAt, recoverySentAt, emailChangeSentAt, invitedAt, emailConfirmedAt, phoneConfirmedAt, lastSignInAt, updatedAt |
String? |
DateTime? |
UserIdentity |
createdAt, lastSignInAt, updatedAt |
String? |
DateTime? |
OAuthClient |
createdAt, updatedAt |
String |
DateTime |
Bucket |
createdAt, updatedAt |
String |
DateTime |
FileObject |
createdAt, updatedAt |
String? |
DateTime? |
FileObjectV2 |
createdAt |
String |
DateTime |
FileObjectV2 |
updatedAt, lastModified |
String? |
DateTime? |
PaginatedFile |
createdAt, updatedAt |
String? |
DateTime? |
// Before
final expiresAt = supabase.auth.currentSession?.expiresAt;
final expiry = expiresAt == null
? null
: DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000);
final createdAt = DateTime.parse(user.createdAt);
// After
final expiry = supabase.auth.currentSession?.expiresAt;
final createdAt = user.createdAt;If you need the previous representation, ask for it explicitly:
final isoString = user.createdAt.toIso8601String();
final unixSeconds = session.expiresAt!.millisecondsSinceEpoch ~/ 1000;The wire format is unchanged. Of the types above only Session, User and UserIdentity have a
toJson(), and those still write ISO 8601 strings for the User and UserIdentity timestamps and
Unix seconds for Session.expires_at, so sessions persisted by v2 are still readable by v3. The
storage types have no toJson(), in v2 or v3.
Three behavioural details are worth checking:
- The
DateTimevalues are in UTC.DateTimeequality takes the time zone flag into account, so compare againstDateTime.utc(...)rather thanDateTime(...), or calltoLocal()first. - A timestamp the server is documented to always send is now parsed strictly.
User.createdAtused to fall back to an empty string when the field was missing and now throws aFormatException, which surfaces a malformed payload instead of passing an unusable value on. - A timestamp naming a date that does not exist is rejected rather than rolled forward.
DateTime.parsereads2019-02-29as 1 March 2019; parsing now throws aFormatExceptioninstead.
The OAuth 2.1 server returns only an id and an email for the user a pending authorization request
belongs to, so OAuthAuthorizationDetailsResponse.user was a User with every other field
defaulted. It is now an OAuthAuthorizingUser, which carries exactly the two fields the server
sends, matching what the other Supabase client libraries expose.
// Before
final User user = details.user;
// After
final OAuthAuthorizingUser user = details.user;id and email keep their names, so code that only reads those needs no change.
PostgrestTransformBuilder.order() and SupabaseStreamBuilder.order() defaulted ascending to
false, so a call that left the parameter out sorted descending. That was the opposite of SQL's
ORDER BY and of the other Supabase client libraries. Both now default to true.
Like the connectionState change above, this one does not produce a compile error. The call still
compiles and still returns the same rows, only in the reverse order, so it is worth checking every
.order() call that does not pass ascending explicitly, in both select() queries and
stream().
// Before: newest first
final messages = await supabase.from('messages').select().order('created_at');
// After: oldest first
final messages = await supabase.from('messages').select().order('created_at');To keep the previous behaviour, ask for descending order explicitly:
final messages = await supabase
.from('messages')
.select()
.order('created_at', ascending: false);ascending: false already means descending on v2, so you can add it to your current code before
upgrading and leave this change out of the upgrade itself.
nullsFirst is unchanged and still defaults to false.
listUsers() accepted page and perPage but returned a bare List<User>, so the pagination
metadata the server reports went unused and there was no way to tell how many users or pages exist
short of requesting pages until a short one came back. It now returns a ListUsersResponse that
carries the page of users plus that metadata.
// Before
final List<User> users = await supabase.auth.admin.listUsers(perPage: 50);
for (final user in users) {
print(user.email);
}
// After
final response = await supabase.auth.admin.listUsers(perPage: 50);
for (final user in response.users) {
print(user.email);
}total comes from the X-Total-Count response header, nextPage and lastPage from the Link
header, and audience from the aud field of the body. nextPage is null on the last page, so
you can walk every page without guessing where it ends:
var response = await supabase.auth.admin.listUsers(perPage: 50);
while (response.nextPage != null) {
response = await supabase.auth.admin.listUsers(
page: response.nextPage,
perPage: 50,
);
}v3 drops the whole deprecated surface that accumulated over v1 and v2. Where an entry has a replacement, that replacement has been available for at least one minor version. The rest were already inert: unused types, options the client ignored, or values the server never sent.
| Removed | Replacement | Package |
|---|---|---|
AuthChangeEvent.userDeleted |
none, it was never emitted | supabase_auth |
OAuthProvider.snakeCase |
OAuthProvider.name |
supabase_auth |
User.confirmedAt |
User.emailConfirmedAt |
supabase_auth |
ReturningOption |
none, it was unused | postgrest |
PostgrestClient.auth() |
none, pass an Authorization header instead |
postgrest |
RealtimeClient.longpollerTimeout |
none, there is no longpoll transport | supabase_realtime |
ChannelResponse.rateLimited |
none, it was never returned | supabase_realtime |
FileObject.lastAccessedAt, FileObjectV2.lastAccessedAt |
none, the server does not populate it | supabase_storage |
AuthUser |
User |
supabase |
RealtimeClientOptions.eventsPerSecond |
none, it was already ignored | supabase |
RemoveSubscriptionResult |
none | supabase |
SupabaseRealtimeError |
none | supabase |
SupabaseEventTypes and SupabaseEventTypesName |
none, it was unused | supabase |
SupabaseStreamBuilder.execute() |
listen to the builder directly | supabase |
Both lastAccessedAt fields were also required constructor parameters, so any code that builds a
FileObject or FileObjectV2 by hand drops that argument.
Two of these need more than a rename.
User.confirmedAt mirrored emailConfirmedAt and is no longer parsed from or written to JSON, so
toJson() output no longer contains a confirmed_at key:
// Before
final confirmed = user.confirmedAt != null;
// After
final confirmed = user.emailConfirmedAt != null;SupabaseStreamBuilder has been a Stream since 1.0.0, so execute() only returned the builder's
own stream:
// Before
supabase.from('users').stream(primaryKey: ['id']).execute().listen(handle);
// After
supabase.from('users').stream(primaryKey: ['id']).listen(handle);The deprecated anonKey parameter is removed and publishableKey is now required. A legacy anon
key is still a valid value, it just goes under the new name.
// Before
await Supabase.initialize(url: url, anonKey: anonKey);
// After
await Supabase.initialize(url: url, publishableKey: anonKey);The old createSignedUrls returned List<SignedUrl> and silently dropped paths the server could
not sign, so there was no way to tell a missing file from a successful one. It is removed, and
createSignedUrlsResult takes over the name. Each entry in the returned list is either a
SignedUrlSuccess or a SignedUrlFailure.
// Before
final urls = await supabase.storage
.from('avatars')
.createSignedUrls(['a.png', 'b.png'], 60);
for (final url in urls) {
print(url.signedUrl);
}
// After
final results = await supabase.storage
.from('avatars')
.createSignedUrls(['a.png', 'b.png'], 60);
for (final result in results) {
switch (result) {
case SignedUrlSuccess(:final signedUrl):
print(signedUrl);
case SignedUrlFailure(:final path, :final error):
print('could not sign $path: $error');
}
}If you were already on createSignedUrlsResult, drop the Result suffix from the call.
Confirming an email or phone change used to emit AuthChangeEvent.signedIn, which made it
indistinguishable from an actual sign-in. It now emits AuthChangeEvent.userUpdated, the same event
that updateUser() emits when the change is requested. This applies to every way the change can be
confirmed:
| Confirmation | Before | After |
|---|---|---|
verifyOTP() with OtpType.emailChange or OtpType.phoneChange |
signedIn |
userUpdated |
getSessionFromUrl() with an implicit type=email_change link |
signedIn |
userUpdated |
exchangeCodeForSession() for a PKCE code from an email change |
signedIn |
userUpdated |
The session is still saved and currentSession still updates, only the event differs. Because this
is a runtime change and not a compile error, check any onAuthStateChange listener that navigates
or fetches on signedIn and expects the email-change confirmation to reach it:
// Before
supabase.auth.onAuthStateChange.listen((data) {
if (data.event == AuthChangeEvent.signedIn) {
// Ran both on sign-in and after an email change was confirmed.
}
});
// After
supabase.auth.onAuthStateChange.listen((data) {
if (data.event == AuthChangeEvent.signedIn) {
// Only runs on an actual sign-in.
} else if (data.event == AuthChangeEvent.userUpdated) {
// Runs when the user record changed, including a confirmed email change.
}
});For the PKCE case, AuthSessionUrlResponse.redirectType is 'userUpdated' instead of null, so
you can also branch on the response of exchangeCodeForSession() directly.
supabase_functions and postgrest each declared their own HttpMethod. There is now one, shaped
like postgrest's and re-exported from both, so imports are unchanged and postgrest callers are
unaffected. For functions.invoke, two things changed:
headwas added, so an exhaustiveswitchoverHttpMethodno longer compiles until you handle it.- The values were reordered, so
indexshifted forpost(1 to 2),put(2 to 3) anddelete(3 to 5). This one is not a compile error, so replace any persistedindexwithname.
The enum also exposes value, the uppercase wire form, in place of method.name.toUpperCase().
The auth client used to read the x-supabase-api-version response header and decide how to parse
errors from it: the code field on servers reporting 2024-01-01 or newer, and the older
error_code field on anything else. It also reconstructed AuthWeakPasswordException by inspecting
a bare weak_password body, for servers so old they sent no error code at all.
Both fallbacks are gone. Error codes are now always read from code, and AuthWeakPasswordException
is only thrown when the server names it. The client still sends
x-supabase-api-version: 2024-01-01 on every request, but no longer looks at what comes back.
Hosted Supabase projects have been past this version for a long time, so this only affects
self-hosted setups pinned to a GoTrue older than 2024-01-01. Against one of those, an auth failure
still throws AuthApiException with the right message and status code, but code is null and a
weak password surfaces as a plain AuthApiException rather than AuthWeakPasswordException. Upgrade
the server to restore both.
The ApiVersions class, and its ApiVersions.v20240101 field, are removed along with it. Nothing
replaces them; they only existed to drive the comparison above.
SharedPreferencesLocalStorage and SharedPreferencesAuthAsyncStorage, the storage
implementations Supabase.initialize uses by default, wrote through the legacy
SharedPreferences
API. They now use SharedPreferencesAsync. On web the session still goes into
window.localStorage under the same key as before, so nothing changes there.
The two APIs do not share a store on every platform, and on the ones where they do the legacy API
prefixes its keys, so a session written by v2 is invisible to the new one. initialize() therefore
moves an existing session over to SharedPreferencesAsync the first time it runs and deletes the
legacy entry, so your users stay signed in. No code change is needed for this, and there is nothing
to migrate if you already pass your own LocalStorage.
What this does mean is that the SDK no longer holds up its end of a mixed setup, and mixing is worse than it first looks. How the two APIs relate depends on the platform:
| Platform | Relationship between the two APIs |
|---|---|
| Windows, Linux | One shared_preferences.json, rewritten in full from each API's own cache, so a write through either one can drop what the other wrote |
| Android | Separate stores, SharedPreferences against DataStore, so a value written through one is invisible to the other |
| iOS, macOS, web | One store, but the legacy API prefixes its keys with flutter., so a value written through one is invisible to the other |
Only the first row loses data, and it loses it in both directions. That is what made sessions go
missing in v2, and from v3 on the same collision runs the other way: a session write by the SDK can
drop preferences your own code wrote through the legacy API. So if your code still calls
SharedPreferences.getInstance(), this is the moment to
migrate it to SharedPreferencesAsync
as well. The snippet below is the way out if you cannot do that yet.
If you would rather keep the session in the legacy store for now, pass a LocalStorage that reads
and writes it. Supplying your own storage is also where the session key comes in: initialize()
derives it from your project URL for the default storage, so you only name the key when you
construct a LocalStorage yourself, and defaultPersistSessionKey hands you the same one.
class LegacySharedPreferencesLocalStorage extends LocalStorage {
LegacySharedPreferencesLocalStorage({required this.persistSessionKey});
final String persistSessionKey;
late final SharedPreferences _preferences;
@override
Future<void> initialize() async {
_preferences = await SharedPreferences.getInstance();
}
@override
Future<bool> hasAccessToken() async =>
_preferences.containsKey(persistSessionKey);
@override
Future<String?> accessToken() async =>
_preferences.getString(persistSessionKey);
@override
Future<void> removePersistedSession() =>
_preferences.remove(persistSessionKey);
@override
Future<void> persistSession(String persistSessionString) =>
_preferences.setString(persistSessionKey, persistSessionString);
}
await Supabase.initialize(
url: url,
publishableKey: publishableKey,
authOptions: FlutterAuthClientOptions(
localStorage: LegacySharedPreferencesLocalStorage(
persistSessionKey: defaultPersistSessionKey(url),
),
),
);Widget tests that call Supabase.initialize need one more line of setup.
SharedPreferences.setMockInitialValues() only stands in for the legacy API, so on its own the new
storage throws StateError: The SharedPreferencesAsyncPlatform instance must be set. Register an
in-memory async store next to it:
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
setUp(() {
SharedPreferences.setMockInitialValues({});
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});shared_preferences_platform_interface needs to be a dev_dependency for that import. Passing
FlutterAuthClientOptions(localStorage: const EmptyLocalStorage()) instead skips storage in tests
altogether.
The constant existed for the v1 to v2 migration from Hive, which v3 no longer carries, and the SDK
itself never read it. The session is stored under the key you pass to LocalStorage, which for the
default storage is sb-<project-ref>-auth-token.
The LocalStorage examples in the README used the constant as their storage key, so if you copied
one of those, take the key as a parameter instead:
// Before
class MySecureStorage extends LocalStorage {
@override
Future<String?> accessToken() => storage.read(key: supabasePersistSessionKey);
// ...
}
// After
class MySecureStorage extends LocalStorage {
MySecureStorage({required this.persistSessionKey});
final String persistSessionKey;
@override
Future<String?> accessToken() => storage.read(key: persistSessionKey);
// ...
}
await Supabase.initialize(
url: url,
publishableKey: publishableKey,
authOptions: FlutterAuthClientOptions(
localStorage: MySecureStorage(
persistSessionKey: defaultPersistSessionKey(url),
),
),
);Passing the key you already store under keeps your users signed in; switching to a different key
signs them out once. To keep the old value, pass 'SUPABASE_PERSIST_SESSION_KEY', which is what the
constant held.
The MigrationLocalStorage and HiveLocalStorage snippets that migrated a v1 session out of
hive are gone from the README along with it. If you are still on
v1, upgrade to v2 first and let it migrate the session, then move to v3.
AuthException, PostgrestException, StorageException and FunctionException each reimplemented
the same message plus status shape under different field names and types. They now extend a shared
SupabaseException, and the ones reporting a response from a service also mix in
SupabaseApiException:
abstract class SupabaseException implements Exception {
final String message;
final String? errorCode;
}
mixin SupabaseApiException on SupabaseException {
int get statusCode;
}So statusCode is a non-nullable int that exists exactly when a service answered, and a failure
the client raised on its own carries only a message and, where the client can name it, an
errorCode. Both types are re-exported from every package, so one catch handles a failure from any
service:
try {
await supabase.from('countries').select();
} on SupabaseApiException catch (error) {
print('${error.statusCode}: ${error.message}');
} on SupabaseException catch (error) {
print(error.message);
}The renames:
| Before | After |
|---|---|
AuthException.statusCode (String?) |
AuthApiException.statusCode (int) |
AuthException.code |
AuthException.errorCode |
StorageException.statusCode (String?) |
StorageApiException.statusCode (int) |
StorageException.error |
errorCode |
StorageException.fromJson(json, '404') |
StorageApiException.fromJson(json, 404) |
PostgrestException |
PostgrestApiException |
PostgrestException.code |
PostgrestApiException.errorCode, with the HTTP status in statusCode |
PostgrestException.fromJson(json, code: 409) |
PostgrestApiException.fromJson(json, statusCode: 409) |
PostgrestException.toJson() key code |
keys statusCode and errorCode |
FunctionsHttpException |
FunctionsApiException |
FunctionException.status (int) |
FunctionsApiException.statusCode |
FunctionException.reasonPhrase |
folded into message |
FunctionsFetchException.status == 0 |
no status at all, no response reached the client |
FunctionResponse.status |
FunctionResponse.statusCode |
Reading a status off a per-service base no longer compiles, since the base no longer has one. Narrow the catch to the API type:
// Before
try {
await supabase.auth.signInWithPassword(email: email, password: password);
} on AuthException catch (error) {
if (error.statusCode == '429') {
// ...
}
}
// After
try {
await supabase.auth.signInWithPassword(email: email, password: password);
} on AuthApiException catch (error) {
if (error.statusCode == 429) {
// ...
}
}Four changes go beyond a rename:
PostgrestException.codeno longer doubles as the status. It held the PostgREST or PostgreSQL code, except when the error body was not JSON, where it held the HTTP status instead.errorCodeis now only ever the former andstatusCodeonly ever the latter, so a duplicate key violation reads asstatusCode: 409, errorCode: '23505'.AuthSessionMissingExceptionandAuthInvalidJwtExceptionreport no status. The400they used to carry was invented by the client, which raises both without making a request. They reporterrorCodevalues ofsession_missingandinvalid_jwtinstead.AuthRetryableFetchExceptioncovers only the transport case now, where the request never reached the service. A retryable 5xx the service answered is anAuthRetryableApiException, which carries the status. CatchingAuthRetryableFetchExceptionstill gets both.FunctionExceptiongained a message. It had onlystatus,detailsandreasonPhrase. The response's reason phrase becomes the message, falling back to a per-subtype default when the response carries none, as over HTTP/2. The response body is still indetails.
AuthUnknownException also no longer reports a status of its own. It derived one from
originalError, which it still exposes, so read it from there when that is an http.Response.
RealtimeSubscribeException is not part of this hierarchy: it reports a channel subscription
outcome rather than a request failure, and carries a RealtimeSubscribeStatus instead of a message.
FunctionException is now a sealed class, so a switch over it is exhaustive at compile time and
adding a failure mode in a later version is a compile error rather than a case that silently falls
through:
try {
await supabase.functions.invoke('hello');
} on FunctionException catch (error) {
final message = switch (error) {
FunctionsFetchException() => 'The request never reached the function',
FunctionsRelayException() => 'The relay reported an error',
FunctionsApiException() => 'The function returned ${error.statusCode}',
};
}FunctionsRelayException extends FunctionsApiException, so it has to come first for its case to
be reachable. Only FunctionsFetchException and FunctionsApiException are needed for the switch
to be exhaustive.
sealed implies abstract, so a bare FunctionException can no longer be constructed, and code
outside supabase_functions can no longer extend or implement it. Name one of the three subtypes
instead, which is what the client throws in every case.
IcebergException used 0 as the status code when no response was received, so callers
had to know that statusCode == 0 meant "no response" rather than a real status. The sealed
hierarchy now splits the same way as the other packages:
IcebergNetworkException |
no response was received from the catalog, so there is no status code and the outcome of the request is unknown |
IcebergApiException |
the catalog answered, so statusCode is a real, non-nullable status |
IcebergApiException is the sealed base for the response-backed subtypes, which are unchanged:
IcebergNotFoundException, IcebergConflictException,
IcebergAuthenticationTimeoutException, IcebergCommitStateUnknownException,
IcebergServerException and IcebergUnknownException.
| Before | After |
|---|---|
IcebergException.type |
errorCode, from SupabaseException |
IcebergException.statusCode |
IcebergApiException.statusCode; gone from the network case |
IcebergException.statusCode == 0 |
catch IcebergNetworkException, or check is SupabaseApiException |
IcebergException.fromResponse |
IcebergApiException.fromResponse |
message, code and details keep their names. code is still the Iceberg numeric error code,
which is unrelated to errorCode, the string error type such as NoSuchTableException.
// Before
try {
await catalog.loadTable(id);
} on IcebergException catch (error) {
if (error.statusCode == 0) {
// no response was received
}
print(error.type);
}
// After
try {
await catalog.loadTable(id);
} on IcebergNetworkException catch (error) {
// no response was received, so the outcome of the request is unknown
print(error.details);
} on IcebergApiException catch (error) {
print('${error.statusCode}: ${error.errorCode}');
}Exhaustive switches over the sealed hierarchy still compile with the same set of cases, since the new base is sealed and every concrete subtype is unchanged.
IcebergRestCatalog, the exceptions above and the table and namespace types now live in
iceberg, mirroring the split between storage-js and iceberg-js. supabase_storage
depends on it and re-exports the whole surface, so importing
package:supabase_storage/supabase_storage.dart or package:supabase_flutter/supabase_flutter.dart
keeps working unchanged, and storage.analyticsCatalog() is still how you get a catalog for an
analytics bucket.
Depend on iceberg directly to talk to an Iceberg REST Catalog without the rest of Storage:
final catalog = IcebergRestCatalog(
baseUrl: 'https://example.com/iceberg',
headers: {'Authorization': 'Bearer $token'},
warehouse: 'my-warehouse',
);Public identifiers that still used abbreviations are spelled out, continuing the precedent set by
conn to connection above. The wire format is unchanged throughout: where a JSON key, query
parameter or URL path matched the abbreviation, only the Dart identifier is renamed.
Across every package:
| Before | After |
|---|---|
RealtimeClient.setAuth() |
RealtimeClient.setAccessToken() |
queryParams: |
queryParameters: |
opts: |
options: |
PresenceOpts |
PresenceOptions |
appendSearchParams |
appendSearchParameters |
overrideSearchParams |
overrideSearchParameters |
toQueryParams |
toQueryParameters |
realtime_client:
| Before | After |
|---|---|
RealtimeClient.params |
RealtimeClient.parameters |
RealtimeClient.endPoint |
RealtimeClient.endpoint |
RealtimeClient.endPointURL |
RealtimeClient.endpointUrl |
RealtimeConstants.wsCloseNormal |
RealtimeConstants.webSocketCloseNormal |
RealtimeProtocolVersion.vsn |
RealtimeProtocolVersion.wireVersion |
RealtimeChannel(topic, socket, params: …) |
RealtimeChannel(topic, socket, config: …) |
Presence.presenceRef |
Presence.presenceReference |
supabase_auth:
| Before | After |
|---|---|
JwtPayload.iss/sub/aud/exp/nbf/iat/jti |
issuer, subject, audience, expiresAt, notBefore, issuedAt, jwtId |
JwtHeader.alg/kid/typ |
algorithm, keyId, type |
JWK.kty/keyOps/alg/kid |
keyType, keyOperations, algorithm, keyId |
User.aud |
User.audience |
CreateOAuthClientParams / UpdateOAuthClientParams |
…Options |
CreateCustomProviderParams / UpdateCustomProviderParams |
…Options |
authorizationParams |
authorizationParameters |
supportedIdTokenSigningAlgs |
supportedIdTokenSigningAlgorithms |
tokenEndpointAuthMethod |
tokenEndpointAuthenticationMethod |
userinfoEndpoint / userinfoUrl |
userInfoEndpoint / userInfoUrl |
validateExp(int? exp) |
validateExpiration(int? expiresAt) |
AMRMethod / AMREntry |
AuthenticationMethodReference / …Entry |
AuthChangeEvent.jsName |
AuthChangeEvent.value |
AuthenticationMethodReference.code |
AuthenticationMethodReference.value |
GenerateLinkType.fromString |
GenerateLinkType.fromValue |
OAuthClientType.fromString |
OAuthClientType.fromValue |
OAuthClientRegistrationType.fromString |
OAuthClientRegistrationType.fromValue |
CustomProviderType.fromString |
CustomProviderType.fromValue |
storage_client:
| Before | After |
|---|---|
StorageFileApi.info() |
StorageFileApi.getMetadata() |
VectorBucketEncryption.sseType |
serverSideEncryptionType |
PartitionSpec and the Iceberg spec cluster |
PartitionSpecification, …Specification… |
TableMetadata.refs |
TableMetadata.references |
TableField.doc |
TableField.documentation |
TableSnapshotScope.value |
TableSnapshotScope.name |
These renames also change a type:
| Before | After |
|---|---|
RealtimeClient.heartbeatIntervalMs (int) |
heartbeatInterval (Duration) |
RealtimeConstants.defaultHeartbeatIntervalMs (int) |
defaultHeartbeatInterval (Duration) |
RealtimeClient.reconnectAfterMs (int return) |
reconnectAfter (Duration return) |
SnapshotReference.maxReferenceAgeMs / maxSnapshotAgeMs (int?) |
maxReferenceAge / maxSnapshotAge (Duration?) |
Snapshot.timestampMs / TableMetadata.lastUpdatedMs (int) |
timestamp / lastUpdated (DateTime, UTC) |
Retry used to be configured differently in every client: PostgREST took three
separate parameters, storage took an int, and the auth token refresh had no
knobs at all. All three take the same SupabaseRetryOptions now, which carries
enabled, count, initialDelay, maxDelay and randomizationFactor. What
counts as a retryable failure stays with each client, since those are not
interchangeable: PostgREST repeats a read that answered with 503 or 520,
storage repeats an upload that hit a network error, and auth repeats a token
refresh that never reached the service.
| Before | After |
|---|---|
PostgrestClient(retryEnabled: …, retryCount: …) |
PostgrestClient(retryOptions: …) |
PostgrestClientOptions(retryEnabled: …, retryCount: …) |
PostgrestClientOptions(retryOptions: …) |
PostgrestBuilder, PostgrestQueryBuilder and PostgrestRpcBuilder constructors, same parameters |
retryOptions: … |
SupabaseStorageClient(retryAttempts: 5) |
SupabaseStorageClient(retryOptions: SupabaseRetryOptions(count: 5)) |
StorageClientOptions(retryAttempts: 5) |
StorageClientOptions(retryOptions: SupabaseRetryOptions(count: 5)) |
upload(…, retryAttempts: 5) and the same parameter on uploadBinary, uploadToSignedUrl, uploadBinaryToSignedUrl, update and updateBinary |
retryOptions: SupabaseRetryOptions(count: 5) |
// Before
postgrestOptions: const PostgrestClientOptions(retryCount: 5),
storageOptions: const StorageClientOptions(retryAttempts: 5),
// After
postgrestOptions: const PostgrestClientOptions(
retryOptions: SupabaseRetryOptions(count: 5),
),
storageOptions: const StorageClientOptions(
retryOptions: SupabaseRetryOptions(count: 5),
),count is the number of retries after the first attempt, so count: 0 sends a
request exactly once. The old storage retryAttempts counted the same way, so
the number carries over unchanged.
The auth token refresh is configurable for the first time, through
AuthClientOptions.retryOptions and AuthClient(retryOptions: …). The refresh
still stops retrying once the next backoff would fall after the next refresh
tick, so the count only caps how many attempts a short backoff can squeeze into
that window.
The per-request PostgrestBuilder.retry() override is unchanged.
One curve is used everywhere now: the first retry waits 400 ms, every retry after that waits twice as long up to 30 seconds, and each delay is randomized by up to 25% so that many clients do not retry in lockstep. Only how many retries are made differs, and only where it has to.
| Client | Before | After |
|---|---|---|
postgrest |
3 retries, 1s doubling to 30s, no jitter | 3 retries on the shared curve |
supabase_storage |
opt-in, 400ms doubling to 30s, 25% jitter | unchanged, still opt-in with count: 0 |
supabase_auth |
400ms doubling to 10s, no jitter | shared curve, bounded by the refresh tick as before |
PostgREST reads therefore back off sooner than they did, and with jitter. Pass
your own SupabaseRetryOptions to keep the old curve:
postgrestOptions: const PostgrestClientOptions(
retryOptions: SupabaseRetryOptions(
initialDelay: Duration(seconds: 1),
randomizationFactor: 0,
),
),503 Service Unavailable and 520 Unknown Error are the only responses worth
repeating, so the set of retried status codes is fixed. Retrying anything else,
a 500 from a failing query for example, only multiplies the load without a
chance of a different answer.
| Before | After |
|---|---|
PostgrestClient(retryableStatusCodes: …) |
removed |
PostgrestClientOptions(retryableStatusCodes: …) |
removed |
PostgrestClient.defaultRetryableStatusCodes |
PostgrestClient.retryableStatusCodes |
If you retried a status code outside that set, catch the exception and decide what to do with it yourself:
// Before
postgrestOptions: const PostgrestClientOptions(
retryableStatusCodes: {500, 503, 520},
),
// After
try {
await supabase.from('todos').select();
} on PostgrestApiException catch (error) {
if (error.statusCode == 500) {
// Retry it yourself, or surface it.
}
}PostgrestClient.setAccessToken(), SupabaseStorageClient.setAccessToken() and
FunctionsClient.setAccessToken() are removed. RealtimeClient.setAccessToken() stays.
Despite the name, these three pinned a token rather than kept one in sync. SupabaseClient gives
the rest, storage and functions clients an HTTP client that attaches the current session token to
every request, but only when the request does not already carry an Authorization header. A token
set through setAccessToken did carry one, so it won, and nothing ever cleared it: it kept
overriding the session token across refreshes and sign-outs for the rest of the client's life.
If you never called them, nothing changes. If you did, the replacement depends on what you were after.
To authenticate as the signed-in user, do nothing. SupabaseClient already resolves that token on
every request.
On a client you construct yourself, pass an accessToken callback. It is resolved before every
request, so a token that rotates is picked up without you pushing the new value anywhere. This is
the closest replacement for the old setter, and unlike it, it does not go stale:
// Before
final functions = FunctionsClient(functionsUrl, {'apikey': anonKey});
functions.setAccessToken(jwt);
// After
final functions = FunctionsClient(
functionsUrl,
{'apikey': anonKey},
accessToken: () async => currentJwt,
);PostgrestClient and SupabaseStorageClient take the same callback. If the token never changes,
a constructor header is still enough:
final functions = FunctionsClient(functionsUrl, {
'apikey': anonKey,
'Authorization': 'Bearer $jwt',
});Passing both an Authorization header and accessToken asserts, because the header would win on
every request and the callback would never be used.
To use a different token for a single call, pass it to that call:
await functions.invoke('hello', headers: {'Authorization': 'Bearer $jwt'});
await postgrest.from('countries').select().setHeader('Authorization', 'Bearer $jwt');To pin a token on a client you got from SupabaseClient, set the header yourself. SupabaseClient
builds its sub-clients, so there is no accessToken callback to pass:
supabase.storage.setHeader('Authorization', 'Bearer $jwt');
supabase.functions.setHeader('Authorization', 'Bearer $jwt');The rest client is stateless and its header map unmodifiable (see
the stateless rest client), so a pinned token is
passed per request there, or client-wide through the headers setter of SupabaseClient:
await supabase.from('countries').select().setHeader('Authorization', 'Bearer $jwt');
supabase.headers = {...supabase.headers, 'Authorization': 'Bearer $jwt'};That shadows the session token exactly as the old setter did, so remove the header again once the pinned token should no longer apply.
Realtime keeps its setter because it holds a live socket and has to push a new token over it rather than attach one per request.
PostgrestClient no longer holds any mutable state, and the query builder is no longer awaitable
before a table operation has been chosen.
| Before | After |
|---|---|
supabase.rest.headers['X-Foo'] = 'bar' |
supabase.headers = {...supabase.headers, 'X-Foo': 'bar'} |
postgrest.headers['X-Foo'] = 'bar' |
pass the header to the PostgrestClient constructor, or use setHeader() per request |
await supabase.from('countries') compiled and threw an ArgumentError at runtime |
does not compile |
SupabaseQuerySchema(headers: …) |
removed, the headers of the rest client are used |
PostgrestQueryBuilder(method: …, abortSignal: …) and PostgrestRpcBuilder(abortSignal: …) |
removed, both belong to the executable builder returned by a table operation or by rpc() |
PostgrestBuilder.appendSearchParameters() and overrideSearchParameters() |
removed, internal URL helpers that leaked into the public API |
PostgrestQueryBuilder<T> |
PostgrestQueryBuilder, the type argument only mattered when the builder was awaitable |
PostgrestClient.headers is now an unmodifiable map. The client never changes after construction,
which makes it safe to share across requests and removes a class of bugs where one call site's
header mutation leaked into every later request. Set headers where they belong instead: on the
constructor for all requests, or with setHeader() on a builder for a single request.
On SupabaseClient, rest is no longer a mutable singleton for the same reason. Assigning
supabase.headers replaces the rest client with one carrying the new headers, so reads through
supabase.rest.headers stay correct, but in-place mutation of that map now throws an
UnsupportedError. A PostgrestClient reference captured before the assignment keeps the headers
it was built with, and the same holds for the SupabaseQuerySchema returned by
supabase.schema(…), which wraps the rest client it was created with. Read supabase.rest or call
supabase.schema(…) again (and create new builders) after changing supabase.headers.
supabase.rpc() used to permanently merge the client headers into the rest client on every call;
that mutation is gone along with the state it leaked into.
Since the query builder is no longer awaitable, its type argument no longer means anything and is
gone: insert(), upsert(), update() and delete() without a trailing select() now resolve
to void everywhere, where supabase.from() used to yield dynamic and a standalone
PostgrestClient void. Code that assigned that value was reading null; drop the assignment or
add select() to actually return data.
A query builder that has not chosen a table operation is meaningless as a request, so
supabase.from('countries') by itself no longer implements Future and cannot be awaited,
converted with withConverter(), or given an abortSignal(). Call select(), insert(),
upsert(), update(), delete() or count() first; everything after that point is unchanged.
setHeader() and retry() remain available before the operation, since they configure whichever
request follows:
// Before: compiled, but threw an ArgumentError at runtime.
await supabase.from('countries');
// After: does not compile. Choose an operation first.
await supabase.from('countries').select();The auth, functions and storage clients used to hand out their internal header map, so any caller
could rewrite the headers of a client from anywhere, including the sub-clients a SupabaseClient
manages. Every headers getter now returns an unmodifiable view, the same way
PostgrestClient.headers and RealtimeClient.headers already did.
| Before | After |
|---|---|
supabase.auth.headers['X-Foo'] = 'bar' |
supabase.headers = {...supabase.headers, 'X-Foo': 'bar'} |
supabase.functions.headers['X-Foo'] = 'bar' |
supabase.headers = {...supabase.headers, 'X-Foo': 'bar'} |
supabase.storage.headers['X-Foo'] = 'bar' |
supabase.storage.setHeader('X-Foo', 'bar') |
storage.from('bucket').headers['X-Foo'] = 'bar' |
storage.from('bucket').setHeader('X-Foo', 'bar') |
authClient.headers['X-Foo'] = 'bar' |
authClient.setHeader('X-Foo', 'bar') |
functionsClient.headers['X-Foo'] = 'bar' |
functionsClient.setHeader('X-Foo', 'bar') |
Mutating one of those maps now throws an UnsupportedError. To add a single header, the auth,
functions and storage clients have a setHeader() method, and storage has one per bucket as well.
A whole set of headers is passed to the constructor. For a client managed by a SupabaseClient,
assign SupabaseClient.headers, which propagates the new headers to every sub-client at once.
SupabaseStorageClient.vectors is a getter that builds a client on each access instead of a cached
instance, so it always carries the current headers. Hold on to the returned client only for as long
as its headers should stay fixed.
Supabase.initialize no longer takes a debug flag and never prints anything to the console. All
packages still emit their records through package:logging
under the supabase logger hierarchy, but whether, where, and at which level those records are
handled is now entirely up to the application.
// Before
await Supabase.initialize(
url: supabaseUrl,
publishableKey: supabaseKey,
debug: true,
);
// After
Logger.root.onRecord.listen((record) {
if (record.loggerName.startsWith('supabase.')) {
debugPrint('${record.loggerName}: ${record.level.name}: '
'${record.message} ${record.error ?? ''}');
}
});
await Supabase.initialize(
url: supabaseUrl,
publishableKey: supabaseKey,
);See the Logging section of the supabase_flutter README for level filtering with
hierarchicalLoggingEnabled.
Two logger names changed, so update any listeners that filter on LogRecord.loggerName:
| Before | After |
|---|---|
supabase.supabase |
supabase.dart |
supabase.supabase_flutter |
supabase.flutter |
SupabaseClient, PostgrestClient and FunctionsClient used to take a YAJsonIsolate
through an isolate: parameter, which named an implementation rather than a contract, and
named one that does not spawn an isolate at all on web. They now take an AsyncJsonCodec
through jsonCodec:, the interface YAJsonIsolate implements. The same rename applies to
the builders that carry the codec through the chain: PostgrestBuilder,
PostgrestQueryBuilder, PostgrestRpcBuilder, RawPostgrestBuilder,
SupabaseQueryBuilder and SupabaseQuerySchema.
// Before
final client = SupabaseClient(url, key, isolate: YAJsonIsolate()..initialize());
// After
final client = SupabaseClient(url, key, jsonCodec: YAJsonIsolate()..initialize());Supabase.initialize takes the codec too, so a supabase_flutter application never has to
construct a SupabaseClient to choose one:
await Supabase.initialize(
url: url,
publishableKey: publishableKey,
jsonCodec: myJsonCodec,
);AsyncJsonCodec is exported from postgrest, supabase_functions, supabase and
supabase_flutter, so an application can encode and decode JSON its own way, for example
through a native parser or through a wrapper that records how long each payload takes:
class TimedJsonCodec implements AsyncJsonCodec {
TimedJsonCodec(this._inner);
final AsyncJsonCodec _inner;
@override
Future<dynamic> decode(String json) => _time(() => _inner.decode(json));
@override
Future<dynamic> decodeBytes(Uint8List encodedJson) =>
_time(() => _inner.decodeBytes(encodedJson));
@override
Future<String> encode(Object? json) => _time(() => _inner.encode(json));
@override
Future<void> dispose() => _inner.dispose();
}A codec passed to a client belongs to the caller, so dispose() leaves it alone, exactly
as the old isolate: parameter did. A client that was not given one creates the default
codec and disposes it with itself. SupabaseClient passes its codec on to the rest and
functions clients it builds, so one codec serves all three.
YAJsonIsolate is not exported by supabase or supabase_flutter, so depend on
yet_another_json_isolate directly to name the default implementation, for example to wrap
it as above.
The realtime client had a second logging path next to package:logging: a logger callback
constructor parameter and a public log method. Both are removed. Realtime diagnostics are
emitted on the supabase.realtime logger, so listen there instead.
// Before
final client = RealtimeClient(
realtimeUrl,
logger: (kind, message, data) => print('$kind: $message $data'),
);
// After
hierarchicalLoggingEnabled = true;
Logger('supabase.realtime').onRecord.listen((record) {
print('${record.level.name}: ${record.message} ${record.error ?? ''}');
});
final client = RealtimeClient(realtimeUrl);Without hierarchicalLoggingEnabled = true, package:logging resolves the onRecord stream of a
non-root logger to Logger.root.onRecord, which receives records from every logger in the
application; in that case listen on Logger.root and filter on LogRecord.loggerName instead.
Three enums described the same ascending or descending direction: BucketSortOrder and
FileSortOrder in supabase_storage, and SortDirection in iceberg. They are now one
SortDirection, shared by every package. The values are unchanged, so only the type name moves.
| Before | After |
|---|---|
BucketSortOrder |
SortDirection |
FileSortOrder |
SortDirection |
SortDirection |
unchanged |
// Before
await supabase.storage.listBuckets(
const ListBucketsOptions(sortOrder: BucketSortOrder.descending),
);
// After
await supabase.storage.listBuckets(
const ListBucketsOptions(sortOrder: SortDirection.descending),
);StorageFileApi.list() took its direction as a String, so a typo such as 'ascending' only
surfaced as a 400 from the storage server. SortBy.order is a SortDirection too, non-nullable
with SortDirection.ascending as its default:
// Before
await supabase.storage.from('bucket').list(
searchOptions: const SearchOptions(
sortBy: SortBy(column: 'created_at', order: 'desc'),
),
);
// After
await supabase.storage.from('bucket').list(
searchOptions: const SearchOptions(
sortBy: SortBy(column: 'created_at', order: SortDirection.descending),
),
);SortBy(order: null) no longer compiles; leave order out to sort ascending. column is
unchanged, since list() accepts any column of a FileObject.
Session.expiresAt is late final, so it can be read but no longer assigned. It has always been
derived from the exp claim of the access token rather than the login response body, so
overwriting it only desynchronized the value from the token that the auto refresh logic acts on.
Mint a new session, or call copyWith with a different accessToken, to change the expiry:
// Before
session.expiresAt = DateTime.now().add(const Duration(hours: 1));
// After
final refreshed = session.copyWith(accessToken: newAccessToken);
print(refreshed.expiresAt);ResendResponse.messageId is final too, matching every other response type.
The request fields on UserAttributes and AdminUserAttributes are final, so pass them to the
constructor instead of assigning after construction:
// Before
final attributes = UserAttributes();
attributes.email = 'new@example.com';
attributes.data = {'name': 'Alice'};
await supabase.auth.updateUser(attributes);
// After
await supabase.auth.updateUser(
UserAttributes(email: 'new@example.com', data: {'name': 'Alice'}),
);